diff --git a/assets/sounds/generate_chimes.py b/assets/sounds/generate_chimes.py new file mode 100644 index 0000000..ee38c70 --- /dev/null +++ b/assets/sounds/generate_chimes.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Generate PeerSpeak's default notification chimes as small mono 16-bit WAVs. + +Stdlib only (wave/struct/math) so there's no build-time dependency. Re-run to +regenerate the committed assets: + + python3 assets/sounds/generate_chimes.py + +Each chime is a short sequence of sine notes with a soft attack (no click) and +an exponential decay (a gentle bell-like tail), plus a quiet second harmonic so +it reads as a "chime" rather than a flat beep. Distinct melodic shapes make the +events recognizable without looking: ascending = arrival, descending = leaving. +""" +import math +import os +import struct +import wave + +RATE = 48000 # match PeerSpeak's audio graph +AMPLITUDE = 0.38 # clearly audible, not harsh (fraction of full scale) +ATTACK_S = 0.006 # short fade-in to kill the start click +DECAY = 7.0 # exponential decay rate (higher = shorter tail) + +# Note frequencies (Hz) +G4, C5, E5, G5 = 392.00, 523.25, 659.25, 783.99 + + +def note(freq, dur): + """One enveloped note: fundamental + soft 2nd harmonic, attack + decay.""" + n = int(RATE * dur) + out = [] + for i in range(n): + t = i / RATE + env = math.exp(-DECAY * t) + if t < ATTACK_S: + env *= t / ATTACK_S + sample = math.sin(2 * math.pi * freq * t) + 0.3 * math.sin(2 * math.pi * 2 * freq * t) + out.append(AMPLITUDE * env * sample / 1.3) + return out + + +def chime(notes): + """Concatenate (freq, dur) notes into one sample buffer.""" + buf = [] + for freq, dur in notes: + buf.extend(note(freq, dur)) + return buf + + +def write_wav(path, samples): + with wave.open(path, "w") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(RATE) + frames = b"".join( + struct.pack(" Task { state.self_id = self_id; state.status_message = "Connected".to_string(); state.current_screen = Screen::Room; + notify::play(Sound::SelfJoin); } UiEvent::RoomLeft => { state.ticket = "".to_string(); @@ -220,12 +222,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } UiEvent::PeerJoined { id, state: peer_state } => { state.peers.insert(id, peer_state); + notify::play(Sound::PeerJoin); } UiEvent::PeerLeft { id } => { state.peers.remove(&id); state.audio_levels.remove(&id); state.connecting.remove(&id); state.ever_connected.remove(&id); + notify::play(Sound::PeerLeave); } UiEvent::PeerUpdated { id, state: peer_state } => { state.peers.insert(id, peer_state); diff --git a/src/lib.rs b/src/lib.rs index 5007d6c..11671f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod network; pub mod core; pub mod app; pub mod config; +pub mod notify; use std::path::PathBuf; use std::sync::OnceLock; diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..644ed2b --- /dev/null +++ b/src/notify.rs @@ -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 { + static CACHE: OnceLock>> = 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; + } + } +}