Skip to content
Merged
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,6 @@ If you find this app useful and want to help keep it going, consider supporting

- **Patreon**: [patreon.com/cw/splitwave](https://www.patreon.com/cw/splitwave/membership)
- or crypto donation:
- Tether USDT (TRC20): `TLhTvnn8CtVuQZruLXmRurGhR9GWd7DrWZ`
- TON: `UQCpokpaZfwmVTjKDj0LrAbEPO-65c81-MiuBQOa7lTXbMGR`
- Bitcoin (BTC): `bc1q6tusr5rht7dgmw8gqzkx7rwdg4q8932lwn2rsy`
- Tether USDT (TRC20): `TLhTvnn8CtVuQZruLXmRurGhR9GWd7DrWZ`
- TON: `UQCpokpaZfwmVTjKDj0LrAbEPO-65c81-MiuBQOa7lTXbMGR`
- Bitcoin (BTC): `bc1q6tusr5rht7dgmw8gqzkx7rwdg4q8932lwn2rsy`
63 changes: 62 additions & 1 deletion docs/CONCEPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ See `MeterHandle` / `EffectControl` in `audio/effects.rs`.

Ring buffers: `rtrb` SPSC. Use `bulk_pop` / `bulk_push`, never per-sample loops.

Resampling: `rubato` `SincFixedIn`. Dev builds require:
Resampling uses two explicit policies:

- Fixed stream rates, such as pipeline → speaker, use `FftFixedIn`. It is the
high-throughput synchronous converter and can skip trailing physical channels
that have never carried a route.
- A rate that follows an independent or drifting clock uses `SincFixedIn` or
`SincFixedOut`, including ratio adjustment where the receiver owns pacing.

Dev builds require:

[profile.dev.package.rubato] opt-level = 3
[profile.dev.package.realfft] opt-level = 3
Expand All @@ -44,6 +52,9 @@ Without these, one chunk takes ~16 ms and the worker stalls.
the wall clock, not the source — a file source decodes faster than real time
and would otherwise over-run the encoder.
- Stall: per-source `last_pop_at`; >150 ms silence → zero-fill and proceed.
- RT promotion happens after speaker startup prefill. Every overdue Linux tick
performs a real blocking sleep before more DSP work; an unbounded catch-up
loop is forbidden even when the ring can absorb it.

## Effects

Expand Down Expand Up @@ -99,3 +110,53 @@ in one usually needs the other two.

A backend that cannot support the feature returns an error; it does not
substitute a different rate, device, or format.

### Linux: PipeWire and RTKit

- PipeWire process callbacks and promoted DSP workers are RT code. They may
touch only preallocated buffers, SPSC rings and relaxed atomics.
- `audio_thread_priority` obtains real-time scheduling through RTKit. Its frame
argument is the maximum uninterrupted render quantum, not a latency target.
Pass the actual known block size; pass `0` when PipeWire owns an unknown
callback quantum.
- Linux `RLIMIT_RTTIME` measures CPU time spent under real-time scheduling
without a blocking syscall. Crossing the soft limit sends `SIGXCPU`; crossing
the hard limit sends `SIGKILL`. Preemption and `sched_yield` do not reset it.
Startup prefill runs before promotion, and deadline catch-up must block
between blocks. Never raise or disable the OS limit to hide an overload.
- A `SIGXCPU` followed by `SIGKILL` from the audio thread with `si_code=SI_KERNEL`
is an RT-budget failure. A Rust panic hook and in-process crash modal cannot
observe `SIGKILL`; preserve the previous-run unexpected-exit report.

### macOS: CoreAudio

- CoreAudio owns the device callback cadence. Callback code follows the common
RT rules and must return within the negotiated buffer duration.
- Time-constraint scheduling and Audio Workgroups express period, computation
and deadline to Darwin. A deadline miss normally appears as an overload or
audio dropout; Linux `RLIMIT_RTTIME` semantics do not apply.
- Device sample-rate or channel-layout changes require rebuilding the stream.
Do not retain callbacks, HAL objects or plugin UI objects past their documented
owner lifetime.

### Windows: WASAPI

- WASAPI owns the render callback cadence. Use the negotiated mix/device format
exactly and rebuild after endpoint invalidation or format change.
- Time-critical audio work belongs to MMCSS/Pro Audio scheduling. Do not use
generic process or thread priority boosts as a substitute, and never block,
allocate or perform COM/UI work in the render callback.
- COM initialization and endpoint management remain on control threads. The
callback exchanges audio and status only through preallocated buffers and
lock-free state.

### Cross-platform output contract

- The physical stream always receives its native channel width. Sample-rate
conversion may stop at the highest routed channel; remaining channels are
explicitly zero-filled before the device ring.
- Callback scratch storage is allocated before playback. Oversized callbacks
are processed in channel-aligned chunks and never grow a `Vec` on the audio
thread.
- Fixed-rate conversion, drift correction and channel mapping are separate
decisions. Do not choose a resampler merely because two nominal rates differ.
18 changes: 11 additions & 7 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }

