Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src-tauri/src/audio/capture/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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<Self> {
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<String>,
capture_sink: bool,
sample_rate: u32,
channels: u32,
callback: Box<dyn FnMut(&[f32]) + Send>,
) -> AppResult<Capture> {
let (sender, receiver) = pw::channel::channel::<Terminate>();
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:?}");
}
});
Expand All @@ -85,6 +91,8 @@ fn run(
receiver: pw::channel::Receiver<Terminate>,
target: Option<String>,
capture_sink: bool,
sample_rate: u32,
channels: u32,
callback: Box<dyn FnMut(&[f32]) + Send>,
) -> Result<(), pw::Error> {
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
Expand Down Expand Up @@ -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(),
Expand Down
31 changes: 28 additions & 3 deletions src-tauri/src/audio/device/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,34 @@ use crate::error::AppResult;

use super::{DeviceInfo, DeviceKind, NativeDeviceInfo};

pub fn device_info(_kind: DeviceKind, _name: &str) -> AppResult<NativeDeviceInfo> {
// 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<NativeDeviceInfo> {
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,
Expand Down
32 changes: 22 additions & 10 deletions src-tauri/src/audio/pipeline/input/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedInput> {
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 {
Expand All @@ -42,22 +49,27 @@ pub(in crate::audio::pipeline) fn start_input_stream(
app: &AppHandle,
) -> AppResult<InputHandle> {
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))
}
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/audio/pipeline/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ pub(super) enum ResolvedInput {
PwSource {
node_id: String,
sample_rate: u32,
channels: u32,
},
SystemAudio {
sample_rate: u32,
Expand Down Expand Up @@ -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,
}
Expand Down
14 changes: 10 additions & 4 deletions src-tauri/src/audio/pipeline/output/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ pub(in crate::audio::pipeline) struct SpeakerHandle {
}

pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult<SpeakerResolved> {
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,
})
}

Expand All @@ -38,7 +39,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream(
meter: crate::audio::effects::MeterHandle,
_app: &AppHandle,
) -> AppResult<(SpeakerHandle, WorkerCtrl, Arc<AtomicBool>, 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(
Expand All @@ -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,
Expand Down
38 changes: 25 additions & 13 deletions src-tauri/src/audio/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<f32>();
const STRIDE: usize = F32_SIZE * CHANNELS;

struct Terminate;

struct UserData {
fill: Box<dyn FnMut(&mut [f32]) -> usize>,
scratch: Vec<f32>,
}

pub struct Playback {
Expand All @@ -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<Self> {
let (sender, receiver) = pw::channel::channel::<Terminate>();
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:?}");
}
});
Expand All @@ -52,6 +52,8 @@ impl Playback {
fn run(
receiver: pw::channel::Receiver<Terminate>,
sink_node_name: &str,
sample_rate: u32,
channels: usize,
fill: Box<dyn FnMut(&mut [f32]) -> usize + Send>,
) -> Result<(), pw::Error> {
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
Expand All @@ -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;
};
Expand All @@ -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(),
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions src-tauri/src/audio/pw_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ pub struct PwNode {
pub id: u32,
pub name: String,
pub description: String,
pub sample_rate: Option<u32>,
pub channels: Option<u32>,
}

pub fn nodes_by_class(media_class: &'static str) -> AppResult<Vec<PwNode>> {
Expand Down Expand Up @@ -46,10 +48,33 @@ fn snapshot(media_class: &str) -> AppResult<Vec<PwNode>> {
.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::<u32>().ok()
} else {
r.trim().parse::<u32>().ok()
}
});
let channels = props
.get("audio.channels")
.and_then(|c| c.trim().parse::<u32>().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();
Expand Down
Loading
Loading