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,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("<h", max(-32767, min(32767, int(s * 32767)))) for s in samples
|
||||||
|
)
|
||||||
|
w.writeframes(frames)
|
||||||
|
print(f"wrote {path} ({len(samples) / RATE * 1000:.0f} ms)")
|
||||||
|
|
||||||
|
|
||||||
|
CHIMES = {
|
||||||
|
# You joined a room: bright ascending triad — welcoming.
|
||||||
|
"self-join.wav": [(C5, 0.11), (E5, 0.11), (G5, 0.30)],
|
||||||
|
# Someone else joined: gentle two-note rise — softer than your own join.
|
||||||
|
"peer-join.wav": [(G4, 0.13), (C5, 0.28)],
|
||||||
|
# Someone left: two-note fall — the mirror of an arrival.
|
||||||
|
"peer-leave.wav": [(C5, 0.13), (G4, 0.28)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
for name, notes in CHIMES.items():
|
||||||
|
write_wav(os.path.join(here, name), chime(notes))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
|
use crate::notify::{self, Sound};
|
||||||
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
||||||
use crate::config::{AppConfig, NetworkMode};
|
use crate::config::{AppConfig, NetworkMode};
|
||||||
|
|
||||||
@@ -208,6 +209,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.self_id = self_id;
|
state.self_id = self_id;
|
||||||
state.status_message = "Connected".to_string();
|
state.status_message = "Connected".to_string();
|
||||||
state.current_screen = Screen::Room;
|
state.current_screen = Screen::Room;
|
||||||
|
notify::play(Sound::SelfJoin);
|
||||||
}
|
}
|
||||||
UiEvent::RoomLeft => {
|
UiEvent::RoomLeft => {
|
||||||
state.ticket = "".to_string();
|
state.ticket = "".to_string();
|
||||||
@@ -220,12 +222,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
}
|
}
|
||||||
UiEvent::PeerJoined { id, state: peer_state } => {
|
UiEvent::PeerJoined { id, state: peer_state } => {
|
||||||
state.peers.insert(id, peer_state);
|
state.peers.insert(id, peer_state);
|
||||||
|
notify::play(Sound::PeerJoin);
|
||||||
}
|
}
|
||||||
UiEvent::PeerLeft { id } => {
|
UiEvent::PeerLeft { id } => {
|
||||||
state.peers.remove(&id);
|
state.peers.remove(&id);
|
||||||
state.audio_levels.remove(&id);
|
state.audio_levels.remove(&id);
|
||||||
state.connecting.remove(&id);
|
state.connecting.remove(&id);
|
||||||
state.ever_connected.remove(&id);
|
state.ever_connected.remove(&id);
|
||||||
|
notify::play(Sound::PeerLeave);
|
||||||
}
|
}
|
||||||
UiEvent::PeerUpdated { id, state: peer_state } => {
|
UiEvent::PeerUpdated { id, state: peer_state } => {
|
||||||
state.peers.insert(id, peer_state);
|
state.peers.insert(id, peer_state);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod network;
|
|||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod notify;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|||||||
@@ -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