369 lines
12 KiB
Rust
369 lines
12 KiB
Rust
//! Linux volume control implementation
|
|
//!
|
|
//! Provides volume control with auto-detection of sound systems:
|
|
//! - PipeWire (via `wpctl`)
|
|
//! - PulseAudio (via `pactl`)
|
|
//! - ALSA (via `amixer`)
|
|
|
|
use async_trait::async_trait;
|
|
use shepherd_host_api::{
|
|
VolumeCapabilities, VolumeController, VolumeError, VolumeResult, VolumeStatus,
|
|
};
|
|
use std::process::Command;
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Detected sound backend
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SoundBackend {
|
|
/// PipeWire with WirePlumber
|
|
PipeWire,
|
|
/// PulseAudio
|
|
PulseAudio,
|
|
/// ALSA (direct)
|
|
Alsa,
|
|
}
|
|
|
|
impl SoundBackend {
|
|
/// Detect the best available sound backend
|
|
pub fn detect() -> Option<Self> {
|
|
// Try PipeWire first (modern systems)
|
|
if Self::is_pipewire_available() {
|
|
info!("Detected PipeWire sound backend");
|
|
return Some(Self::PipeWire);
|
|
}
|
|
|
|
// Try PulseAudio
|
|
if Self::is_pulseaudio_available() {
|
|
info!("Detected PulseAudio sound backend");
|
|
return Some(Self::PulseAudio);
|
|
}
|
|
|
|
// Try ALSA as fallback
|
|
if Self::is_alsa_available() {
|
|
info!("Detected ALSA sound backend");
|
|
return Some(Self::Alsa);
|
|
}
|
|
|
|
warn!("No sound backend detected");
|
|
None
|
|
}
|
|
|
|
fn is_pipewire_available() -> bool {
|
|
// Check if wpctl is available and can communicate with PipeWire
|
|
Command::new("wpctl")
|
|
.args(["status"])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn is_pulseaudio_available() -> bool {
|
|
// Check if pactl is available and server is running
|
|
Command::new("pactl")
|
|
.args(["info"])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn is_alsa_available() -> bool {
|
|
// Check if amixer is available
|
|
Command::new("amixer")
|
|
.args(["sget", "Master"])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub fn name(&self) -> &'static str {
|
|
match self {
|
|
Self::PipeWire => "pipewire",
|
|
Self::PulseAudio => "pulseaudio",
|
|
Self::Alsa => "alsa",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Linux volume controller with auto-detection
|
|
pub struct LinuxVolumeController {
|
|
capabilities: VolumeCapabilities,
|
|
backend: Option<SoundBackend>,
|
|
}
|
|
|
|
impl LinuxVolumeController {
|
|
/// Create a new volume controller with auto-detection
|
|
pub fn new() -> Self {
|
|
let backend = SoundBackend::detect();
|
|
|
|
let capabilities = VolumeCapabilities {
|
|
available: backend.is_some(),
|
|
backend: backend.map(|b| b.name().to_string()),
|
|
can_mute: backend.is_some(),
|
|
max_volume: 100,
|
|
};
|
|
|
|
Self {
|
|
capabilities,
|
|
backend,
|
|
}
|
|
}
|
|
|
|
/// Get volume status via PipeWire
|
|
fn get_status_pipewire() -> VolumeResult<VolumeStatus> {
|
|
// Get volume: wpctl get-volume @DEFAULT_AUDIO_SINK@
|
|
// Output: "Volume: 0.50" or "Volume: 0.50 [MUTED]"
|
|
let output = Command::new("wpctl")
|
|
.args(["get-volume", "@DEFAULT_AUDIO_SINK@"])
|
|
.output()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
debug!("wpctl get-volume output: {}", stdout.trim());
|
|
|
|
let muted = stdout.contains("[MUTED]");
|
|
|
|
// Parse "Volume: 0.50" -> 50%
|
|
let percent = stdout
|
|
.split(':')
|
|
.nth(1)
|
|
.and_then(|s| s.split_whitespace().next())
|
|
.and_then(|s| s.parse::<f32>().ok())
|
|
.map(|v| (v * 100.0).round() as u8)
|
|
.unwrap_or(0);
|
|
|
|
Ok(VolumeStatus { percent, muted })
|
|
}
|
|
|
|
/// Get volume status via PulseAudio
|
|
fn get_status_pulseaudio() -> VolumeResult<VolumeStatus> {
|
|
let mut status = VolumeStatus::default();
|
|
|
|
// Get default sink info
|
|
if let Ok(output) = Command::new("pactl")
|
|
.args(["get-sink-volume", "@DEFAULT_SINK@"])
|
|
.output()
|
|
{
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
debug!("pactl get-sink-volume output: {}", stdout.trim());
|
|
|
|
// Output: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..."
|
|
if let Some(percent_str) = stdout.split('/').nth(1)
|
|
&& let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::<u8>() {
|
|
status.percent = percent;
|
|
}
|
|
}
|
|
|
|
// Check mute status
|
|
if let Ok(output) = Command::new("pactl")
|
|
.args(["get-sink-mute", "@DEFAULT_SINK@"])
|
|
.output()
|
|
{
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
debug!("pactl get-sink-mute output: {}", stdout.trim());
|
|
status.muted = stdout.contains("yes");
|
|
}
|
|
|
|
Ok(status)
|
|
}
|
|
|
|
/// Get volume status via ALSA
|
|
fn get_status_alsa() -> VolumeResult<VolumeStatus> {
|
|
// amixer sget Master
|
|
// Output includes: "Front Left: Playback 65536 [100%] [on]"
|
|
let output = Command::new("amixer")
|
|
.args(["sget", "Master"])
|
|
.output()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
debug!("amixer sget Master output: {}", stdout);
|
|
|
|
let mut status = VolumeStatus::default();
|
|
|
|
for line in stdout.lines() {
|
|
if line.contains("Playback") && line.contains('%') {
|
|
// Extract percentage: [100%]
|
|
if let Some(start) = line.find('[')
|
|
&& let Some(end) = line[start..].find('%')
|
|
&& let Ok(percent) = line[start + 1..start + end].parse::<u8>() {
|
|
status.percent = percent;
|
|
}
|
|
// Check mute status: [on] or [off]
|
|
status.muted = line.contains("[off]");
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(status)
|
|
}
|
|
|
|
/// Set volume via PipeWire
|
|
fn set_volume_pipewire(percent: u8) -> VolumeResult<()> {
|
|
let volume = format!("{}%", percent);
|
|
Command::new("wpctl")
|
|
.args(["set-volume", "@DEFAULT_AUDIO_SINK@", &volume])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set volume via PulseAudio
|
|
fn set_volume_pulseaudio(percent: u8) -> VolumeResult<()> {
|
|
Command::new("pactl")
|
|
.args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set volume via ALSA
|
|
fn set_volume_alsa(percent: u8) -> VolumeResult<()> {
|
|
Command::new("amixer")
|
|
.args(["sset", "Master", &format!("{}%", percent)])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle mute via PipeWire
|
|
fn toggle_mute_pipewire() -> VolumeResult<()> {
|
|
Command::new("wpctl")
|
|
.args(["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle mute via PulseAudio
|
|
fn toggle_mute_pulseaudio() -> VolumeResult<()> {
|
|
Command::new("pactl")
|
|
.args(["set-sink-mute", "@DEFAULT_SINK@", "toggle"])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle mute via ALSA
|
|
fn toggle_mute_alsa() -> VolumeResult<()> {
|
|
Command::new("amixer")
|
|
.args(["sset", "Master", "toggle"])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set mute state via PipeWire
|
|
fn set_mute_pipewire(muted: bool) -> VolumeResult<()> {
|
|
let state = if muted { "1" } else { "0" };
|
|
Command::new("wpctl")
|
|
.args(["set-mute", "@DEFAULT_AUDIO_SINK@", state])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set mute state via PulseAudio
|
|
fn set_mute_pulseaudio(muted: bool) -> VolumeResult<()> {
|
|
let state = if muted { "1" } else { "0" };
|
|
Command::new("pactl")
|
|
.args(["set-sink-mute", "@DEFAULT_SINK@", state])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set mute state via ALSA
|
|
fn set_mute_alsa(muted: bool) -> VolumeResult<()> {
|
|
let state = if muted { "mute" } else { "unmute" };
|
|
Command::new("amixer")
|
|
.args(["sset", "Master", state])
|
|
.status()
|
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Default for LinuxVolumeController {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl VolumeController for LinuxVolumeController {
|
|
fn capabilities(&self) -> &VolumeCapabilities {
|
|
&self.capabilities
|
|
}
|
|
|
|
async fn get_status(&self) -> VolumeResult<VolumeStatus> {
|
|
match self.backend {
|
|
Some(SoundBackend::PipeWire) => Self::get_status_pipewire(),
|
|
Some(SoundBackend::PulseAudio) => Self::get_status_pulseaudio(),
|
|
Some(SoundBackend::Alsa) => Self::get_status_alsa(),
|
|
None => Err(VolumeError::NotAvailable(
|
|
"No sound backend available".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn set_volume(&self, percent: u8) -> VolumeResult<()> {
|
|
if percent > self.capabilities.max_volume {
|
|
return Err(VolumeError::OutOfRange(percent));
|
|
}
|
|
|
|
match self.backend {
|
|
Some(SoundBackend::PipeWire) => Self::set_volume_pipewire(percent),
|
|
Some(SoundBackend::PulseAudio) => Self::set_volume_pulseaudio(percent),
|
|
Some(SoundBackend::Alsa) => Self::set_volume_alsa(percent),
|
|
None => Err(VolumeError::NotAvailable(
|
|
"No sound backend available".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn volume_up(&self, step: u8) -> VolumeResult<()> {
|
|
let current = self.get_status().await?;
|
|
let new_volume = current.percent.saturating_add(step).min(self.capabilities.max_volume);
|
|
self.set_volume(new_volume).await
|
|
}
|
|
|
|
async fn volume_down(&self, step: u8) -> VolumeResult<()> {
|
|
let current = self.get_status().await?;
|
|
let new_volume = current.percent.saturating_sub(step);
|
|
self.set_volume(new_volume).await
|
|
}
|
|
|
|
async fn toggle_mute(&self) -> VolumeResult<()> {
|
|
match self.backend {
|
|
Some(SoundBackend::PipeWire) => Self::toggle_mute_pipewire(),
|
|
Some(SoundBackend::PulseAudio) => Self::toggle_mute_pulseaudio(),
|
|
Some(SoundBackend::Alsa) => Self::toggle_mute_alsa(),
|
|
None => Err(VolumeError::NotAvailable(
|
|
"No sound backend available".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn set_mute(&self, muted: bool) -> VolumeResult<()> {
|
|
match self.backend {
|
|
Some(SoundBackend::PipeWire) => Self::set_mute_pipewire(muted),
|
|
Some(SoundBackend::PulseAudio) => Self::set_mute_pulseaudio(muted),
|
|
Some(SoundBackend::Alsa) => Self::set_mute_alsa(muted),
|
|
None => Err(VolumeError::NotAvailable(
|
|
"No sound backend available".into(),
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_backend_name() {
|
|
assert_eq!(SoundBackend::PipeWire.name(), "pipewire");
|
|
assert_eq!(SoundBackend::PulseAudio.name(), "pulseaudio");
|
|
assert_eq!(SoundBackend::Alsa.name(), "alsa");
|
|
}
|
|
}
|