Files
qmk-host/src/volume.rs
T
rootiest 4e33e67e96 fix(volume): log initial volume level at INFO on startup
Volume changes were only logged at DEBUG (requires -v), leaving the
volume monitor completely silent at the default log level. The first
reading is now logged at INFO so users can confirm the monitor is
running and see the current level without needing verbose output.
2026-04-10 16:47:25 -04:00

138 lines
4.1 KiB
Rust

// 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<Keyboard>) {
let mut last: Option<u8> = 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<u8> {
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<u8> {
let levels: Vec<u32> = s
.split('/')
.filter_map(|part| {
part.trim()
.strip_suffix('%')
.and_then(|n| n.trim().parse::<u32>().ok())
})
.collect();
if levels.is_empty() {
return None;
}
let avg = levels.iter().sum::<u32>() / 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);
}
}