deckster/pa-volume-interface/src/lib.rs
2024-01-14 02:01:52 +01:00

270 lines
7.5 KiB
Rust

use std::process::Command;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use im::HashMap;
use tokio::sync::broadcast;
pub type PaEntityId = usize;
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum PaEntityKind {
Source,
Sink,
Application,
}
#[derive(Debug, Clone)]
pub struct PaEntityState {
id: PaEntityId,
kind: PaEntityKind,
name: String,
channel_volumes: Box<[f32]>,
is_muted: bool,
}
impl PaEntityState {
pub fn id(&self) -> &PaEntityId {
&self.id
}
pub fn kind(&self) -> &PaEntityKind {
&self.kind
}
pub fn name(&self) -> &String {
&self.name
}
pub fn channel_volumes(&self) -> &[f32] {
&self.channel_volumes
}
pub fn is_muted(&self) -> bool {
self.is_muted
}
}
#[derive(Debug, Clone)]
pub struct PaVolumeState {
timestamp: Instant,
entities_by_id: HashMap<PaEntityId, Arc<PaEntityState>>,
default_sink_id: PaEntityId,
default_source_id: PaEntityId,
}
impl PaVolumeState {
pub fn timestamp(&self) -> &Instant {
&self.timestamp
}
pub fn entities_by_id(&self) -> &HashMap<PaEntityId, Arc<PaEntityState>> {
&self.entities_by_id
}
pub fn default_sink_id(&self) -> &PaEntityId {
&self.default_sink_id
}
pub fn default_source_id(&self) -> &PaEntityId {
&self.default_source_id
}
}
enum PaCommand {
QueryState,
SetIsMuted { id: PaEntityId, value: bool },
SetChannelVolumes { id: PaEntityId, channel_volumes: Box<[f32]> },
Terminate,
}
#[derive(Debug)]
struct PaThread {
commands_tx: flume::Sender<PaCommand>,
}
fn query_and_publish_state(state_tx: &broadcast::Sender<PaVolumeState>) {
let output = Command::new("pulsemixer").arg("-l").output().unwrap();
if !output.status.success() {
panic!("`pulsemixer -l` failed");
}
let raw = String::from_utf8_lossy(&output.stdout);
let mut default_sink_id = 0;
let mut default_source_id = 0;
let entities_by_id: HashMap<_, _> = raw
.lines()
.map(|line| {
let (prefix, rest) = line.split_once(':').unwrap();
let kind = match prefix {
"Sink" => PaEntityKind::Sink,
"Source" => PaEntityKind::Source,
"Sink input" => PaEntityKind::Application,
x => panic!("Unknown entity kind: {}", x),
};
let segments: Vec<&str> = rest.split(", ").collect();
let id = segments[0]
.trim_start()
.strip_prefix("ID: ")
.unwrap()
.split('-')
.last()
.unwrap()
.parse::<usize>()
.unwrap();
let name = segments[1].strip_prefix("Name: ").unwrap();
let is_muted = segments[2].strip_prefix("Mute: ").unwrap().parse::<u8>().unwrap() == 1;
let channel_count = segments[3].strip_prefix("Channels: ").unwrap().parse::<usize>().unwrap();
let channel_volumes: Box<[f32]> = segments[4..(4 + channel_count)]
.iter()
.map(|s| {
let s = s.strip_prefix("Volumes: [").unwrap_or(s);
s.strip_suffix(']').unwrap_or(s)
})
.map(|s| &s[1..(s.len() - 2)])
.map(|s| s.parse::<f32>().unwrap() / 100.0)
.collect();
if segments.last().unwrap() == &"Default" {
match kind {
PaEntityKind::Source => {
default_source_id = id;
}
PaEntityKind::Sink => {
default_sink_id = id;
}
PaEntityKind::Application => {
panic!("Sink sources cannot be a default");
}
}
}
(
id,
Arc::new(PaEntityState {
id,
kind,
name: name.to_owned(),
is_muted,
channel_volumes,
}),
)
})
.collect();
state_tx
.send(PaVolumeState {
timestamp: Instant::now(),
entities_by_id,
default_sink_id,
default_source_id,
})
.unwrap();
}
impl PaThread {
fn spawn(
max_time_between_queries: Duration,
commands_tx: flume::Sender<PaCommand>,
commands_rx: flume::Receiver<PaCommand>,
state_tx: broadcast::Sender<PaVolumeState>,
) -> PaThread {
thread::spawn(move || loop {
let command = commands_rx.recv_timeout(max_time_between_queries).unwrap_or(PaCommand::QueryState);
match command {
PaCommand::SetIsMuted { id, value } => {
let action = if value { "--mute" } else { "--unmute" };
let status = Command::new("pulsemixer").args(["--id", &id.to_string(), action]).status().unwrap();
if !status.success() {
panic!("(Un-)muting failed with status code {:?}", status.code());
}
}
PaCommand::SetChannelVolumes { id, channel_volumes } => {
let volumes = channel_volumes
.iter()
.map(|v| (v * 100.0).round() as u32)
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(":");
let status = Command::new("pulsemixer")
.args(["--id", &id.to_string(), "--set-volume-all", &volumes])
.status()
.unwrap();
if !status.success() {
panic!("Setting the channel values failed with status code {:?}", status.code());
}
}
PaCommand::Terminate => break,
PaCommand::QueryState => {}
}
query_and_publish_state(&state_tx)
});
PaThread { commands_tx }
}
}
impl Drop for PaThread {
fn drop(&mut self) {
self.commands_tx.send(PaCommand::Terminate).ok();
}
}
#[derive(Debug, Clone)]
pub struct PaVolumeInterface {
#[allow(unused)]
thread: Arc<PaThread>,
state_tx: broadcast::Sender<PaVolumeState>,
commands_tx: flume::Sender<PaCommand>,
}
impl PaVolumeInterface {
pub fn spawn_thread(max_time_between_queries: Duration) -> PaVolumeInterface {
let (commands_tx, commands_rx) = flume::unbounded();
let state_tx = broadcast::Sender::new(5);
let thread = PaThread::spawn(max_time_between_queries, commands_tx.clone(), commands_rx, state_tx.clone());
PaVolumeInterface {
thread: Arc::new(thread),
commands_tx,
state_tx,
}
}
pub fn subscribe_to_state(&self) -> broadcast::Receiver<PaVolumeState> {
self.state_tx.subscribe()
}
pub fn query_state(&self) {
self.commands_tx.send(PaCommand::QueryState).unwrap()
}
pub fn set_is_muted(&self, id: PaEntityId, value: bool) {
self.commands_tx.send(PaCommand::SetIsMuted { id, value }).unwrap()
}
pub fn set_channel_volumes(&self, id: PaEntityId, channel_volumes: impl Into<Box<[f32]>>) {
self.commands_tx
.send(PaCommand::SetChannelVolumes {
id,
channel_volumes: channel_volumes.into(),
})
.unwrap()
}
}