// Copyright 2026 rootiest // SPDX-License-Identifier: GPL-3.0-or-later //! System volume monitor. //! //! Polls `pactl get-sink-volume @DEFAULT_SINK@` every 2 seconds and sends a //! `CMD_VOLUME` packet to all keyboards when the value changes. //! //! This works on both PulseAudio and PipeWire (which exposes a PulseAudio //! compatibility layer on KDE6 / Wayland systems). //! //! # Future improvement //! Replace polling with a PipeWire or PulseAudio event subscription so volume //! changes are reflected immediately rather than within the 2 s poll window. use std::process::Command; use std::thread; use std::time::Duration; use log::{debug, error, info, warn}; use crate::keyboard::Keyboard; use crate::protocol::{Packet, DEV_HOST}; const POLL_INTERVAL: Duration = Duration::from_secs(2); /// Monitor system volume and send updates to `keyboards`. /// Blocks the calling thread indefinitely (intended to run in a dedicated thread). pub fn monitor(keyboards: Vec) { let mut last: Option = None; loop { thread::sleep(POLL_INTERVAL); match query_volume() { Some(vol) if last != Some(vol) => { if last.is_none() { info!("Volume monitor started: {}%", vol); } else { debug!("Volume changed: {}%", vol); } last = Some(vol); broadcast(Packet::volume(DEV_HOST, vol), &keyboards); } None => { warn!("Could not read system volume"); } _ => {} // unchanged } } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Query the current default-sink volume via `pactl`. /// Returns the percentage (0-100) of the first channel, or `None` on failure. fn query_volume() -> Option { let out = Command::new("pactl") .args(["get-sink-volume", "@DEFAULT_SINK@"]) .output() .ok()?; if !out.status.success() { return None; } parse_volume(&String::from_utf8_lossy(&out.stdout)) } /// Parse the average percentage across all channels from pactl output such as: /// `"Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% ..."` /// /// Averaging across channels matches what OS volume sliders display and avoids /// a misleading reading when channels are unbalanced (e.g. hard-panned mono). fn parse_volume(s: &str) -> Option { let levels: Vec = s .split('/') .filter_map(|part| { part.trim() .strip_suffix('%') .and_then(|n| n.trim().parse::().ok()) }) .collect(); if levels.is_empty() { return None; } let avg = levels.iter().sum::() / levels.len() as u32; Some(avg.min(100) as u8) } fn broadcast(pkt: Packet, keyboards: &[Keyboard]) { for kbd in keyboards { if kbd.sender.send(pkt.clone()).is_err() { error!( "volume: failed to send to device 0x{:02x} (channel closed)", kbd.device_id ); } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::parse_volume; #[test] fn parses_stereo_equal() { let s = "Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB"; assert_eq!(parse_volume(s), Some(100)); } #[test] fn parses_stereo_equal_partial() { let s = "Volume: front-left: 45875 / 70% / -9.03 dB, front-right: 45875 / 70% / -9.03 dB"; assert_eq!(parse_volume(s), Some(70)); } #[test] fn averages_unbalanced_channels() { // left=80%, right=20% → average=50% let s = "Volume: front-left: 52429 / 80% / -5.08 dB, front-right: 13107 / 20% / -13.98 dB"; assert_eq!(parse_volume(s), Some(50)); } #[test] fn returns_none_for_garbage() { assert_eq!(parse_volume("no percentage here"), None); } }