Audio settings: Sinks and sources control fully implemented (cards wip), port pactl_wrapper

This commit is contained in:
Aleksander
2025-12-05 21:30:17 +01:00
parent 9767940923
commit 1d60bed361
9 changed files with 948 additions and 49 deletions

View File

@@ -0,0 +1,320 @@
use std::collections::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<VolumeChannel>,
pub aux1: Option<VolumeChannel>,
// Analog and HDMI devices
#[serde(rename = "front-left")]
pub front_left: Option<VolumeChannel>,
#[serde(rename = "front-right")]
pub front_right: Option<VolumeChannel>,
}
#[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<String, String>,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct SourceProperties {
#[serde(rename = "device.name")]
pub device_name: Option<String>, // "alsa_card.pci-0000_0b_00.1"
#[serde(rename = "device.class")]
pub device_class: Option<String>, // "monitor", "sound"
#[serde(rename = "alsa.card_name")]
pub card_name: Option<String>, // "Valve VR Radio & HMD Mic"
#[serde(rename = "alsa.components")]
pub components: Option<String>, // USB28de:2102
#[serde(rename = "device.vendor_name")]
pub vendor_name: Option<String>, // 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<String>, // "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<String, String>,
}
#[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: HashMap<String, CardProfile>, // key: "output:analog-stereo"
pub ports: HashMap<String, CardPort>, // key: "analog-output-lineout"
}
// ########################################
// ~ sinks ~
// ########################################
pub fn list_sinks() -> anyhow::Result<Vec<Sink>> {
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<Sink> = serde_json::from_str(json_str)?;
Ok(sinks)
}
pub fn get_default_sink(sinks: &[Sink]) -> anyhow::Result<Option<Sink>> {
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_from_index(sinks: &[Sink], index: u32) -> Option<&Sink> {
sinks.iter().find(|&sink| sink.index == index)
}
pub fn get_sink_volume(sink: &Sink) -> anyhow::Result<f32> {
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<Vec<Source>> {
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<Source> = 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<Option<Source>> {
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_from_index(sources: &[Source], index: u32) -> Option<&Source> {
sources.iter().find(|&source| source.index == index)
}
pub fn get_source_volume(source: &Source) -> anyhow::Result<f32> {
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<Vec<Card>> {
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<Card> = 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(())
}