use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; #[derive(Clone, Serialize, Deserialize, Debug)] pub struct VolumeChannel { pub value: u32, // 48231 pub value_percent: String, // "80%" pub db: String, // "-5.81 dB" } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Volume { // WiVRn and other devices pub aux0: Option, pub aux1: Option, // Analog and HDMI devices #[serde(rename = "front-left")] pub front_left: Option, #[serde(rename = "front-right")] pub front_right: Option, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Sink { pub index: u32, // 123 pub state: String, // "RUNNING" / "SUSPENDED" pub name: String, // alsa_output.pci-0000_0c_00.4.analog-stereo pub description: String, // Starship/Matisse HD Audio Controller Analog Stereo pub mute: bool, // false pub volume: Volume, pub properties: HashMap, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct SourceProperties { #[serde(rename = "device.name")] pub device_name: Option, // "alsa_card.pci-0000_0b_00.1" #[serde(rename = "device.class")] pub device_class: Option, // "monitor", "sound" #[serde(rename = "alsa.card_name")] pub card_name: Option, // "Valve VR Radio & HMD Mic" #[serde(rename = "alsa.components")] pub components: Option, // USB28de:2102 #[serde(rename = "device.vendor_name")] pub vendor_name: Option, // Valve Software } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Source { pub index: u32, // 123 pub state: String, // "RUNNING" / "SUSPENDED" pub name: String, // alsa_input.pci-0000_0c_00.4.analog-stereo pub description: String, // Valve VR Radio & HMD Mic Mono pub mute: bool, // false pub volume: Volume, pub properties: SourceProperties, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct CardProperties { #[serde(rename = "device.description")] pub device_description: String, // Starship/Matisse HD Audio Controller #[serde(rename = "device.name")] pub device_name: String, // alsa_card.pci-0000_0c_00.4 #[serde(rename = "device.nick")] pub device_nick: String, // HD-Audio Generic } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct CardProfile { pub description: String, // "Digital Stereo (HDMI 2) Output", "Analog Stereo Output", pub sinks: u32, // 1 pub sources: u32, // 0 pub priority: u32, // 6500 pub available: bool, // true } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct CardPort { pub description: String, // "HDMI / DisplayPort 2" pub r#type: String, // "HDMI" pub profiles: Vec, // "output:hdmi-stereo-extra1", "output:hdmi-surround-extra1", "output:analog-stereo", "output:analog-stereo+input:analog-stereo" // example: // "port.type": "hdmi" // "device.product_name": "Index HMD" pub properties: HashMap, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Card { pub index: u32, // 57 pub name: String, // alsa_card.pci-0000_0c_00.4 pub active_profile: String, // output:analog-stereo pub properties: CardProperties, pub profiles: BTreeMap, // key: "output:analog-stereo" pub ports: BTreeMap, // key: "analog-output-lineout" } // ######################################## // ~ sinks ~ // ######################################## pub fn list_sinks() -> anyhow::Result> { let output = std::process::Command::new("pactl") .arg("--format=json") .arg("list") .arg("sinks") .output()?; if !output.status.success() { anyhow::bail!("pactl exit status {}", output.status); } let json_str = std::str::from_utf8(&output.stdout)?; let sinks: Vec = serde_json::from_str(json_str)?; Ok(sinks) } pub fn get_default_sink(sinks: &[Sink]) -> anyhow::Result> { let output = std::process::Command::new("pactl").arg("get-default-sink").output()?; let utf8_name = std::str::from_utf8(&output.stdout)?.trim(); for sink in sinks { if sink.name == utf8_name { return Ok(Some(sink.clone())); } } Ok(None) } pub fn set_default_sink(sink_index: u32) -> anyhow::Result<()> { std::process::Command::new("pactl") .arg("set-default-sink") .arg(format!("{}", sink_index)) .output()?; Ok(()) } pub fn get_sink_volume(sink: &Sink) -> anyhow::Result { let volume_channel = { if let Some(front_left) = &sink.volume.front_left { front_left } else if let Some(aux0) = &sink.volume.aux0 { aux0 } else { return Ok(0.0); // fail silently } }; let Some(pair) = volume_channel.value_percent.split_once("%") else { anyhow::bail!("volume percentage invalid"); // shouldn't happen }; let percent_num: f32 = pair.0.parse().unwrap_or(0.0); Ok(percent_num / 100.0) } pub fn set_sink_volume(sink_index: u32, volume: f32) -> anyhow::Result<()> { let target_vol = (volume * 100.0).clamp(0.0, 150.0); // limit to 150% std::process::Command::new("pactl") .arg("set-sink-volume") .arg(format!("{}", sink_index)) .arg(format!("{}%", target_vol)) .output()?; Ok(()) } pub fn set_sink_mute(sink_index: u32, mute: bool) -> anyhow::Result<()> { std::process::Command::new("pactl") .arg("set-sink-mute") .arg(format!("{}", sink_index)) .arg(format!("{}", mute as i32)) .output()?; Ok(()) } // ######################################## // ~ sources ~ // ######################################## pub fn list_sources() -> anyhow::Result> { let output = std::process::Command::new("pactl") .arg("--format=json") .arg("list") .arg("sources") .output()?; if !output.status.success() { anyhow::bail!("pactl exit status {}", output.status); } let json_str = std::str::from_utf8(&output.stdout)?; let mut sources: Vec = serde_json::from_str(json_str)?; // exclude all monitor sources sources.retain(|source| match &source.properties.device_class { Some(c) => c != "monitor", None => false, }); Ok(sources) } pub fn get_default_source(sources: &[Source]) -> anyhow::Result> { let output = std::process::Command::new("pactl").arg("get-default-source").output()?; let utf8_name = std::str::from_utf8(&output.stdout)?.trim(); for source in sources { if source.name == utf8_name { return Ok(Some(source.clone())); } } Ok(None) } pub fn set_default_source(source_index: u32) -> anyhow::Result<()> { std::process::Command::new("pactl") .arg("set-default-source") .arg(format!("{}", source_index)) .output()?; Ok(()) } pub fn get_source_volume(source: &Source) -> anyhow::Result { let volume_channel = { if let Some(front_left) = &source.volume.front_left { front_left } else if let Some(aux0) = &source.volume.aux0 { aux0 } else { return Ok(0.0); // fail silently } }; let Some(pair) = volume_channel.value_percent.split_once("%") else { anyhow::bail!("volume percentage invalid"); // shouldn't happen }; let percent_num: f32 = pair.0.parse().unwrap_or(0.0); Ok(percent_num / 100.0) } pub fn set_source_volume(source_index: u32, volume: f32) -> anyhow::Result<()> { let target_vol = (volume * 100.0).clamp(0.0, 150.0); // limit to 150% std::process::Command::new("pactl") .arg("set-source-volume") .arg(format!("{}", source_index)) .arg(format!("{}%", target_vol)) .output()?; Ok(()) } pub fn set_source_mute(source_index: u32, mute: bool) -> anyhow::Result<()> { std::process::Command::new("pactl") .arg("set-source-mute") .arg(format!("{}", source_index)) .arg(format!("{}", mute as i32)) .output()?; Ok(()) } // ######################################## // ~ cards ~ // ######################################## pub fn list_cards() -> anyhow::Result> { let output = std::process::Command::new("pactl") .arg("--format=json") .arg("list") .arg("cards") .output()?; if !output.status.success() { anyhow::bail!("pactl exit status {}", output.status); } let json_str = std::str::from_utf8(&output.stdout)?; let mut cards: Vec = serde_json::from_str(json_str)?; // exclude card which has "Loopback" in name cards.retain(|card| card.properties.device_nick != "Loopback"); Ok(cards) } pub fn set_card_profile(card_index: u32, profile: &str) -> anyhow::Result<()> { std::process::Command::new("pactl") .arg("set-card-profile") .arg(format!("{}", card_index)) .arg(profile) .output()?; Ok(()) }