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 000000000..56169f833 Binary files /dev/null and b/assets/bell.pcm differ 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