# rubato's sinc resampler is unusable in dev without optimisation — a single
# 1024-frame block takes ~16 ms vs ~500 µs at -O3, so the DSP worker can't
# keep real time and input rings overflow. Optimise just the resampler stack;
# rubato's resamplers are unusable in dev without optimisation — a single
# block can miss its deadline at -O0, so the DSP worker cannot keep real time
# and input rings overflow. Optimise just the resampler stack;
# leaving the rest of the workspace (tauri, web stack, our crate) at -O0
# keeps clean builds fast.
[profile.dev.package.rubato]
Expand Down Expand Up @@ -106,23 +106,24 @@ raw-window-handle = "0.6"
vst3 = "0.3"

[target.'cfg(target_os = "linux")'.dependencies]
pipewire = { version = "0.9", features = ["v0_3_44"] }
pipewire = { version = "0.9", features = ["v0_3_49"] }
# Native volume read/set and event-driven change notifications. Talks to
# pipewire-pulse; high-level, so no subprocess or polling like wpctl/pactl.
libpulse-binding = "2"
freedesktop-desktop-entry = "0.8"
freedesktop-icons = "0.4"
resvg = "0.47"
dirs = "5"
# VST3: modules are loaded with dlopen, and X11 editors hand the host file
# descriptors to poll from its run loop.
libc = "0.2"
# Bundled roots for the updater's reqwest client (see `check_for_updates`):
# sandboxed AppImages/Flatpak can't see the host trust store, so the update
# check falls back to webpki-roots. macOS/Windows keep the platform verifier.
webpki-roots = "0.26"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }

[target.'cfg(unix)'.dependencies]
# Fatal-signal crash persistence; Linux VST3 hosting also uses dlopen and poll.
libc = "0.2"

