feat: notification chimes for room join / peer join / peer leave (phase 1)
Adds audible notifications for the basic membership events: a bright ascending triad when you join a room, a soft two-note rise when another participant joins, and a two-note fall when one leaves. Hooked into the app's UiEvent handling (RoomJoined / PeerJoined / PeerLeft) so each is played locally by whoever observes the event. The chimes are short mono 16-bit WAVs generated by a committed stdlib-only Python script (assets/sounds/generate_chimes.py) and embedded in the binary with include_bytes!, so a deployed single binary is self-contained. The new notify module materializes each to a temp file once, then plays it fire-and-forget via pw-play (PipeWire-native; falls back to paplay/aplay) on a detached thread that waits on the child -- never blocks the UI, never leaves a zombie, and silently no-ops if no player is available. Phase 1 of a larger plan; always-on for now. Later phases: reconnect-attempt / reconnected sounds, a settings enable/disable toggle, per-event custom sounds, and extra events (self-leave, mute/unmute, connection-lost). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
//! Notification chimes for room events (join / peer-join / peer-leave, with more
|
||||
//! events in later phases).
|
||||
//!
|
||||
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
||||
//! binary is self-contained — no asset directory to ship alongside it. On first
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget
|
||||
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
|
||||
//! on a detached thread that waits on the child, so it never blocks the UI and
|
||||
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
|
||||
//! a missing chime should never disrupt a call.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// A notification event with a distinct chime.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Sound {
|
||||
/// You joined a room.
|
||||
SelfJoin,
|
||||
/// Another participant joined.
|
||||
PeerJoin,
|
||||
/// A participant left.
|
||||
PeerLeave,
|
||||
}
|
||||
|
||||
impl Sound {
|
||||
/// Embedded WAV bytes for this sound.
|
||||
fn bytes(self) -> &'static [u8] {
|
||||
match self {
|
||||
Sound::SelfJoin => include_bytes!("../assets/sounds/self-join.wav"),
|
||||
Sound::PeerJoin => include_bytes!("../assets/sounds/peer-join.wav"),
|
||||
Sound::PeerLeave => include_bytes!("../assets/sounds/peer-leave.wav"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable file stem for the cached temp file.
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Sound::SelfJoin => "self-join",
|
||||
Sound::PeerJoin => "peer-join",
|
||||
Sound::PeerLeave => "peer-leave",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Play a notification chime, fire-and-forget. Never blocks; never errors out.
|
||||
pub fn play(sound: Sound) {
|
||||
let Some(path) = cached_path(sound) else {
|
||||
return;
|
||||
};
|
||||
std::thread::spawn(move || spawn_player(&path));
|
||||
}
|
||||
|
||||
/// Materialize the embedded WAV to a temp file the first time it's needed and
|
||||
/// cache the path. Returns `None` only if the file can't be written.
|
||||
fn cached_path(sound: Sound) -> Option<PathBuf> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<&'static str, PathBuf>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
|
||||
let mut guard = cache.lock().unwrap();
|
||||
if let Some(path) = guard.get(sound.name()) {
|
||||
return Some(path.clone());
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name()));
|
||||
if std::fs::write(&path, sound.bytes()).is_err() {
|
||||
return None;
|
||||
}
|
||||
guard.insert(sound.name(), path.clone());
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Try each available player in turn, waiting on the first that starts (which
|
||||
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
.arg(path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
// `status()` errors only if the player binary isn't present; on a real
|
||||
// playback error it still returns (non-zero), so a started player ends
|
||||
// the loop either way — we don't want to double-play through fallbacks.
|
||||
if started.is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user