use std::{cell::RefCell, mem::ManuallyDrop, rc::Rc};
use libpulse_binding as pulse;
use pulse::{
callbacks::ListResult,
context::{Context, FlagSet as ContextFlagSet, State as ContextState},
mainloop::standard::{IterateResult, Mainloop},
operation::State as OperationState,
volume::{ChannelVolumes, Volume},
};
use volumecontrol_core::{AudioError, DeviceInfo};
#[derive(Clone)]
pub(crate) struct SinkSnapshot {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) volume: u8,
pub(crate) mute: bool,
}
fn volume_to_pct(v: Volume) -> u8 {
let norm = Volume::NORMAL.0 as f64;
if norm == 0.0 {
return 0;
}
((v.0 as f64 / norm) * 100.0).round().clamp(0.0, 100.0) as u8
}
fn pct_to_volume(pct: u8) -> Volume {
let norm = Volume::NORMAL.0 as f64;
Volume(((pct.min(100) as f64 / 100.0) * norm).round() as u32)
}
fn connect() -> Result<(Mainloop, Context), AudioError> {
let mut mainloop = Mainloop::new().ok_or_else(|| {
AudioError::InitializationFailed("could not create PulseAudio main loop".into())
})?;
let mut context = Context::new(&mainloop, "volumecontrol").ok_or_else(|| {
AudioError::InitializationFailed("could not create PulseAudio context".into())
})?;
context
.connect(None, ContextFlagSet::NOFLAGS, None)
.map_err(|e| {
AudioError::InitializationFailed(format!("PulseAudio connect error: {e:?}"))
})?;
loop {
match mainloop.iterate(false) {
IterateResult::Quit(_) => {
return Err(AudioError::InitializationFailed(
"PulseAudio main loop quit during connect".into(),
))
}
IterateResult::Err(e) => {
return Err(AudioError::InitializationFailed(format!(
"PulseAudio main loop error during connect: {e:?}"
)))
}
IterateResult::Success(_) => {}
}
match context.get_state() {
ContextState::Ready => break,
ContextState::Failed | ContextState::Terminated => {
return Err(AudioError::InitializationFailed(
"PulseAudio context failed to connect to server".into(),
))
}
_ => {}
}
}
Ok((mainloop, context))
}
fn wait_for_op<C: ?Sized>(
mainloop: &mut Mainloop,
op: &pulse::operation::Operation<C>,
) -> Result<(), AudioError> {
loop {
match mainloop.iterate(false) {
IterateResult::Quit(_) => {
return Err(AudioError::InitializationFailed(
"PulseAudio main loop quit unexpectedly".into(),
))
}
IterateResult::Err(e) => {
return Err(AudioError::InitializationFailed(format!(
"PulseAudio main loop error: {e:?}"
)))
}
IterateResult::Success(_) => {}
}
match op.get_state() {
OperationState::Done => return Ok(()),
OperationState::Cancelled => {
return Err(AudioError::InitializationFailed(
"PulseAudio operation was cancelled".into(),
))
}
OperationState::Running => {}
}
}
}
pub(crate) struct PulseConnection {
mainloop: ManuallyDrop<Mainloop>,
context: ManuallyDrop<Context>,
}
impl Drop for PulseConnection {
fn drop(&mut self) {
unsafe {
ManuallyDrop::drop(&mut self.context);
ManuallyDrop::drop(&mut self.mainloop);
}
}
}
impl PulseConnection {
pub(crate) fn new() -> Result<Self, AudioError> {
let (mainloop, context) = connect()?;
Ok(Self {
mainloop: ManuallyDrop::new(mainloop),
context: ManuallyDrop::new(context),
})
}
fn ensure_ready(&mut self) -> Result<(), AudioError> {
if !matches!(self.context.get_state(), ContextState::Ready) {
let (mainloop, context) = connect()?;
unsafe {
ManuallyDrop::drop(&mut self.context);
ManuallyDrop::drop(&mut self.mainloop);
}
self.context = ManuallyDrop::new(context);
self.mainloop = ManuallyDrop::new(mainloop);
}
Ok(())
}
pub(crate) fn default_sink_name(&mut self) -> Result<String, AudioError> {
self.ensure_ready()?;
let result: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let result_cb = Rc::clone(&result);
let op = self.context.introspect().get_server_info(move |info| {
*result_cb.borrow_mut() = info.default_sink_name.as_deref().map(String::from);
});
wait_for_op(&mut self.mainloop, &op)?;
let borrowed = result.borrow();
let name = borrowed.clone().ok_or(AudioError::DeviceNotFound)?;
Ok(name)
}
pub(crate) fn sink_by_name(&mut self, name: &str) -> Result<SinkSnapshot, AudioError> {
self.ensure_ready()?;
let result: Rc<RefCell<Option<SinkSnapshot>>> = Rc::new(RefCell::new(None));
let result_cb = Rc::clone(&result);
let op = self
.context
.introspect()
.get_sink_info_by_name(name, move |list| {
if let ListResult::Item(info) = list {
*result_cb.borrow_mut() = Some(SinkSnapshot {
name: opt_cow_str(info.name.as_ref()),
description: opt_cow_str(info.description.as_ref()),
volume: volume_to_pct(info.volume.avg()),
mute: info.mute,
});
}
});
wait_for_op(&mut self.mainloop, &op)?;
let snap = result.borrow().clone().ok_or(AudioError::DeviceNotFound)?;
Ok(snap)
}
pub(crate) fn sink_matching_description(
&mut self,
query: &str,
) -> Result<SinkSnapshot, AudioError> {
let query_lower = query.to_lowercase();
self.list_sink_snapshots()?
.into_iter()
.find(|s| s.description.to_lowercase().contains(&query_lower))
.ok_or(AudioError::DeviceNotFound)
}
pub(crate) fn list_sinks(&mut self) -> Result<Vec<DeviceInfo>, AudioError> {
Ok(self
.list_sink_snapshots()?
.into_iter()
.map(|s| DeviceInfo {
id: s.name,
name: s.description,
})
.collect())
}
pub(crate) fn set_sink_volume(&mut self, name: &str, vol: u8) -> Result<(), AudioError> {
self.ensure_ready()?;
let cv: Rc<RefCell<Option<ChannelVolumes>>> = Rc::new(RefCell::new(None));
let cv_cb = Rc::clone(&cv);
let op = self
.context
.introspect()
.get_sink_info_by_name(name, move |list| {
if let ListResult::Item(info) = list {
*cv_cb.borrow_mut() = Some(info.volume);
}
});
wait_for_op(&mut self.mainloop, &op)?;
let cv_opt: Option<ChannelVolumes> = *cv.borrow();
let mut volumes = cv_opt.ok_or(AudioError::DeviceNotFound)?;
let pa_vol = pct_to_volume(vol);
volumes.set(volumes.len(), pa_vol);
let success: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
let success_cb = Rc::clone(&success);
let mut insp = self.context.introspect();
let op2 = insp.set_sink_volume_by_name(
name,
&volumes,
Some(Box::new(move |ok| *success_cb.borrow_mut() = ok)),
);
wait_for_op(&mut self.mainloop, &op2)?;
if *success.borrow() {
Ok(())
} else {
Err(AudioError::SetVolumeFailed(
"PulseAudio server rejected the volume change".into(),
))
}
}
pub(crate) fn set_sink_mute(&mut self, name: &str, muted: bool) -> Result<(), AudioError> {
self.ensure_ready()?;
let success: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
let success_cb = Rc::clone(&success);
let mut insp = self.context.introspect();
let op = insp.set_sink_mute_by_name(
name,
muted,
Some(Box::new(move |ok| *success_cb.borrow_mut() = ok)),
);
wait_for_op(&mut self.mainloop, &op)?;
if *success.borrow() {
Ok(())
} else {
Err(AudioError::SetMuteFailed(
"PulseAudio server rejected the mute state change".into(),
))
}
}
fn list_sink_snapshots(&mut self) -> Result<Vec<SinkSnapshot>, AudioError> {
self.ensure_ready()?;
let result: Rc<RefCell<Vec<SinkSnapshot>>> = Rc::new(RefCell::new(Vec::new()));
let result_cb = Rc::clone(&result);
let op = self.context.introspect().get_sink_info_list(move |list| {
if let ListResult::Item(info) = list {
result_cb.borrow_mut().push(SinkSnapshot {
name: opt_cow_str(info.name.as_ref()),
description: opt_cow_str(info.description.as_ref()),
volume: volume_to_pct(info.volume.avg()),
mute: info.mute,
});
}
});
wait_for_op(&mut self.mainloop, &op)?;
let sinks = result.borrow().clone();
Ok(sinks)
}
}
fn opt_cow_str(s: Option<&std::borrow::Cow<'_, str>>) -> String {
s.map(|c| c.as_ref()).unwrap_or("").to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn volume_to_pct_normal_is_100() {
assert_eq!(volume_to_pct(Volume::NORMAL), 100);
}
#[test]
fn volume_to_pct_muted_is_0() {
assert_eq!(volume_to_pct(Volume::MUTED), 0);
}
#[test]
fn pct_to_volume_100_is_normal() {
assert_eq!(pct_to_volume(100), Volume::NORMAL);
}
#[test]
fn pct_to_volume_0_is_muted() {
assert_eq!(pct_to_volume(0), Volume::MUTED);
}
#[test]
fn round_trip_volume_pct() {
for pct in [0u8, 25, 50, 75, 100] {
let recovered = volume_to_pct(pct_to_volume(pct));
assert_eq!(recovered, pct, "round-trip failed for {pct}%");
}
}
#[test]
fn pct_to_volume_clamps_above_100() {
assert_eq!(pct_to_volume(200_u8), pct_to_volume(100));
}
}