use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Mutex, OnceLock};
const SAMPLE_RATE: u32 = 44_100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
Low,
High,
Stale,
}
pub fn alarm(tone: Tone) {
std::thread::spawn(move || {
let _ = sound_check(tone);
});
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Played {
Player(&'static str),
Bell,
Nothing,
}
pub fn sound_check(tone: Tone) -> Played {
let Some(path) = wav_path(tone) else {
bell();
return Played::Nothing;
};
match play(&path) {
Some(prog) => Played::Player(prog),
None => {
bell();
Played::Bell
}
}
}
fn wav_path(tone: Tone) -> Option<PathBuf> {
static PATHS: OnceLock<[Option<PathBuf>; 3]> = OnceLock::new();
let paths = PATHS.get_or_init(|| {
let Some(dir) = private_audio_dir() else {
return [None, None, None];
};
[Tone::Low, Tone::High, Tone::Stale].map(|t| {
let path = dir.join(format!("sugarrush-alarm-{}.wav", t.suffix()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&path).ok()?;
std::io::Write::write_all(&mut file, &alarm_wav(t)).ok()?;
Some(path)
})
});
paths[tone.index()].clone()
}
fn private_audio_dir() -> Option<PathBuf> {
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
#[cfg(unix)]
{
let uid = dirs::home_dir()
.and_then(|p| std::fs::metadata(p).ok())
.map(|m| m.uid())
.unwrap_or(0);
std::env::temp_dir().join(format!("sugarrush-{uid}"))
}
#[cfg(not(unix))]
std::env::temp_dir().join("sugarrush")
})
.join("sugarrush-audio");
std::fs::create_dir_all(&base).ok()?;
#[cfg(unix)]
{
let ours = dirs::home_dir()
.and_then(|p| std::fs::metadata(p).ok())
.is_some_and(|home| std::fs::metadata(&base).is_ok_and(|dir| dir.uid() == home.uid()));
if !ours {
return None;
}
std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)).ok()?;
}
Some(base)
}
impl Tone {
fn index(self) -> usize {
match self {
Tone::Low => 0,
Tone::High => 1,
Tone::Stale => 2,
}
}
fn suffix(self) -> &'static str {
match self {
Tone::Low => "low",
Tone::High => "high",
Tone::Stale => "stale",
}
}
fn freqs(self) -> [f64; 4] {
match self {
Tone::Low => [1320.0, 1100.0, 880.0, 660.0],
Tone::High => [660.0, 880.0, 1100.0, 1320.0],
Tone::Stale => [880.0, 0.0, 880.0, 0.0],
}
}
}
static PLAYERS: Mutex<Vec<Child>> = Mutex::new(Vec::new());
static FAILED: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
static WORKING: Mutex<Option<&'static str>> = Mutex::new(None);
const STARTUP_POLL: std::time::Duration = std::time::Duration::from_millis(15);
const STARTUP_CHECKS: usize = 10;
fn reap(players: &mut Vec<Child>) {
players.retain_mut(|c| matches!(c.try_wait(), Ok(None)));
}
fn produced_sound(child: &mut Child) -> bool {
for _ in 0..STARTUP_CHECKS {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => std::thread::sleep(STARTUP_POLL),
Err(_) => return false,
}
}
true
}
fn play(path: &Path) -> Option<&'static str> {
let candidates: [(&str, &[&str]); 7] = [
("paplay", &[]),
("pw-play", &[]),
("aplay", &["-q"]),
("ffplay", &["-nodisp", "-autoexit", "-loglevel", "quiet"]),
("canberra-gtk-play", &["-f"]), ("afplay", &[]), ("cvlc", &["--play-and-exit", "--intf", "dummy"]),
];
let known_good = WORKING.lock().ok().and_then(|w| *w);
for (prog, args) in candidates {
if FAILED.lock().is_ok_and(|f| f.contains(&prog)) {
continue;
}
let mut cmd = Command::new(prog);
if prog == "canberra-gtk-play" {
cmd.arg(format!("--file={}", path.display()));
} else {
cmd.args(args).arg(path);
}
let spawned = cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
let Ok(mut child) = spawned else { continue };
if known_good != Some(prog) && !produced_sound(&mut child) {
if let Ok(mut failed) = FAILED.lock() {
failed.push(prog);
}
continue;
}
if let Ok(mut working) = WORKING.lock() {
*working = Some(prog);
}
if let Ok(mut players) = PLAYERS.lock() {
reap(&mut players);
players.push(child);
}
return Some(prog);
}
None
}
fn bell() {
use std::io::Write;
let mut out = std::io::stdout();
let _ = out.write_all(b"\x07");
let _ = out.flush();
}
fn alarm_wav(tone: Tone) -> Vec<u8> {
let mut samples: Vec<i16> = Vec::new();
for freq in tone.freqs() {
let n = SAMPLE_RATE as usize * 110 / 1000;
for i in 0..n {
let t = i as f64 / SAMPLE_RATE as f64;
let fade_len = (SAMPLE_RATE as f64 * 0.004) as usize;
let amp = if i < fade_len {
i as f64 / fade_len as f64
} else if i > n - fade_len {
(n - i) as f64 / fade_len as f64
} else {
1.0
};
let s = (t * freq * std::f64::consts::TAU).sin() * amp * 0.5;
samples.push((s * i16::MAX as f64) as i16);
}
}
encode_wav(&samples)
}
fn encode_wav(samples: &[i16]) -> Vec<u8> {
let data_len = (samples.len() * 2) as u32;
let mut out = Vec::with_capacity(44 + data_len as usize);
let byte_rate = SAMPLE_RATE * 2;
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(36 + data_len).to_le_bytes());
out.extend_from_slice(b"WAVE");
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
out.extend_from_slice(&byte_rate.to_le_bytes());
out.extend_from_slice(&2u16.to_le_bytes()); out.extend_from_slice(&16u16.to_le_bytes()); out.extend_from_slice(b"data");
out.extend_from_slice(&data_len.to_le_bytes());
for &s in samples {
out.extend_from_slice(&s.to_le_bytes());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wav_has_valid_header() {
let wav = alarm_wav(Tone::Low);
assert_eq!(&wav[0..4], b"RIFF");
assert_eq!(&wav[8..12], b"WAVE");
assert_eq!(&wav[36..40], b"data");
let declared = u32::from_le_bytes([wav[40], wav[41], wav[42], wav[43]]) as usize;
assert_eq!(declared, wav.len() - 44);
}
#[test]
fn alarm_files_use_a_private_directory() {
let dir = private_audio_dir().expect("a private runtime directory");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(dir).unwrap().permissions().mode() & 0o777,
0o700
);
}
}
#[test]
fn alarm_player_discovery_does_not_block_the_caller() {
let started = std::time::Instant::now();
alarm(Tone::Stale);
assert!(started.elapsed() < std::time::Duration::from_millis(100));
}
#[test]
fn a_player_that_exits_nonzero_did_not_produce_sound() {
let mut child = Command::new("sh")
.args(["-c", "exit 1"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("sh should exist");
assert!(
!produced_sound(&mut child),
"a failing player was counted as a sounded alarm"
);
}
#[test]
fn a_player_still_running_counts_as_sound() {
let mut child = Command::new("sh")
.args(["-c", "sleep 2"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("sh should exist");
assert!(produced_sound(&mut child));
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn a_player_that_exits_cleanly_counts_as_sound() {
let mut child = Command::new("sh")
.args(["-c", "exit 0"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("sh should exist");
assert!(produced_sound(&mut child));
}
}