#![forbid(unsafe_code)]
use std::collections::VecDeque;
use std::sync::mpsc::{SyncSender, sync_channel};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use crate::audio::{AudioCapture, AudioCaptureConfig};
use crate::{CaptureError, Select};
use mediaway_common::{AudioFrame, Bytes, CodecKind, Rational, StreamInfo};
use pipewire as pw;
use pw::properties::properties;
use pw::spa;
const PCM_QUEUE_CAP: usize = 64;
struct SharedQueue {
frames: Mutex<VecDeque<AudioFrame>>,
}
struct MicSession {
stream_info: StreamInfo,
queue: Arc<SharedQueue>,
quit_tx: Option<pw::channel::Sender<()>>,
worker: Option<JoinHandle<()>>,
}
pub struct LinuxMicrophoneCapture {
inner: Option<MicSession>,
}
impl LinuxMicrophoneCapture {
pub fn open(config: &AudioCaptureConfig) -> Result<Self, CaptureError> {
if config.select != Select::Default {
return Err(CaptureError::Unsupported);
}
if config.sample_format != mediaway_common::SampleFormat::F32 {
return Err(CaptureError::Unsupported);
}
if config.time_base.den == 0 {
return Err(CaptureError::InvalidInput);
}
let queue = Arc::new(SharedQueue {
frames: Mutex::new(VecDeque::new()),
});
let queue_worker = Arc::clone(&queue);
let time_base = config.time_base;
let (tx_info, rx_info) = sync_channel::<Result<StreamInfo, CaptureError>>(1);
let (quit_tx, quit_rx) = pw::channel::channel::<()>();
let worker = thread::Builder::new()
.name("mediaway-pw-mic".into())
.spawn(move || {
run_pipewire_mic_worker(queue_worker, time_base, tx_info, quit_rx);
})
.map_err(|_| CaptureError::Backend)?;
let stream_info = rx_info.recv().map_err(|_| CaptureError::Backend)??;
Ok(Self {
inner: Some(MicSession {
stream_info,
queue,
quit_tx: Some(quit_tx),
worker: Some(worker),
}),
})
}
}
impl AudioCapture for LinuxMicrophoneCapture {
fn stream_info(&self) -> &StreamInfo {
#[allow(
clippy::option_if_let_else,
reason = "map_or_else forces 'static vs 'self lifetime clash"
)]
if let Some(s) = self.inner.as_ref() {
&s.stream_info
} else {
closed_audio_info()
}
}
fn poll_frame(&mut self) -> Result<Option<AudioFrame>, CaptureError> {
let Some(session) = self.inner.as_ref() else {
return Err(CaptureError::Closed);
};
let mut q = session
.queue
.frames
.lock()
.map_err(|_| CaptureError::Backend)?;
Ok(q.pop_front())
}
fn close(&mut self) -> Result<(), CaptureError> {
let Some(mut session) = self.inner.take() else {
return Ok(());
};
if let Some(tx) = session.quit_tx.take() {
let _ = tx.send(());
}
if let Some(h) = session.worker.take() {
let _ = h.join();
}
Ok(())
}
}
impl Drop for LinuxMicrophoneCapture {
fn drop(&mut self) {
let _ = self.close();
}
}
fn closed_audio_info() -> &'static StreamInfo {
use std::sync::OnceLock;
static INFO: OnceLock<StreamInfo> = OnceLock::new();
INFO.get_or_init(|| StreamInfo::Audio {
id: 0,
codec: CodecKind::RawAudio,
time_base: Rational::new(1, 48_000),
sample_rate: 0,
channels: 0,
extra_data: Bytes::new(),
})
}
struct StreamUserData {
format: spa::param::audio::AudioInfoRaw,
info_tx: Option<SyncSender<Result<StreamInfo, CaptureError>>>,
time_base: Rational,
queue: Arc<SharedQueue>,
next_pts: i64,
}
fn run_pipewire_mic_worker(
queue: Arc<SharedQueue>,
time_base: Rational,
tx_info: SyncSender<Result<StreamInfo, CaptureError>>,
quit_rx: pw::channel::Receiver<()>,
) {
let tx_info_fallback = tx_info.clone();
if let Err(e) = try_run_pipewire_mic(queue, time_base, tx_info, quit_rx) {
let _ = tx_info_fallback.send(Err(e));
}
}
fn try_run_pipewire_mic(
queue: Arc<SharedQueue>,
time_base: Rational,
tx_info: SyncSender<Result<StreamInfo, CaptureError>>,
quit_rx: pw::channel::Receiver<()>,
) -> Result<(), CaptureError> {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|_| CaptureError::Backend)?;
let context =
pw::context::ContextBox::new(mainloop.loop_(), None).map_err(|_| CaptureError::Backend)?;
let core = context.connect(None).map_err(|_| CaptureError::Backend)?;
let mainloop_weak = mainloop.downgrade();
let _quit_listener = quit_rx.attach(mainloop.loop_(), move |()| {
if let Some(m) = mainloop_weak.upgrade() {
m.quit();
}
});
let user_data = StreamUserData {
format: spa::param::audio::AudioInfoRaw::default(),
info_tx: Some(tx_info),
time_base,
queue,
next_pts: 0,
};
let stream = pw::stream::StreamBox::new(
&core,
"mediaway-microphone",
properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
},
)
.map_err(|_| CaptureError::Backend)?;
let _listener = stream
.add_local_listener_with_user_data(user_data)
.param_changed(|_stream, user_data, id, param| on_param_changed(user_data, id, param))
.process(|stream, user_data| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
let Some(data) = datas.first_mut() else {
return;
};
let chunk_offset = usize::try_from(data.chunk().offset()).unwrap_or(0);
let chunk_size = usize::try_from(data.chunk().size()).unwrap_or(0);
let Some(mapped) = data.data() else {
return;
};
let Some(chunk_bytes) =
mapped.get(chunk_offset..chunk_offset.saturating_add(chunk_size))
else {
return;
};
let channels = user_data.format.channels();
let Some(usable) = usable_pcm_len(chunk_bytes.len(), channels) else {
return;
};
let num_frames = usable / (4 * channels as usize);
push_frame(
&user_data.queue,
user_data.format.rate(),
channels,
user_data.next_pts,
&chunk_bytes[..usable],
);
user_data.next_pts = user_data
.next_pts
.saturating_add(i64::try_from(num_frames).unwrap_or(0));
})
.register()
.map_err(|_| CaptureError::Backend)?;
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
let obj = spa::pod::Object {
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
let values = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.map_err(|_| CaptureError::Backend)?
.0
.into_inner();
let mut params = [spa::pod::Pod::from_bytes(&values).ok_or(CaptureError::Backend)?];
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.map_err(|_| CaptureError::Backend)?;
mainloop.run();
Ok(())
}
fn on_param_changed(user_data: &mut StreamUserData, id: u32, param: Option<&spa::pod::Pod>) {
let Some(param) = param else {
return;
};
if id != spa::param::ParamType::Format.as_raw() {
return;
}
let Ok((media_type, media_subtype)) = spa::param::format_utils::parse_format(param) else {
return;
};
if media_type != spa::param::format::MediaType::Audio
|| media_subtype != spa::param::format::MediaSubtype::Raw
{
return;
}
if user_data.format.parse(param).is_err() {
return;
}
if let Some(tx) = user_data.info_tx.take() {
let info = StreamInfo::Audio {
id: 0,
codec: CodecKind::RawAudio,
time_base: user_data.time_base,
extra_data: Bytes::new(),
sample_rate: user_data.format.rate(),
channels: u16::try_from(user_data.format.channels()).unwrap_or(0),
};
let _ = tx.send(Ok(info));
}
}
fn usable_pcm_len(len: usize, channels: u32) -> Option<usize> {
if channels == 0 {
return None;
}
let bytes_per_frame = 4usize.checked_mul(channels as usize)?;
if bytes_per_frame == 0 || len < bytes_per_frame {
return None;
}
Some(len - (len % bytes_per_frame))
}
fn push_frame(queue: &SharedQueue, sample_rate: u32, channels: u32, pts: i64, chunk_bytes: &[u8]) {
let bytes_per_frame = 4usize.saturating_mul(channels as usize).max(1);
let num_frames = u64::try_from(chunk_bytes.len() / bytes_per_frame).unwrap_or(0);
let frame = AudioFrame {
pts,
duration: num_frames,
sample_rate,
channels: u16::try_from(channels).unwrap_or(0),
format: mediaway_common::SampleFormat::F32,
data: Bytes::copy_from_slice(chunk_bytes),
};
if let Ok(mut q) = queue.frames.lock() {
if q.len() >= PCM_QUEUE_CAP {
let _ = q.pop_front();
}
q.push_back(frame);
}
}
pub(crate) fn probe_daemon_reachable() -> bool {
pw::init();
let Ok(mainloop) = pw::main_loop::MainLoopRc::new(None) else {
return false;
};
let Ok(context) = pw::context::ContextBox::new(mainloop.loop_(), None) else {
return false;
};
context.connect(None).is_ok()
}
#[cfg(test)]
#[path = "mic_tests.rs"]
mod tests;