From e5d2110aa154ef8fce4d4b03577815ed347da039 Mon Sep 17 00:00:00 2001 From: Roman Stingler Date: Mon, 10 Aug 2026 22:05:13 +0200 Subject: [PATCH] add audio feedback for volume and mute changes --- Cargo.lock | 22 ++++ Cargo.toml | 1 + assets/bell.pcm | Bin 0 -> 12302 bytes src/config.rs | 2 + src/modules/settings/audio.rs | 22 ++++ src/modules/settings/mod.rs | 2 + src/utils/audio_feedback.rs | 112 ++++++++++++++++++ src/utils/mod.rs | 1 + website/docs/configuration/full_config.md | 1 + .../docs/configuration/modules/settings.md | 16 +++ 10 files changed, 179 insertions(+) create mode 100644 assets/bell.pcm create mode 100644 src/utils/audio_feedback.rs diff --git a/Cargo.lock b/Cargo.lock index 89fed92c6..34947934e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,7 @@ dependencies = [ "itertools 0.15.0", "libc", "libpulse-binding", + "libpulse-simple-binding", "linicon-theme", "log", "niri-ipc", @@ -2474,6 +2475,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "libpulse-simple-binding" +version = "2.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7bebef0381c8e3e4b23cc24aaf36fab37472bece128de96f6a111efa464cfef" +dependencies = [ + "libpulse-binding", + "libpulse-simple-sys", + "libpulse-sys", +] + +[[package]] +name = "libpulse-simple-sys" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd96888fe37ad270d16abf5e82cccca1424871cf6afa2861824d2a52758eebc" +dependencies = [ + "libpulse-sys", + "pkg-config", +] + [[package]] name = "libpulse-sys" version = "1.23.0" diff --git a/Cargo.toml b/Cargo.toml index b741ef017..b095fd4e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ url = "2.5.7" signal-hook = "0.4.3" signal-hook-tokio = { version = "0.4", features = ["futures-v0_3"] } libc = "0.2.182" +libpulse-simple-binding = "2.29" # keep in sync with the fontdb version pulled transitively by iced_layershell fontdb = { version = "0.23", features = ["fontconfig"] } chrono-tz = "0.10.4" diff --git a/assets/bell.pcm b/assets/bell.pcm new file mode 100644 index 0000000000000000000000000000000000000000..56169f833adbd4483322870055249bf12c4330d1 GIT binary patch literal 12302 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 literal 0 HcmV?d00001 diff --git a/src/config.rs b/src/config.rs index 0363b9351..529539d4b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -640,6 +640,7 @@ pub struct SettingsModuleConfig { pub bluetooth_more_cmd: Option, pub remove_airplane_btn: bool, pub remove_idle_btn: bool, + pub audio_feedback: bool, pub enable_tooltips: bool, pub indicators: Vec, #[serde(rename = "CustomButton")] @@ -674,6 +675,7 @@ impl Default for SettingsModuleConfig { bluetooth_more_cmd: Default::default(), remove_airplane_btn: Default::default(), remove_idle_btn: Default::default(), + audio_feedback: true, enable_tooltips: true, indicators: vec![ SettingsIndicator::IdleInhibitor, diff --git a/src/modules/settings/audio.rs b/src/modules/settings/audio.rs index bd196e9df..bc1709507 100644 --- a/src/modules/settings/audio.rs +++ b/src/modules/settings/audio.rs @@ -13,6 +13,7 @@ use crate::{ t, theme::use_theme, utils::IndicatorState, + utils::audio_feedback::AudioFeedback, utils::remote_value::{self, Remote}, }; use iced::{ @@ -61,6 +62,7 @@ pub struct AudioSettingsConfig { pub max_volume: u8, pub indicator_format: SettingsFormat, pub microphone_indicator_format: SettingsFormat, + pub audio_feedback: bool, } impl AudioSettingsConfig { @@ -71,6 +73,7 @@ impl AudioSettingsConfig { max_volume: u8, indicator_format: SettingsFormat, microphone_indicator_format: SettingsFormat, + audio_feedback: bool, ) -> Self { Self { sinks_more_cmd, @@ -79,6 +82,7 @@ impl AudioSettingsConfig { max_volume, indicator_format, microphone_indicator_format, + audio_feedback, } } } @@ -86,6 +90,7 @@ impl AudioSettingsConfig { pub struct AudioSettings { config: AudioSettingsConfig, service: Option, + audio_feedback: AudioFeedback, } pub struct SubmenuEntry { @@ -103,9 +108,15 @@ pub enum SliderType { impl AudioSettings { pub fn new(config: AudioSettingsConfig) -> Self { + let audio_feedback = if config.audio_feedback { + AudioFeedback::enabled() + } else { + AudioFeedback::disabled() + }; Self { config, service: None, + audio_feedback, } } @@ -236,6 +247,7 @@ impl AudioSettings { Message::ToggleSinkMute => { if let Some(service) = self.service.as_mut() { let _ = service.command(AudioCommand::ToggleSinkMute); + self.audio_feedback.play_mute_toggle(); } Action::None } @@ -243,6 +255,7 @@ impl AudioSettings { if let Some(service) = self.service.as_mut() { if let Some(value) = message.value() { let _ = service.command(AudioCommand::SinkVolume(value)); + self.audio_feedback.play(value); } return Action::Task( service @@ -262,6 +275,7 @@ impl AudioSettings { Message::ToggleSourceMute => { if let Some(service) = self.service.as_mut() { let _ = service.command(AudioCommand::ToggleSourceMute); + self.audio_feedback.play_mute_toggle(); } Action::None } @@ -269,6 +283,7 @@ impl AudioSettings { if let Some(service) = self.service.as_mut() { if let Some(value) = message.value() { let _ = service.command(AudioCommand::SourceVolume(value)); + self.audio_feedback.play(value); } return Action::Task( service @@ -316,6 +331,13 @@ impl AudioSettings { Message::ToggleSinksMenu => Action::ToggleSinksMenu, Message::ToggleSourcesMenu => Action::ToggleSourcesMenu, Message::ConfigReloaded(config) => { + if config.audio_feedback != self.config.audio_feedback { + self.audio_feedback = if config.audio_feedback { + AudioFeedback::enabled() + } else { + AudioFeedback::disabled() + }; + } self.config = config; Action::None } diff --git a/src/modules/settings/mod.rs b/src/modules/settings/mod.rs index fdaf703a2..58af099d4 100644 --- a/src/modules/settings/mod.rs +++ b/src/modules/settings/mod.rs @@ -222,6 +222,7 @@ impl Settings { config.max_volume, config.audio_indicator_format, config.microphone_indicator_format, + config.audio_feedback, )), brightness: BrightnessSettings::new(config.brightness_indicator_format), network: NetworkSettings::new(NetworkSettingsConfig::new( @@ -523,6 +524,7 @@ impl Settings { config.max_volume, config.audio_indicator_format, config.microphone_indicator_format, + config.audio_feedback, ))); self.network.update(network::Message::ConfigReloaded( NetworkSettingsConfig::new( diff --git a/src/utils/audio_feedback.rs b/src/utils/audio_feedback.rs new file mode 100644 index 000000000..4c6bef8db --- /dev/null +++ b/src/utils/audio_feedback.rs @@ -0,0 +1,112 @@ +use std::sync::mpsc::{self, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +use libpulse_binding::sample::{Format, Spec}; +use libpulse_binding::stream::Direction; +use libpulse_simple_binding::Simple; +use log::warn; + +const SPEC: Spec = Spec { + format: Format::S16NE, + channels: 1, + rate: 44100, +}; +const BELL_PCM: &[u8] = include_bytes!("../../assets/bell.pcm"); +const MIN_VOLUME_DELTA_PERCENT: u32 = 4; +const MIN_TIME_BETWEEN_PLAYS: Duration = Duration::from_millis(150); +const VOL_PERCENT: u32 = 65536 / 100; + +pub struct AudioFeedback { + enabled: bool, + last_played: Instant, + last_volume_percent: u32, + sender: Option>, +} + +impl AudioFeedback { + pub fn enabled() -> Self { + Self { + enabled: true, + last_played: Instant::now() - MIN_TIME_BETWEEN_PLAYS, + last_volume_percent: 0, + sender: Some(Self::spawn_player()), + } + } + + pub fn disabled() -> Self { + Self { + enabled: false, + last_played: Instant::now() - MIN_TIME_BETWEEN_PLAYS, + last_volume_percent: 0, + sender: None, + } + } + + pub fn play(&mut self, volume_raw: u32) { + if !self.enabled { + return; + } + let volume_percent = volume_raw / VOL_PERCENT; + let delta = volume_percent.abs_diff(self.last_volume_percent); + let elapsed = self.last_played.elapsed(); + if delta < MIN_VOLUME_DELTA_PERCENT || elapsed < MIN_TIME_BETWEEN_PLAYS { + return; + } + self.trigger_bell(); + self.last_played = Instant::now(); + self.last_volume_percent = volume_percent; + } + + pub fn play_mute_toggle(&mut self) { + if !self.enabled { + return; + } + if self.last_played.elapsed() < MIN_TIME_BETWEEN_PLAYS { + return; + } + self.trigger_bell(); + self.last_played = Instant::now(); + } + + fn trigger_bell(&self) { + if let Some(sender) = &self.sender + && sender.send(()).is_err() + { + warn!("Audio feedback player thread is not running"); + } + } + + // Runs on a single long-lived thread holding one PulseAudio connection, + // so repeated beeps don't each pay a fresh connection handshake. + fn spawn_player() -> Sender<()> { + let (tx, rx) = mpsc::channel::<()>(); + thread::spawn(move || { + let stream = match Simple::new( + None, + "ashell", + Direction::Playback, + None, + "audio-feedback", + &SPEC, + None, + None, + ) { + Ok(s) => s, + Err(e) => { + warn!("Failed to open audio feedback stream: {e}"); + return; + } + }; + + while rx.recv().is_ok() { + if let Err(e) = stream.write(BELL_PCM) { + warn!("Failed to write beep samples: {e}"); + } else if let Err(e) = stream.drain() { + warn!("Failed to drain beep: {e}"); + } + } + }); + tx + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9bcc270c9..6048cbaaf 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,6 +2,7 @@ use std::time::Duration; use unicode_segmentation::UnicodeSegmentation; +pub mod audio_feedback; pub mod launcher; pub mod remote_value; diff --git a/website/docs/configuration/full_config.md b/website/docs/configuration/full_config.md index 5e1fbcf69..913110846 100644 --- a/website/docs/configuration/full_config.md +++ b/website/docs/configuration/full_config.md @@ -173,6 +173,7 @@ volume_step = 5 # (default) step size for IPC volume up/down, range 1..=50 max_volume = 100 # (default) max volume level, range 1..=200 (>100 enables overdrive) # remove_airplane_btn = false # (default) set true to hide airplane mode button # remove_idle_btn = false # (default) set true to hide idle inhibitor button +# audio_feedback = true # (default) play a beep on volume / mute changes indicators = [ "IdleInhibitor", "PowerProfile", "Audio", "Microphone", "Bluetooth", "Network", "Vpn", "Battery", "Brightness" ] # indicators = [ "IdleInhibitor", "PowerProfile", "Audio", "Microphone", "Bluetooth", "Network", "Vpn", "Battery", "PeripheralBattery", "Brightness" ] diff --git a/website/docs/configuration/modules/settings.md b/website/docs/configuration/modules/settings.md index b6acab234..26e48012b 100644 --- a/website/docs/configuration/modules/settings.md +++ b/website/docs/configuration/modules/settings.md @@ -68,6 +68,22 @@ With the `remove_airplane_btn` option you can remove the airplane mode button. With the `remove_idle_btn` option you can remove the idle inhibitor button. +## Audio Feedback + +With the `audio_feedback` option you can enable or disable the audible beep +played when adjusting the volume (default: `true`). The beep plays on slider +drags, scroll wheel, mute toggles, and keyboard volume keys sent via IPC. + +The beep is a short embedded sound sample played on the active audio output. +Beeps are rate-limited: a beep only plays if the volume has changed by at +least 4% **and** at least 150ms have passed since the last beep, preventing +rapid overlapping sounds. + +```toml +[settings] +audio_feedback = true +``` + ## Tooltips By default, hovering over the status bar indicators shows a tooltip describing