feat: sound notification chimes for connection lifecycle events

Audible chimes for reconnect/reconnected/reconnect-failed, self-leave,
peer join/leave, and mic/deafen toggle, completing the 4-phase
notification plan (phase 1 room/peer join+leave shipped in 673b72d).

- Per-event custom WAV overrides in Settings, with ~ expansion and live
  file-found/not-found validation; persisted on settings exit. Each event
  falls back to its embedded default chime when no custom path is set.
- Reconnect-attempt chime is edge-triggered: fires once per disconnect,
  not once per redial attempt.
- Global "enable sound notifications" toggle.
This commit is contained in:
2026-06-01 03:31:12 -04:00
parent bddd1a0e6e
commit 2aec154f07
11 changed files with 270 additions and 33 deletions
+10
View File
@@ -66,6 +66,16 @@ CHIMES = {
"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)],
# Reconnecting to peer: soft single Bb4 note.
"reconnect-attempt.wav": [(466.16, 0.20)],
# Reconnected to peer: bright two-note rise (C5 -> G5).
"reconnected.wav": [(C5, 0.10), (G5, 0.25)],
# Left a room: descending triad.
"self-leave.wav": [(G5, 0.10), (E5, 0.10), (C5, 0.25)],
# Mute/unmute toggle: tiny clean blip.
"mic-toggle.wav": [(E5, 0.08)],
# Reconnect gave up: disappointing low two-note fall.
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+126 -27
View File
@@ -45,6 +45,8 @@ pub enum AppMessage {
EventOccurred(Event),
NavigateToSettings,
NavigateBack,
ToggleNotifications(bool),
CustomSoundPathChanged(Sound, String),
}
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
@@ -90,6 +92,22 @@ pub struct AppState {
current_screen: Screen,
}
impl AppState {
fn custom_sound_path(&self, sound: Sound) -> &str {
let opt = match sound {
Sound::SelfJoin => &self.config.custom_sound_self_join,
Sound::PeerJoin => &self.config.custom_sound_peer_join,
Sound::PeerLeave => &self.config.custom_sound_peer_leave,
Sound::ReconnectAttempt => &self.config.custom_sound_reconnect_attempt,
Sound::Reconnected => &self.config.custom_sound_reconnected,
Sound::SelfLeave => &self.config.custom_sound_self_leave,
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
};
opt.as_deref().unwrap_or("")
}
}
impl Default for AppState {
fn default() -> Self {
let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(100);
@@ -97,6 +115,7 @@ impl Default for AppState {
let _ = UI_RX.set(Mutex::new(Some(ui_rx)));
let config = AppConfig::load();
notify::set_enabled(config.notifications_enabled);
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));
let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
let all_devices = enumerate_audio_devices();
@@ -197,10 +216,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::ToggleMutePressed => {
let _ = state.controller.send(CoreCommand::ToggleMute);
state.is_muted = !state.is_muted;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
}
AppMessage::ToggleDeafenPressed => {
let _ = state.controller.send(CoreCommand::ToggleDeafen);
state.is_deafened = !state.is_deafened;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
}
AppMessage::UiEventReceived(event) => {
match event {
@@ -209,7 +230,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.self_id = self_id;
state.status_message = "Connected".to_string();
state.current_screen = Screen::Room;
notify::play(Sound::SelfJoin);
notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref());
}
UiEvent::RoomLeft => {
state.ticket = "".to_string();
@@ -219,27 +240,44 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.ever_connected.clear();
state.status_message = "Ready to connect".to_string();
state.current_screen = Screen::Home;
notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref());
}
UiEvent::PeerJoined { id, state: peer_state } => {
state.peers.insert(id, peer_state);
notify::play(Sound::PeerJoin);
notify::play(Sound::PeerJoin, state.config.custom_sound_peer_join.as_deref());
}
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);
notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref());
}
UiEvent::PeerConnectionFailed { id } => {
state.peers.remove(&id);
state.audio_levels.remove(&id);
state.connecting.remove(&id);
state.ever_connected.remove(&id);
notify::play(Sound::ReconnectFailed, state.config.custom_sound_reconnect_failed.as_deref());
}
UiEvent::PeerUpdated { id, state: peer_state } => {
state.peers.insert(id, peer_state);
}
UiEvent::PeerConnecting { id } => {
let is_reconnect_attempt = state.ever_connected.contains(&id);
let was_already_connecting = state.connecting.contains(&id);
state.connecting.insert(id);
if is_reconnect_attempt && !was_already_connecting {
notify::play(Sound::ReconnectAttempt, state.config.custom_sound_reconnect_attempt.as_deref());
}
}
UiEvent::PeerConnected { id } => {
let was_reconnect = state.ever_connected.contains(&id);
state.connecting.remove(&id);
state.ever_connected.insert(id);
if was_reconnect {
notify::play(Sound::Reconnected, state.config.custom_sound_reconnected.as_deref());
}
}
UiEvent::AudioLevels(levels) => {
for (id, val) in levels {
@@ -288,6 +326,24 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Applied on the next join, since the endpoint is rebuilt then.
let _ = state.controller.send(CoreCommand::SetNetworkMode(mode));
}
AppMessage::ToggleNotifications(enabled) => {
state.config.notifications_enabled = enabled;
state.config.save();
notify::set_enabled(enabled);
}
AppMessage::CustomSoundPathChanged(sound, path) => {
let path_opt = if path.trim().is_empty() { None } else { Some(path) };
match sound {
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt,
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt,
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt,
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt,
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt,
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt,
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt,
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
}
}
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
if state.is_setting_hotkey {
state.ptt_hotkey = key.clone();
@@ -308,6 +364,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.current_screen = Screen::Settings;
}
AppMessage::NavigateBack => {
state.config.save();
if state.ticket.is_empty() {
state.current_screen = Screen::Home;
} else {
@@ -407,30 +464,50 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
].width(iced::Length::Fill).padding(10);
if state.current_screen == Screen::Settings {
let settings_box = container(
let path_field = |label: &'static str, sound: Sound| {
let path = state.custom_sound_path(sound);
let validation_widget = match notify::validate_custom_path(path) {
None => text(""),
Some(true) => text("✓ File found").size(10).color(color_green),
Some(false) => text("✗ File not found").size(10).color(color_red),
};
column![
row![
text(label).size(12).color(Color::from_rgb8(180, 180, 180)),
horizontal_space(),
validation_widget,
].align_y(iced::alignment::Vertical::Center),
text_input("Default (embedded)...", path)
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.style(t_style)
.padding(8)
].spacing(4).width(iced::Length::Fixed(320.0))
};
let settings_content = scrollable(
column![
text("Settings").size(24).color(color_blue),
vertical_space(20.0),
text("Device Settings").size(14).color(color_subtext),
row![
column![
text("Input Target").size(12).color(color_subtext),
pick_list(
&state.input_devices[..],
state.selected_input.clone(),
AppMessage::InputDeviceSelected,
).width(iced::Length::Fixed(160.0))
].spacing(4),
horizontal_space(),
column![
text("Output Target").size(12).color(color_subtext),
pick_list(
&state.output_devices[..],
state.selected_output.clone(),
AppMessage::OutputDeviceSelected,
).width(iced::Length::Fixed(160.0))
].spacing(4),
].spacing(10),
vertical_space(10.0),
column![
text("Input Device").size(12).color(Color::from_rgb8(180, 180, 180)),
pick_list(
&state.input_devices[..],
state.selected_input.as_ref(),
AppMessage::InputDeviceSelected,
).width(iced::Length::Fixed(320.0)),
].spacing(4).width(iced::Length::Fixed(320.0)),
vertical_space(10.0),
column![
text("Output Device").size(12).color(Color::from_rgb8(180, 180, 180)),
pick_list(
&state.output_devices[..],
state.selected_output.as_ref(),
AppMessage::OutputDeviceSelected,
).width(iced::Length::Fixed(320.0)),
].spacing(4).width(iced::Length::Fixed(320.0)),
vertical_space(20.0),
column![
text(format!("Mic Sensitivity (Noise Gate): {:.1}%", state.config.noise_gate_threshold * 100.0)).size(14).color(color_subtext),
@@ -447,6 +524,25 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
text("Takes effect on your next room join.").size(11).color(color_surface),
].spacing(6).width(iced::Length::Fixed(320.0)),
vertical_space(20.0),
column![
text("Notification Chimes").size(14).color(color_subtext),
checkbox(state.config.notifications_enabled)
.label("Enable sound notifications")
.on_toggle(AppMessage::ToggleNotifications),
].spacing(10).width(iced::Length::Fixed(320.0)),
vertical_space(20.0),
text("Custom Chime Files (WAV Paths)").size(14).color(color_subtext),
path_field("Self Join", Sound::SelfJoin),
path_field("Self Leave", Sound::SelfLeave),
path_field("Peer Join", Sound::PeerJoin),
path_field("Peer Leave", Sound::PeerLeave),
path_field("Reconnect Attempt", Sound::ReconnectAttempt),
path_field("Reconnected", Sound::Reconnected),
path_field("Mic Toggle", Sound::MicToggle),
path_field("Reconnect Failed", Sound::ReconnectFailed),
vertical_space(30.0),
button(
text("Back")
@@ -460,10 +556,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
]
.align_x(iced::alignment::Horizontal::Center)
.spacing(10)
)
.style(c_style(color_mantle, color_surface, 12.0))
.padding(30)
.width(420);
);
let settings_box = container(settings_content)
.style(c_style(color_mantle, color_surface, 12.0))
.padding(30)
.width(460)
.height(500);
let content = column![settings_box].align_x(iced::alignment::Horizontal::Center);
+31
View File
@@ -36,6 +36,10 @@ impl std::fmt::Display for NetworkMode {
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub input_device: String,
@@ -43,6 +47,24 @@ pub struct AppConfig {
pub noise_gate_threshold: f32,
#[serde(default)]
pub network_mode: NetworkMode,
#[serde(default = "default_true")]
pub notifications_enabled: bool,
#[serde(default)]
pub custom_sound_self_join: Option<String>,
#[serde(default)]
pub custom_sound_peer_join: Option<String>,
#[serde(default)]
pub custom_sound_peer_leave: Option<String>,
#[serde(default)]
pub custom_sound_reconnect_attempt: Option<String>,
#[serde(default)]
pub custom_sound_reconnected: Option<String>,
#[serde(default)]
pub custom_sound_self_leave: Option<String>,
#[serde(default)]
pub custom_sound_mic_toggle: Option<String>,
#[serde(default)]
pub custom_sound_reconnect_failed: Option<String>,
}
impl Default for AppConfig {
@@ -52,6 +74,15 @@ impl Default for AppConfig {
output_device: "".to_string(),
noise_gate_threshold: 0.01,
network_mode: NetworkMode::default(),
notifications_enabled: true,
custom_sound_self_join: None,
custom_sound_peer_join: None,
custom_sound_peer_leave: None,
custom_sound_reconnect_attempt: None,
custom_sound_reconnected: None,
custom_sound_self_leave: None,
custom_sound_mic_toggle: None,
custom_sound_reconnect_failed: None,
}
}
}
+1
View File
@@ -23,6 +23,7 @@ pub enum UiEvent {
RoomLeft,
PeerJoined { id: EndpointId, state: PeerState },
PeerLeft { id: EndpointId },
PeerConnectionFailed { id: EndpointId },
PeerUpdated { id: EndpointId, state: PeerState },
/// Audio link to a peer is being (re)established — show a connecting state.
PeerConnecting { id: EndpointId },
+1 -1
View File
@@ -510,7 +510,7 @@ async fn run_core_loop(
));
transport_evict.disconnect_peer(peer_id).await;
jitter_evict.lock().await.remove(&peer_id);
let _ = ui_evict.send(UiEvent::PeerLeft { id: peer_id }).await;
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
timers_evict.lock().unwrap().remove(&peer_id);
});
// Replace (and abort) any timer already pending for
+101 -5
View File
@@ -12,10 +12,24 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
static ENABLED: AtomicBool = AtomicBool::new(true);
/// Enable or disable all notification chimes globally.
pub fn set_enabled(enabled: bool) {
ENABLED.store(enabled, Ordering::Relaxed);
}
/// Check if notifications are enabled.
pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// A notification event with a distinct chime.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sound {
/// You joined a room.
SelfJoin,
@@ -23,6 +37,16 @@ pub enum Sound {
PeerJoin,
/// A participant left.
PeerLeave,
/// Reconnecting to a peer.
ReconnectAttempt,
/// Reconnected to a peer.
Reconnected,
/// You left a room.
SelfLeave,
/// Mic mute/unmute toggle.
MicToggle,
/// Reconnect failed / peer evicted.
ReconnectFailed,
}
impl Sound {
@@ -32,6 +56,11 @@ impl Sound {
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"),
Sound::ReconnectAttempt => include_bytes!("../assets/sounds/reconnect-attempt.wav"),
Sound::Reconnected => include_bytes!("../assets/sounds/reconnected.wav"),
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
}
}
@@ -41,16 +70,60 @@ impl Sound {
Sound::SelfJoin => "self-join",
Sound::PeerJoin => "peer-join",
Sound::PeerLeave => "peer-leave",
Sound::ReconnectAttempt => "reconnect-attempt",
Sound::Reconnected => "reconnected",
Sound::SelfLeave => "self-leave",
Sound::MicToggle => "mic-toggle",
Sound::ReconnectFailed => "reconnect-failed",
}
}
}
/// Helper to expand `~` at the start of a path to the user's home directory.
pub fn expand_tilde(path_str: &str) -> PathBuf {
let trimmed = path_str.trim();
if trimmed == "~" {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(trimmed))
} else if let Some(stripped) = trimmed.strip_prefix("~/") {
dirs::home_dir()
.map(|home| home.join(stripped))
.unwrap_or_else(|| PathBuf::from(trimmed))
} else {
PathBuf::from(trimmed)
}
}
/// Check if a custom path is valid (i.e. if it exists and is a file).
/// Returns `None` if empty/whitespace, `Some(true)` if valid, `Some(false)` if invalid.
pub fn validate_custom_path(path_str: &str) -> Option<bool> {
if path_str.trim().is_empty() {
None
} else {
let path = expand_tilde(path_str);
Some(path.exists() && path.is_file())
}
}
/// Play a notification chime, fire-and-forget. Never blocks; never errors out.
pub fn play(sound: Sound) {
let Some(path) = cached_path(sound) else {
pub fn play(sound: Sound, custom_path: Option<&str>) {
if !is_enabled() {
return;
};
std::thread::spawn(move || spawn_player(&path));
}
let custom_path = custom_path.map(String::from);
std::thread::spawn(move || {
if let Some(ref path_str) = custom_path
&& !path_str.trim().is_empty()
{
let path = expand_tilde(path_str);
if path.exists() && path.is_file() {
spawn_player(&path);
return;
}
}
if let Some(path) = cached_path(sound) {
spawn_player(&path);
}
});
}
/// Materialize the embedded WAV to a temp file the first time it's needed and
@@ -89,3 +162,26 @@ fn spawn_player(path: &Path) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_tilde() {
if let Some(home) = dirs::home_dir() {
assert_eq!(expand_tilde("~"), home);
assert_eq!(expand_tilde("~/foo/bar.wav"), home.join("foo/bar.wav"));
}
assert_eq!(expand_tilde("/absolute/path.wav"), PathBuf::from("/absolute/path.wav"));
assert_eq!(expand_tilde("relative/path.wav"), PathBuf::from("relative/path.wav"));
assert_eq!(expand_tilde(" "), PathBuf::from(""));
}
#[test]
fn test_validate_custom_path() {
assert_eq!(validate_custom_path(""), None);
assert_eq!(validate_custom_path(" "), None);
assert_eq!(validate_custom_path("/non/existent/file.wav"), Some(false));
}
}