[target.'cfg(target_os = "windows")'.dependencies]
windows-core = "0.61"
png = "0.17"
Expand All @@ -138,6 +139,9 @@ windows = { version = "0.61", features = [
"Win32_Media_Audio_Endpoints",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Diagnostics_Debug",
"Win32_System_IO",
"Win32_System_Kernel",
"Win32_System_JobObjects",
"Win32_System_Registry",
"Win32_System_Variant",
Expand Down
124 changes: 112 additions & 12 deletions src-tauri/src/audio/capture/linux.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;

use pipewire as pw;
use pw::spa;
use pw::spa::param::audio::{AudioFormat, AudioInfoRaw};
use pw::spa::pod::Pod;
use pw::spa::pod::{ChoiceValue, Pod, Value};
use pw::spa::utils::{Choice, ChoiceEnum, ChoiceFlags};

use crate::audio::pipeline::RtThread;
use crate::error::{AppError, AppResult};
Expand All @@ -13,11 +16,28 @@ struct Terminate;

struct UserData {
callback: Box<dyn FnMut(&[f32])>,
format: AudioInfoRaw,
sample_rate: Arc<AtomicU32>,
}

pub struct Capture {
sender: pw::channel::Sender<Terminate>,
thread: Option<std::thread::JoinHandle<()>>,
sample_rate: Arc<AtomicU32>,
}

#[derive(Clone)]
pub struct RateProbe {
sample_rate: Arc<AtomicU32>,
}

impl RateProbe {
pub fn sample_rate(&self) -> Option<u32> {
match self.sample_rate.load(Ordering::Relaxed) {
0 => None,
rate => Some(rate),
}
}
}

impl Drop for Capture {
Expand All @@ -30,62 +50,112 @@ impl Drop for Capture {
}

impl Capture {
pub fn rate_probe(&self) -> RateProbe {
RateProbe {
sample_rate: self.sample_rate.clone(),
}
}

// Monitor of the default sink (whole-system audio).
pub fn start_system(callback: impl FnMut(&[f32]) + Send + 'static) -> AppResult<Self> {
spawn(None, true, Box::new(callback))
pub fn start_system(
sample_rate: u32,
channels: u32,
callback: impl FnMut(&[f32]) + Send + 'static,
) -> AppResult<Self> {
spawn(None, true, sample_rate, channels, Box::new(callback))
}

// Tap a specific app's output stream, found by binary/name.
pub fn start_app(
binary: &str,
sample_rate: u32,
channels: u32,
callback: impl FnMut(&[f32]) + Send + 'static,
) -> AppResult<Self> {
let serial = resolve_serial(binary)?
.ok_or_else(|| AppError::Stream(format!("no audio stream found for {binary:?}")))?;
spawn(Some(serial.to_string()), false, Box::new(callback))
spawn(
Some(serial.to_string()),
false,
sample_rate,
channels,
Box::new(callback),
)
}

// 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 negotiated_rate = Arc::new(AtomicU32::new(sample_rate));
let thread_rate = negotiated_rate.clone();
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", 0, sample_rate);
if let Err(e) = run(
receiver,
target,
capture_sink,
sample_rate,
channels,
callback,
thread_rate,
) {
tracing::error!("pipewire capture: {e:?}");
}
});
Ok(Capture {
sender,
thread: Some(thread),
sample_rate: negotiated_rate,
})
}

fn run(
receiver: pw::channel::Receiver<Terminate>,
target: Option<String>,
capture_sink: bool,
sample_rate: u32,
channels: u32,
callback: Box<dyn FnMut(&[f32]) + Send>,
negotiated_rate: Arc<AtomicU32>,
) -> Result<(), pw::Error> {
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
let context = pw::context::ContextRc::new(&mainloop, None)?;
Expand All @@ -109,10 +179,26 @@ fn run(
}

let stream = pw::stream::StreamRc::new(core.clone(), "splitwave-capture", props)?;
let user_data = UserData { callback };
let user_data = UserData {
callback,
format: AudioInfoRaw::new(),
sample_rate: negotiated_rate,
};

let _listener = stream
.add_local_listener_with_user_data(user_data)
.param_changed(|_, user_data, id, param| {
if id != spa::param::ParamType::Format.as_raw() {
return;
}
let Some(param) = param else { return };
if user_data.format.parse(param).is_ok() {
let rate = user_data.format.rate();
if rate > 0 {
user_data.sample_rate.store(rate, Ordering::Relaxed);
}
}
})
.process(|stream, user_data| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
Expand All @@ -138,13 +224,27 @@ 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 mut properties: Vec<spa::pod::Property> = audio_info.into();
if let Some(rate) = properties
.iter_mut()
.find(|property| property.key == spa::param::format::FormatProperties::AudioRate.as_raw())
{
rate.value = Value::Choice(ChoiceValue::Int(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Range {
default: sample_rate as i32,
min: 1,
max: i32::MAX,
},
)));
}
let obj = spa::pod::Object {
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
properties,
};
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/audio/capture/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub(crate) mod macos_tap;
pub use macos_backend::{capture_rate, uses_taps, Capture};

#[cfg(target_os = "linux")]
mod linux;
pub(crate) mod linux;
#[cfg(target_os = "linux")]
pub use linux::Capture;

Expand Down
Loading
Loading