diff --git a/src-tauri/src/audio/capture/linux.rs b/src-tauri/src/audio/capture/linux.rs index 76a5feff..447a415e 100644 --- a/src-tauri/src/audio/capture/linux.rs +++ b/src-tauri/src/audio/capture/linux.rs @@ -48,30 +48,36 @@ impl Capture { // Capture a real source node (microphone) by node.name. pub fn start_source( node_name: &str, + sample_rate: u32, + channels: u32, callback: impl FnMut(&[f32]) + Send + 'static, ) -> AppResult { - spawn(Some(node_name.to_string()), false, Box::new(callback)) + spawn(Some(node_name.to_string()), false, sample_rate, channels, Box::new(callback)) } // Capture the monitor of a specific sink by its node.name. pub fn start_sink_monitor( sink_node_name: &str, + sample_rate: u32, + channels: u32, callback: impl FnMut(&[f32]) + Send + 'static, ) -> AppResult { - spawn(Some(sink_node_name.to_string()), true, Box::new(callback)) + spawn(Some(sink_node_name.to_string()), true, sample_rate, channels, Box::new(callback)) } } fn spawn( target: Option, capture_sink: bool, + sample_rate: u32, + channels: u32, callback: Box, ) -> AppResult { let (sender, receiver) = pw::channel::channel::(); let thread = std::thread::spawn(move || { // Drain the PipeWire stream on a real-time thread so delivery keeps up. - let _rt = RtThread::promote("capture", 48_000); - if let Err(e) = run(receiver, target, capture_sink, callback) { + let _rt = RtThread::promote("capture", sample_rate); + if let Err(e) = run(receiver, target, capture_sink, sample_rate, channels, callback) { tracing::error!("pipewire capture: {e:?}"); } }); @@ -85,6 +91,8 @@ fn run( receiver: pw::channel::Receiver, target: Option, capture_sink: bool, + sample_rate: u32, + channels: u32, callback: Box, ) -> Result<(), pw::Error> { let mainloop = pw::main_loop::MainLoopRc::new(None)?; @@ -138,8 +146,8 @@ fn run( let mut audio_info = AudioInfoRaw::new(); audio_info.set_format(AudioFormat::F32LE); - audio_info.set_rate(48_000); - audio_info.set_channels(2); + audio_info.set_rate(sample_rate); + audio_info.set_channels(channels); let obj = spa::pod::Object { type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(), diff --git a/src-tauri/src/audio/device/linux.rs b/src-tauri/src/audio/device/linux.rs index 1d1fcc48..faa0734e 100644 --- a/src-tauri/src/audio/device/linux.rs +++ b/src-tauri/src/audio/device/linux.rs @@ -3,9 +3,34 @@ use crate::error::AppResult; use super::{DeviceInfo, DeviceKind, NativeDeviceInfo}; -pub fn device_info(_kind: DeviceKind, _name: &str) -> AppResult { - // PipeWire converts to whatever we request, so the graph always runs at - // 48 kHz stereo f32. +pub fn device_info(kind: DeviceKind, name: &str) -> AppResult { + let clean_name = name.strip_prefix("monitor:").unwrap_or(name); + + // Check if it's one of Splitwave's virtual devices first + if let Some(vd) = crate::audio::virtual_device::find_virtual_device(clean_name) { + return Ok(NativeDeviceInfo { + sample_rate: vd.sample_rate, + channels: vd.channels as u16, + sample_format: "f32", + }); + } + + // Otherwise check PipeWire nodes + let class = match kind { + DeviceKind::Input if name.starts_with("monitor:") => "Audio/Sink", + DeviceKind::Input => "Audio/Source", + DeviceKind::Output => "Audio/Sink", + }; + if let Ok(nodes) = nodes_by_class(class) { + if let Some(node) = nodes.into_iter().find(|n| n.name == clean_name) { + return Ok(NativeDeviceInfo { + sample_rate: node.sample_rate.unwrap_or(48_000), + channels: node.channels.unwrap_or(2) as u16, + sample_format: "f32", + }); + } + } + Ok(NativeDeviceInfo { sample_rate: 48_000, channels: 2, diff --git a/src-tauri/src/audio/pipeline/input/linux.rs b/src-tauri/src/audio/pipeline/input/linux.rs index 9dc9a737..e705b355 100644 --- a/src-tauri/src/audio/pipeline/input/linux.rs +++ b/src-tauri/src/audio/pipeline/input/linux.rs @@ -12,10 +12,17 @@ use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput, SC pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult { match &inp.spec { - InputSpec::Microphone { device_id } => Ok(ResolvedInput::PwSource { - node_id: device_id.clone(), - sample_rate: 48_000, - }), + InputSpec::Microphone { device_id } => { + let info = crate::audio::device::device_info( + crate::audio::device::DeviceKind::Input, + device_id, + )?; + Ok(ResolvedInput::PwSource { + node_id: device_id.clone(), + sample_rate: info.sample_rate, + channels: info.channels as u32, + }) + } InputSpec::SystemAudio { exclude_current_app, } => Ok(ResolvedInput::SystemAudio { @@ -42,22 +49,27 @@ pub(in crate::audio::pipeline) fn start_input_stream( app: &AppHandle, ) -> AppResult { match resolved { - ResolvedInput::PwSource { node_id, .. } => { + ResolvedInput::PwSource { + node_id, + sample_rate, + channels, + } => { let mut bridge = bridge; let meter = meter; + let ch = channels as usize; let cb = move |samples: &[f32]| { bridge.apply_commands(); if let Some(m) = &meter { - crate::audio::effects::update_meter(m, samples, 2); + crate::audio::effects::update_meter(m, samples, ch); } bridge.broadcast(samples); }; let capture = if let Some(sink) = node_id.strip_prefix("monitor:") { - info!(sink, "starting microphone capture (PipeWire sink monitor)"); - crate::audio::capture::Capture::start_sink_monitor(sink, cb)? + info!(sink, sample_rate, channels, "starting microphone capture (PipeWire sink monitor)"); + crate::audio::capture::Capture::start_sink_monitor(sink, sample_rate, channels, cb)? } else { - info!(%node_id, "starting microphone capture (PipeWire source)"); - crate::audio::capture::Capture::start_source(&node_id, cb)? + info!(%node_id, sample_rate, channels, "starting microphone capture (PipeWire source)"); + crate::audio::capture::Capture::start_source(&node_id, sample_rate, channels, cb)? }; Ok(InputHandle::Capture(capture)) } diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index 4907109c..6bc161c9 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -115,6 +115,7 @@ pub(super) enum ResolvedInput { PwSource { node_id: String, sample_rate: u32, + channels: u32, }, SystemAudio { sample_rate: u32, @@ -152,6 +153,8 @@ impl ResolvedInput { match self { #[cfg(any(target_os = "macos", target_os = "windows"))] ResolvedInput::Cpal { src_channels, .. } => (*src_channels as u32).max(1), + #[cfg(target_os = "linux")] + ResolvedInput::PwSource { channels, .. } => (*channels).max(1), ResolvedInput::AudioFile { channels, .. } => (*channels).max(1), _ => 2, } diff --git a/src-tauri/src/audio/pipeline/output/linux.rs b/src-tauri/src/audio/pipeline/output/linux.rs index 8d0b1824..5f76b6a0 100644 --- a/src-tauri/src/audio/pipeline/output/linux.rs +++ b/src-tauri/src/audio/pipeline/output/linux.rs @@ -24,10 +24,11 @@ pub(in crate::audio::pipeline) struct SpeakerHandle { } pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult { + let info = crate::audio::device::device_info(crate::audio::device::DeviceKind::Output, device_id)?; Ok(SpeakerResolved { node_id: device_id.to_string(), - sample_rate: 48_000, - out_channels: 2, + sample_rate: info.sample_rate, + out_channels: info.channels as usize, }) } @@ -38,7 +39,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( meter: crate::audio::effects::MeterHandle, _app: &AppHandle, ) -> AppResult<(SpeakerHandle, WorkerCtrl, Arc, SpeakerIo)> { - info!(node = %spec.node_id, sample_rate = spec.sample_rate, "opening speaker stream (PipeWire)"); + info!(node = %spec.node_id, sample_rate = spec.sample_rate, channels = spec.out_channels, "opening speaker stream (PipeWire)"); let dead = Arc::new(AtomicBool::new(false)); let (producer, mut fill, level, target, io) = speaker_ring( @@ -51,7 +52,12 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( fill(out, 0); out.len() }; - let playback = crate::audio::playback::Playback::start(&spec.node_id, fill_pw)?; + let playback = crate::audio::playback::Playback::start( + &spec.node_id, + spec.sample_rate, + spec.out_channels, + fill_pw, + )?; let (worker_handle, ctrl) = spawn_speaker_worker( producer, diff --git a/src-tauri/src/audio/playback.rs b/src-tauri/src/audio/playback.rs index e16dd148..9306d112 100644 --- a/src-tauri/src/audio/playback.rs +++ b/src-tauri/src/audio/playback.rs @@ -5,15 +5,13 @@ use pw::spa::pod::Pod; use crate::error::AppResult; -const CHANNELS: usize = 2; -const RATE: u32 = 48_000; const F32_SIZE: usize = std::mem::size_of::(); -const STRIDE: usize = F32_SIZE * CHANNELS; struct Terminate; struct UserData { fill: Box usize>, + scratch: Vec, } pub struct Playback { @@ -33,12 +31,14 @@ impl Drop for Playback { impl Playback { pub fn start( sink_node_name: &str, + sample_rate: u32, + channels: usize, fill: impl FnMut(&mut [f32]) -> usize + Send + 'static, ) -> AppResult { let (sender, receiver) = pw::channel::channel::(); let target = sink_node_name.to_string(); let thread = std::thread::spawn(move || { - if let Err(e) = run(receiver, &target, Box::new(fill)) { + if let Err(e) = run(receiver, &target, sample_rate, channels, Box::new(fill)) { tracing::error!("pipewire playback: {e:?}"); } }); @@ -52,6 +52,8 @@ impl Playback { fn run( receiver: pw::channel::Receiver, sink_node_name: &str, + sample_rate: u32, + channels: usize, fill: Box usize + Send>, ) -> Result<(), pw::Error> { let mainloop = pw::main_loop::MainLoopRc::new(None)?; @@ -71,11 +73,15 @@ fn run( props.insert(*pw::keys::TARGET_OBJECT, sink_node_name); let stream = pw::stream::StreamRc::new(core.clone(), "splitwave-playback", props)?; - let user_data = UserData { fill }; + let user_data = UserData { + fill, + scratch: Vec::with_capacity(32768), + }; + let stride = (F32_SIZE * channels) as i32; let _listener = stream .add_local_listener_with_user_data(user_data) - .process(|stream, user_data| { + .process(move |_stream, user_data| { let Some(mut buffer) = stream.dequeue_buffer() else { return; }; @@ -89,22 +95,28 @@ fn run( if capacity == 0 { return; } - let mut samples = vec![0.0f32; capacity]; - let written = (user_data.fill)(&mut samples).min(capacity); + if user_data.scratch.len() < capacity { + user_data.scratch.resize(capacity, 0.0); + } + let samples = &mut user_data.scratch[..capacity]; + let written = (user_data.fill)(samples).min(capacity); for (i, s) in samples[..written].iter().enumerate() { raw[i * F32_SIZE..(i + 1) * F32_SIZE].copy_from_slice(&s.to_le_bytes()); } + for i in written..capacity { + raw[i * F32_SIZE..(i + 1) * F32_SIZE].copy_from_slice(&0.0_f32.to_le_bytes()); + } let chunk = data.chunk_mut(); *chunk.offset_mut() = 0; - *chunk.stride_mut() = STRIDE as i32; - *chunk.size_mut() = (written * F32_SIZE) as u32; + *chunk.stride_mut() = stride; + *chunk.size_mut() = (capacity * F32_SIZE) as u32; }) .register()?; let mut audio_info = AudioInfoRaw::new(); audio_info.set_format(AudioFormat::F32LE); - audio_info.set_rate(RATE); - audio_info.set_channels(CHANNELS as u32); + audio_info.set_rate(sample_rate); + audio_info.set_channels(channels as u32); let obj = spa::pod::Object { type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(), @@ -144,7 +156,7 @@ mod tests { let c = calls.clone(); let t = total.clone(); let mut phase = 0.0f32; - let pb = Playback::start("alsa_output.pci-0000_00_0a.0.stereo-fallback", move |buf| { + let pb = Playback::start("alsa_output.pci-0000_00_0a.0.stereo-fallback", 48_000, 2, move |buf| { c.fetch_add(1, Ordering::Relaxed); t.fetch_add(buf.len(), Ordering::Relaxed); for f in buf.chunks_mut(2) { diff --git a/src-tauri/src/audio/pw_enum.rs b/src-tauri/src/audio/pw_enum.rs index 534763a4..79cf7705 100644 --- a/src-tauri/src/audio/pw_enum.rs +++ b/src-tauri/src/audio/pw_enum.rs @@ -10,6 +10,8 @@ pub struct PwNode { pub id: u32, pub name: String, pub description: String, + pub sample_rate: Option, + pub channels: Option, } pub fn nodes_by_class(media_class: &'static str) -> AppResult> { @@ -46,10 +48,33 @@ fn snapshot(media_class: &str) -> AppResult> { .filter(|d| !d.is_empty()) .unwrap_or(name) .to_string(); + let sample_rate = props + .get("audio.rate") + .or_else(|| props.get("node.rate")) + .and_then(|r| { + if let Some((_, denom)) = r.split_once('/') { + denom.trim().parse::().ok() + } else { + r.trim().parse::().ok() + } + }); + let channels = props + .get("audio.channels") + .and_then(|c| c.trim().parse::().ok()) + .or_else(|| { + props.get("audio.position").map(|p| { + p.trim_matches(|c| c == '[' || c == ']') + .split(|c| c == ',' || c == ' ') + .filter(|s| !s.trim().is_empty()) + .count() as u32 + }) + }); nodes_cb.borrow_mut().push(PwNode { id: global.id, name: name.to_string(), description, + sample_rate, + channels, }); }) .register(); diff --git a/src-tauri/src/audio/virtual_device/linux.rs b/src-tauri/src/audio/virtual_device/linux.rs index 3aa26324..9b2bfe77 100644 --- a/src-tauri/src/audio/virtual_device/linux.rs +++ b/src-tauri/src/audio/virtual_device/linux.rs @@ -61,12 +61,86 @@ pub fn uninstall() -> Result<(), String> { Ok(()) } +use std::sync::Mutex; + +static CACHED_DEVICES: Mutex>> = Mutex::new(None); + +pub fn find_virtual_device(id_or_name: &str) -> Option { + let clean = id_or_name.strip_prefix("monitor:").unwrap_or(id_or_name); + let mut guard = CACHED_DEVICES.lock().unwrap(); + if guard.is_none() { + *guard = Some(load_devices_from_conf()); + } + guard.as_ref()?.iter().find(|d| { + d.id == clean || format!("{NODE_PREFIX}.{}", d.id) == clean + }).cloned() +} + +fn load_devices_from_conf() -> Vec { + let Some(path) = conf_path() else { return Vec::new() }; + let Ok(content) = std::fs::read_to_string(path) else { return Vec::new() }; + parse_conf_devices(&content) +} + +fn parse_conf_devices(content: &str) -> Vec { + let mut devices = Vec::new(); + let mut cur_id = None; + let mut cur_name = None; + let mut cur_channels = 2; + let mut cur_rate = 48_000; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("node.name") { + if let Some(val) = trimmed.split('=').nth(1) { + let name = val.trim().trim_matches('"'); + if let Some(id) = name.strip_prefix(&format!("{NODE_PREFIX}.")) { + cur_id = Some(id.to_string()); + } + } + } else if trimmed.starts_with("node.description") { + if let Some(val) = trimmed.split('=').nth(1) { + cur_name = Some(val.trim().trim_matches('"').to_string()); + } + } else if trimmed.starts_with("audio.channels") { + if let Some(val) = trimmed.split('=').nth(1) { + if let Ok(ch) = val.trim().parse::() { + cur_channels = ch; + } + } + } else if trimmed.starts_with("audio.rate") { + if let Some(val) = trimmed.split('=').nth(1) { + if let Ok(r) = val.trim().parse::() { + cur_rate = r; + } + } + } else if trimmed == "}" { + if let (Some(id), Some(name)) = (cur_id.take(), cur_name.take()) { + devices.push(VirtualDeviceConfig { + id, + name, + channels: cur_channels, + sample_rate: cur_rate, + }); + cur_channels = 2; + cur_rate = 48_000; + } + } + } + devices +} + pub fn apply_virtual_devices(devices: Vec) -> Result<(), String> { + *CACHED_DEVICES.lock().unwrap() = Some(devices.clone()); + unload_runtime_sinks(); let conf = conf_path().ok_or("no config directory")?; if devices.is_empty() { let _ = std::fs::remove_file(&conf); + let _ = std::process::Command::new("systemctl") + .args(["--user", "restart", "pipewire"]) + .output(); return Ok(()); } @@ -75,8 +149,18 @@ pub fn apply_virtual_devices(devices: Vec) -> Result<(), St } std::fs::write(&conf, conf_contents(&devices)).map_err(|e| format!("write conf: {e}"))?; - for d in &devices { - create_runtime_sink(&d.id, &clean_label(&d.name), d.channels, d.sample_rate)?; + // Try restarting pipewire user service if systemd is available so that PipeWire + // recreates all sinks cleanly from the updated config file at the new sample rates. + let restarted = std::process::Command::new("systemctl") + .args(["--user", "restart", "pipewire"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !restarted { + for d in &devices { + create_runtime_sink(&d.id, &clean_label(&d.name), d.channels, d.sample_rate)?; + } } Ok(()) } @@ -96,11 +180,13 @@ fn conf_contents(devices: &[VirtualDeviceConfig]) -> String { )); out.push_str(&format!(" node.description = \"{desc}\"\n")); out.push_str(" media.class = Audio/Sink\n"); + out.push_str(&format!(" audio.channels = {}\n", d.channels)); out.push_str(&format!( " audio.position = [ {} ]\n", positions(d.channels) )); out.push_str(&format!(" audio.rate = {}\n", d.sample_rate)); + out.push_str(&format!(" node.rate = 1/{}\n", d.sample_rate)); out.push_str(" object.linger = true\n"); out.push_str(" }\n"); out.push_str(" }\n"); @@ -159,8 +245,10 @@ fn create_runtime_sink( }; props.insert(*pw::keys::NODE_NAME, format!("{NODE_PREFIX}.{id}")); props.insert(*pw::keys::NODE_DESCRIPTION, label.to_string()); - props.insert("audio.position", positions(channels)); + props.insert("audio.channels", channels.to_string()); + props.insert("audio.position", format!("[ {} ]", positions(channels))); props.insert("audio.rate", sample_rate.to_string()); + props.insert("node.rate", format!("1/{sample_rate}")); let _node: pw::node::Node = core .create_object("adapter", &props) .map_err(|e| format!("create null sink: {e}"))?; @@ -194,3 +282,38 @@ fn unload_runtime_sinks() { roundtrip(core, mainloop) }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_conf_devices_correctly() { + let conf = r#" +# Auto-generated by Splitwave. Do not edit. + +context.objects = [ + { + factory = adapter + args = { + factory.name = support.null-audio-sink + node.name = "splitwave.test123" + node.description = "Test Virtual Device" + media.class = Audio/Sink + audio.channels = 6 + audio.position = [ FL FR FC LFE SL SR ] + audio.rate = 96000 + node.rate = 1/96000 + object.linger = true + } + } +] +"#; + let devices = parse_conf_devices(conf); + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].id, "test123"); + assert_eq!(devices[0].name, "Test Virtual Device"); + assert_eq!(devices[0].channels, 6); + assert_eq!(devices[0].sample_rate, 96000); + } +} diff --git a/src-tauri/src/audio/virtual_device/mod.rs b/src-tauri/src/audio/virtual_device/mod.rs index 1c4f1e5c..89168f35 100644 --- a/src-tauri/src/audio/virtual_device/mod.rs +++ b/src-tauri/src/audio/virtual_device/mod.rs @@ -43,7 +43,13 @@ pub use macos::{apply_virtual_devices, install, status, uninstall}; #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] -pub use linux::{apply_virtual_devices, install, status, uninstall}; +pub use linux::{apply_virtual_devices, find_virtual_device, install, status, uninstall}; + +#[cfg(not(target_os = "linux"))] +#[allow(dead_code)] +pub fn find_virtual_device(_id_or_name: &str) -> Option { + None +} #[cfg(target_os = "windows")] mod windows;