diff --git a/README.md b/README.md index 6eedb1e1..a9adc309 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 8cc72237..17f2d203 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -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 @@ -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 @@ -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. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1e52c8ba..46e124a4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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] @@ -106,7 +106,7 @@ 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" @@ -114,15 +114,16 @@ 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" @@ -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", diff --git a/src-tauri/src/audio/capture/linux.rs b/src-tauri/src/audio/capture/linux.rs index 76a5feff..f309b546 100644 --- a/src-tauri/src/audio/capture/linux.rs +++ b/src-tauri/src/audio/capture/linux.rs @@ -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}; @@ -13,11 +16,28 @@ struct Terminate; struct UserData { callback: Box, + format: AudioInfoRaw, + sample_rate: Arc, } pub struct Capture { sender: pw::channel::Sender, thread: Option>, + sample_rate: Arc, +} + +#[derive(Clone)] +pub struct RateProbe { + sample_rate: Arc, +} + +impl RateProbe { + pub fn sample_rate(&self) -> Option { + match self.sample_rate.load(Ordering::Relaxed) { + 0 => None, + rate => Some(rate), + } + } } impl Drop for Capture { @@ -30,54 +50,101 @@ 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 { - spawn(None, true, Box::new(callback)) + pub fn start_system( + sample_rate: u32, + channels: u32, + callback: impl FnMut(&[f32]) + Send + 'static, + ) -> AppResult { + 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 { 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 { - 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 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, }) } @@ -85,7 +152,10 @@ fn run( receiver: pw::channel::Receiver, target: Option, capture_sink: bool, + sample_rate: u32, + channels: u32, callback: Box, + negotiated_rate: Arc, ) -> Result<(), pw::Error> { let mainloop = pw::main_loop::MainLoopRc::new(None)?; let context = pw::context::ContextRc::new(&mainloop, None)?; @@ -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; @@ -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 = 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 = spa::pod::serialize::PodSerializer::serialize( std::io::Cursor::new(Vec::new()), diff --git a/src-tauri/src/audio/capture/mod.rs b/src-tauri/src/audio/capture/mod.rs index 78a45619..2fca9006 100644 --- a/src-tauri/src/audio/capture/mod.rs +++ b/src-tauri/src/audio/capture/mod.rs @@ -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; diff --git a/src-tauri/src/audio/clock.rs b/src-tauri/src/audio/clock.rs index 9fac344a..231b85f9 100644 --- a/src-tauri/src/audio/clock.rs +++ b/src-tauri/src/audio/clock.rs @@ -8,6 +8,8 @@ use std::time::{Duration, Instant}; use crate::audio::health; const LATE_REPORT_THRESHOLD: Duration = Duration::from_millis(2); +#[cfg(target_os = "linux")] +const RT_BUDGET_RESET_SLEEP: Duration = Duration::from_micros(50); pub trait ClockSource: Send + 'static { /// Returns `false` when `stop` is set; `true` on each tick. @@ -16,6 +18,10 @@ pub trait ClockSource: Send + 'static { /// Nominal sample rate this clock targets. #[allow(dead_code)] fn sample_rate(&self) -> u32; + + fn realtime_ready(&self) -> bool { + true + } } /// On overrun, the next deadline resets to "now" rather than bursting through @@ -31,6 +37,7 @@ pub struct SystemClockTicker { period: Duration, next_deadline: Option, catchup_max: Duration, + report_late: bool, } impl SystemClockTicker { @@ -42,6 +49,7 @@ impl SystemClockTicker { period, next_deadline: None, catchup_max: Duration::ZERO, + report_late: true, } } @@ -52,6 +60,12 @@ impl SystemClockTicker { t.catchup_max = t.period * max_blocks; t } + + fn rate_limiter(sample_rate: u32, block_frames: usize) -> Self { + let mut ticker = Self::new(sample_rate, block_frames); + ticker.report_late = false; + ticker + } } impl ClockSource for SystemClockTicker { @@ -69,13 +83,17 @@ impl ClockSource for SystemClockTicker { let late = now - d; // Sub-threshold lateness is scheduler jitter the next deadline // absorbs; only a real block-scale miss is worth reporting. - if late >= LATE_REPORT_THRESHOLD { + if self.report_late && late >= LATE_REPORT_THRESHOLD { health::bump(&health::CLOCK_LATE_BLOCKS, 1); health::raise_max(&health::CLOCK_LATE_MAX_US, late.as_micros() as u64); } if late <= self.catchup_max { + #[cfg(target_os = "linux")] + thread::sleep(RT_BUDGET_RESET_SLEEP); d } else { + #[cfg(target_os = "linux")] + thread::sleep(RT_BUDGET_RESET_SLEEP); now } } @@ -107,9 +125,13 @@ pub struct DeviceFillClock { /// (see `speaker_ring`). Read here every tick so the ring always bridges /// one full callback whatever buffer the device negotiated. target: Arc, - /// The ring has reached its target at least once. Until then the empty - /// ring is the startup prefill, not a worker that fell behind. + /// The startup fill budget has been produced. Until then an empty ring is + /// startup prefill, not a worker that fell behind. primed: bool, + startup_frames: usize, + /// Prevents a sink that drains immediately (for example a PipeWire null + /// sink) from turning the real-time worker into an unbounded busy loop. + wall_clock: SystemClockTicker, } impl DeviceFillClock { @@ -127,6 +149,8 @@ impl DeviceFillClock { level, target, primed: false, + startup_frames: 0, + wall_clock: SystemClockTicker::rate_limiter(pipeline_sample_rate, engine_block_frames), } } } @@ -143,15 +167,14 @@ impl ClockSource for DeviceFillClock { let pipe_sr = self.pipeline_sample_rate.max(1) as u64; let block_frames = ((self.engine_block_frames as u64 * dev_sr as u64 + pipe_sr / 2) / pipe_sr) as usize; - if queued + block_frames <= target_frames { - // Less than one block of headroom left in the ring -- the - // worker isn't staying ahead of the device. - if self.primed && queued < block_frames { - health::bump(&health::CLOCK_LATE_BLOCKS, 1); - } + if !self.primed { + self.startup_frames = self.startup_frames.saturating_add(block_frames); + self.primed = self.startup_frames >= target_frames; return true; } - self.primed = true; + if queued + block_frames <= target_frames { + return self.wall_clock.wait_for_tick(stop); + } let overshoot = queued + block_frames - target_frames; let drain = Duration::from_nanos((overshoot as u64 * 1_000_000_000) / dev_sr as u64); thread::sleep(drain.min(FILL_CLOCK_MAX_SLEEP)); @@ -161,4 +184,8 @@ impl ClockSource for DeviceFillClock { fn sample_rate(&self) -> u32 { self.device_sample_rate.load(Ordering::Relaxed) } + + fn realtime_ready(&self) -> bool { + self.primed + } } diff --git a/src-tauri/src/audio/device/linux.rs b/src-tauri/src/audio/device/linux.rs index 1d1fcc48..c9921ca0 100644 --- a/src-tauri/src/audio/device/linux.rs +++ b/src-tauri/src/audio/device/linux.rs @@ -1,14 +1,43 @@ use crate::audio::pw_enum::nodes_by_class; -use crate::error::AppResult; +use crate::error::{AppError, 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 config_name = name.strip_prefix("monitor:").unwrap_or(name); + if let Some(config) = crate::audio::virtual_device::config_for_node(config_name) { + return Ok(NativeDeviceInfo { + sample_rate: config.sample_rate, + channels: u16::try_from(config.channels) + .map_err(|_| AppError::Device(format!("invalid channel count for {name}")))?, + sample_format: "f32", + }); + } + + let (class, node_name) = match kind { + DeviceKind::Input => name + .strip_prefix("monitor:") + .map_or(("Audio/Source", name), |sink| ("Audio/Sink", sink)), + DeviceKind::Output => ("Audio/Sink", name), + }; + let node = nodes_by_class(class)? + .into_iter() + .find(|node| node.name == node_name) + .ok_or_else(|| AppError::Device(format!("PipeWire node {name:?} not found")))?; + let sample_rate = node.sample_rate.ok_or_else(|| { + AppError::Device(format!( + "PipeWire node {name:?} does not report a sample rate" + )) + })?; + let channels = node.channels.ok_or_else(|| { + AppError::Device(format!( + "PipeWire node {name:?} does not report its channel count" + )) + })?; Ok(NativeDeviceInfo { - sample_rate: 48_000, - channels: 2, + sample_rate, + channels: u16::try_from(channels) + .map_err(|_| AppError::Device(format!("invalid channel count for {name}")))?, sample_format: "f32", }) } @@ -24,9 +53,14 @@ pub fn list_inputs() -> AppResult> { .collect(); // Every sink exposes a monitor we can record; offer them as inputs too. for sink in nodes_by_class("Audio/Sink")? { + let owned = crate::audio::virtual_device::config_for_node(&sink.name).is_some(); out.push(DeviceInfo { id: format!("monitor:{}", sink.name), - name: format!("{} (Monitor)", sink.description), + name: if owned { + sink.description + } else { + format!("{} (Monitor)", sink.description) + }, kind: DeviceKind::Input, }); } diff --git a/src-tauri/src/audio/health.rs b/src-tauri/src/audio/health.rs index aaa8657c..c0cf0e37 100644 --- a/src-tauri/src/audio/health.rs +++ b/src-tauri/src/audio/health.rs @@ -36,9 +36,7 @@ counters! { SOURCE_TRIM_DROPPED_SAMPLES, /// Samples dropped by a StagingRing overrun (producer outran the drain). STAGING_OVERRUN_SAMPLES, - /// Worker blocks produced with no clock slack left: a wall-clock deadline - /// already passed on wake, or (device-paced speaker workers) the ring had - /// less than one block of headroom. + /// Worker blocks whose wall-clock deadline had already passed on wake. CLOCK_LATE_BLOCKS, /// Worst single deadline miss, microseconds (monotonic high-water mark). CLOCK_LATE_MAX_US, diff --git a/src-tauri/src/audio/permission.rs b/src-tauri/src/audio/permission.rs index ebe75538..52bc3cf7 100644 --- a/src-tauri/src/audio/permission.rs +++ b/src-tauri/src/audio/permission.rs @@ -12,11 +12,13 @@ pub enum PermissionState { /// use System Audio Recording; ScreenCaptureKit uses Screen Recording. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "lowercase")] -#[cfg_attr(target_os = "macos", allow(dead_code))] pub enum PermissionKind { + #[cfg(target_os = "macos")] SystemAudio, + #[cfg(target_os = "macos")] ScreenRecording, /// Linux and Windows capture needs no separate grant. + #[cfg(any(target_os = "linux", target_os = "windows"))] None, } diff --git a/src-tauri/src/audio/pipeline/cue.rs b/src-tauri/src/audio/pipeline/cue.rs index dc33be64..81a53a73 100644 --- a/src-tauri/src/audio/pipeline/cue.rs +++ b/src-tauri/src/audio/pipeline/cue.rs @@ -64,11 +64,16 @@ pub fn play(device_id: &str, muted: bool, gain: f32, beep: bool) -> AppResult<() #[cfg(target_os = "linux")] let _playback = { let mut render = render; - crate::audio::playback::Playback::start(&spec.node_id, move |out| { - let frames = out.len() / channels; - render(out, frames); - out.len() - })? + crate::audio::playback::Playback::start( + &spec.node_id, + spec.sample_rate, + channels, + move |out| { + let frames = out.len() / channels; + render(out, frames); + out.len() + }, + )? }; thread::sleep(duration + DRAIN); diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs index 6cbb7f62..5bbb0687 100644 --- a/src-tauri/src/audio/pipeline/dag.rs +++ b/src-tauri/src/audio/pipeline/dag.rs @@ -19,11 +19,10 @@ use crate::audio::stream_recv::ChannelReceiver; use crate::audio::streams::bulk_push_counted; use crate::error::{AppError, AppResult}; -/// Ring buffer length in frames per source; multiplied by the source's channel -/// count at build time. ~500 ms at 96 kHz so the worker rides out longer source -/// pauses (SCK silent gaps, scheduler hiccups, capture-clock drift) without -/// overflowing the FAST source's ring while waiting on a SLOW one. -pub(super) const RING_CAPACITY_FRAMES: usize = 48_000; +/// One second of frames at the ring's own clock rate. +pub(super) fn ring_capacity_frames(sample_rate: u32) -> usize { + sample_rate.max(1) as usize +} /// Block size used by the resampler. 256 frames @ 48 kHz ~ 5.3 ms. pub(super) const RESAMPLE_CHUNK: usize = 256; @@ -852,6 +851,23 @@ impl OutputGraph { self.out_channels = channels; } + pub(super) fn active_output_channels(&self) -> usize { + self.terminals + .iter() + .map(|terminal| match terminal.route { + Some((offset, width)) => offset + width, + None => { + self.nodes[terminal.src_idx] + .out_buf_for_handle(terminal.source_handle.as_deref()) + .len() + / DSP_BLOCK_FRAMES + } + }) + .max() + .unwrap_or(1) + .clamp(1, self.out_channels) + } + /// Attach a publish ring to a fan-out effect node; its `out_buf` is pushed /// there each block for another output's ring-source to read. pub(super) fn attach_tap(&mut self, node_idx: usize, prod: Producer) { @@ -1180,7 +1196,7 @@ pub(super) fn build_output_graph( // Scale by channels to keep the buffered span constant in time; at // high channel counts a smaller cushion starves on capture-clock drift. let (producer, consumer) = - RingBuffer::::new(RING_CAPACITY_FRAMES * source_channels); + RingBuffer::::new(ring_capacity_frames(input_sr) * source_channels); producer_pairs.push((id.clone(), producer)); let mut ch_handles: Vec = valid .edges diff --git a/src-tauri/src/audio/pipeline/file_reader.rs b/src-tauri/src/audio/pipeline/file_reader.rs index 38b68a86..1ab977c1 100644 --- a/src-tauri/src/audio/pipeline/file_reader.rs +++ b/src-tauri/src/audio/pipeline/file_reader.rs @@ -410,8 +410,8 @@ fn run( let mut last_paused_progress = Instant::now(); // Playback rate follows the consumers draining these rings: decode until - // `pace_queue` samples are buffered, then idle. The ring (dag.rs - // RING_CAPACITY_FRAMES) can hold a whole short file, so backpressure alone + // `pace_queue` samples are buffered, then idle. The downstream ring can + // hold a whole short file, so backpressure alone // doesn't pace anything; a wall-clock schedule instead drifts against the // audio clock (steady underruns) and turns any stall -- a graph swap // re-routing our bridges -- into an unpaced catch-up burst. diff --git a/src-tauri/src/audio/pipeline/input/linux.rs b/src-tauri/src/audio/pipeline/input/linux.rs index 9dc9a737..da9654a5 100644 --- a/src-tauri/src/audio/pipeline/input/linux.rs +++ b/src-tauri/src/audio/pipeline/input/linux.rs @@ -8,22 +8,32 @@ use crate::audio::graph::{InputSpec, ValidInput}; use crate::audio::input_bridge::BroadcastRx; use crate::error::AppResult; -use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput, SCK_SR}; +use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; -pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult { +pub(in crate::audio::pipeline) fn resolve_input( + inp: &ValidInput, + target_sample_rate: u32, +) -> 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: u32::from(info.channels), + }) + } InputSpec::SystemAudio { exclude_current_app, } => Ok(ResolvedInput::SystemAudio { - sample_rate: SCK_SR, + sample_rate: target_sample_rate, exclude_current_app: *exclude_current_app, }), InputSpec::AppAudio { bundle_id } => Ok(ResolvedInput::AppAudio { - sample_rate: SCK_SR, + sample_rate: target_sample_rate, bundle_id: bundle_id.clone(), }), InputSpec::AudioFile { file_path } => resolve_audio_file(file_path), @@ -42,41 +52,54 @@ 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 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, channels as usize); } 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)? + 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)? + crate::audio::capture::Capture::start_source(&node_id, sample_rate, channels, cb)? }; Ok(InputHandle::Capture(capture)) } - ResolvedInput::SystemAudio { .. } => { + ResolvedInput::SystemAudio { sample_rate, .. } => { info!("starting system-audio capture (PipeWire sink monitor)"); let mut bridge = bridge; - let capture = crate::audio::capture::Capture::start_system(move |samples| { - bridge.apply_commands(); - bridge.broadcast(samples); - })?; + let capture = + crate::audio::capture::Capture::start_system(sample_rate, 2, move |samples| { + bridge.apply_commands(); + bridge.broadcast(samples); + })?; Ok(InputHandle::Capture(capture)) } - ResolvedInput::AppAudio { bundle_id, .. } => { + ResolvedInput::AppAudio { + sample_rate, + bundle_id, + } => { info!(%bundle_id, "starting app-audio capture (PipeWire tap)"); let mut bridge = bridge; - let capture = crate::audio::capture::Capture::start_app(&bundle_id, move |samples| { - bridge.apply_commands(); - bridge.broadcast(samples); - })?; + let capture = crate::audio::capture::Capture::start_app( + &bundle_id, + sample_rate, + 2, + move |samples| { + bridge.apply_commands(); + bridge.broadcast(samples); + }, + )?; Ok(InputHandle::Capture(capture)) } ResolvedInput::AudioFile { path, .. } => { diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index 4907109c..97c19579 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -16,7 +16,7 @@ use crate::audio::input_bridge::{broadcast_channel, BroadcastRx}; use crate::audio::resample::MultiResampler; use crate::error::{AppError, AppResult}; -use super::dag::{RESAMPLE_CHUNK, RING_CAPACITY_FRAMES}; +use super::dag::{ring_capacity_frames, RESAMPLE_CHUNK}; use super::file_reader::{probe_audio_file, start_audio_file_reader, AudioFileReader}; #[cfg(target_os = "macos")] @@ -35,11 +35,6 @@ use windows as platform; pub(super) use platform::resolve_input; use platform::start_input_stream as start_native_input_stream; -/// ScreenCaptureKit (macOS) and PipeWire (Linux) both deliver 48 kHz, matching -/// the device side so no resampling happens on capture delivery. -#[cfg_attr(target_os = "macos", allow(dead_code))] -pub(super) const SCK_SR: u32 = 48_000; - /// RAII handle held only for its `Drop` -- stops the cpal stream, tears /// down the capture, or signals + joins the file reader thread. #[allow(dead_code)] @@ -76,10 +71,19 @@ impl InputHandle { } #[cfg(target_os = "macos")] - fn tap_rate_probe(&self) -> Option { + fn rate_probe(&self) -> Option { match self { InputHandle::Capture(capture) => capture.tap_rate_probe(), - InputHandle::Normalized(n) => n._input.tap_rate_probe(), + InputHandle::Normalized(n) => n._input.rate_probe(), + _ => None, + } + } + + #[cfg(target_os = "linux")] + fn rate_probe(&self) -> Option { + match self { + InputHandle::Capture(capture) => Some(capture.rate_probe()), + InputHandle::Normalized(n) => n._input.rate_probe(), _ => None, } } @@ -115,6 +119,7 @@ pub(super) enum ResolvedInput { PwSource { node_id: String, sample_rate: u32, + channels: u32, }, SystemAudio { sample_rate: u32, @@ -152,6 +157,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, } @@ -213,12 +220,12 @@ pub(super) fn start_input_stream( let sample_rate = resolved.sample_rate(); let channels = resolved.native_channels() as usize; let (raw_producer, mut raw_consumer) = - RingBuffer::::new(RING_CAPACITY_FRAMES * channels.max(1)); + RingBuffer::::new(ring_capacity_frames(sample_rate) * channels.max(1)); let (mut raw_tx, raw_rx) = broadcast_channel(); raw_tx.add(raw_producer)?; let input = start_native_input_stream(node_id, resolved, raw_rx, paused, None, app)?; - #[cfg(target_os = "macos")] - let rate_probe = input.tap_rate_probe(); + #[cfg(any(target_os = "macos", target_os = "linux"))] + let rate_probe = input.rate_probe(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); let label = node_id.to_string(); @@ -227,7 +234,10 @@ pub(super) fn start_input_stream( .spawn(move || { let mut bridge = bridge; let mut input_buf = vec![0.0; RESAMPLE_CHUNK * channels]; + #[cfg(any(target_os = "macos", target_os = "linux"))] let mut native_rate = sample_rate; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let native_rate = sample_rate; let mut resampler = if native_rate == target_sample_rate { None } else { @@ -245,8 +255,8 @@ pub(super) fn start_input_stream( ); while !stop_thread.load(std::sync::atomic::Ordering::Relaxed) { bridge.apply_commands(); - #[cfg(target_os = "macos")] - if let Some(rate) = rate_probe.and_then(|probe| probe.sample_rate()) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + if let Some(rate) = rate_probe.as_ref().and_then(|probe| probe.sample_rate()) { if rate == native_rate { // Nothing to do; avoid perturbing the sinc state. } else { diff --git a/src-tauri/src/audio/pipeline/input/windows.rs b/src-tauri/src/audio/pipeline/input/windows.rs index eb2430c2..8035b4b5 100644 --- a/src-tauri/src/audio/pipeline/input/windows.rs +++ b/src-tauri/src/audio/pipeline/input/windows.rs @@ -13,7 +13,9 @@ use crate::audio::streams; use crate::error::AppResult; use super::super::native::native_config; -use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput, SCK_SR}; +use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; + +const LOOPBACK_FALLBACK_RATE: u32 = 48_000; const LOOPBACK_CHANNELS: usize = 2; @@ -33,11 +35,12 @@ pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult Ok(ResolvedInput::SystemAudio { - sample_rate: crate::audio::capture::loopback_mix_rate().unwrap_or(SCK_SR), + sample_rate: crate::audio::capture::loopback_mix_rate() + .unwrap_or(LOOPBACK_FALLBACK_RATE), exclude_current_app: *exclude_current_app, }), InputSpec::AppAudio { bundle_id } => Ok(ResolvedInput::AppAudio { - sample_rate: SCK_SR, + sample_rate: LOOPBACK_FALLBACK_RATE, bundle_id: bundle_id.clone(), }), InputSpec::AudioFile { file_path } => resolve_audio_file(file_path), diff --git a/src-tauri/src/audio/pipeline/meter.rs b/src-tauri/src/audio/pipeline/meter.rs index 7e814703..dc4ba185 100644 --- a/src-tauri/src/audio/pipeline/meter.rs +++ b/src-tauri/src/audio/pipeline/meter.rs @@ -56,6 +56,7 @@ impl Drop for XrunTickThread { pub(super) fn spawn_xrun_thread( sources: Vec, outputs: Vec, + expected_speaker_streams: i64, ) -> XrunTickThread { let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); @@ -290,11 +291,10 @@ pub(super) fn spawn_xrun_thread( // draining a ring nobody fills, which shows up in the global // underrun total but in no output's own counters. let live_streams = LIVE_SPEAKER_STREAMS.load(Ordering::Relaxed); - let speaker_outputs = outputs.iter().filter(|o| o.io.is_some()).count() as i64; - if live_streams != speaker_outputs { + if live_streams != expected_speaker_streams { warn!( live_speaker_streams = live_streams, - speaker_outputs, "orphan speaker streams" + expected_speaker_streams, "orphan speaker streams" ); } diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs index 31d550db..ba15eb97 100644 --- a/src-tauri/src/audio/pipeline/mod.rs +++ b/src-tauri/src/audio/pipeline/mod.rs @@ -39,11 +39,11 @@ mod meter; mod native; mod output; #[cfg(target_os = "linux")] -pub(crate) use output::RtThread; +pub(crate) use worker::RtThread; mod sig; mod worker; -use dag::{build_output_graph, OutputGraph, OutputMeta, SourceMeta, RING_CAPACITY_FRAMES}; +use dag::{build_output_graph, ring_capacity_frames, OutputGraph, OutputMeta, SourceMeta}; use input::{resolve_input, start_input_stream, InputHandle, ResolvedInput}; use meter::{spawn_meter_thread, spawn_xrun_thread, MeterTickThread, XrunTickThread}; use output::{ @@ -638,6 +638,9 @@ impl ActivePipeline { input_native_sr.insert(inp.id.clone(), state.sample_rate); input_native_channels.insert(inp.id.clone(), state.channels); } else { + #[cfg(target_os = "linux")] + let resolved = resolve_input(inp, pipeline_sr)?; + #[cfg(not(target_os = "linux"))] let resolved = resolve_input(inp)?; let sr = match &resolved { ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate, @@ -843,7 +846,7 @@ impl ActivePipeline { }; for o2 in cons { let (prod, consumer) = - rtrb::RingBuffer::::new(RING_CAPACITY_FRAMES * width); + rtrb::RingBuffer::::new(ring_capacity_frames(output_sr) * width); built.graph.attach_tap(idx, prod); pending_cuts .entry(o2.clone()) @@ -1287,6 +1290,7 @@ impl ActivePipeline { Some(spawn_xrun_thread( self.source_stats.clone(), self.output_stats.clone(), + self.speakers.len() as i64, )) }; diff --git a/src-tauri/src/audio/pipeline/output/linux.rs b/src-tauri/src/audio/pipeline/output/linux.rs index 8d0b1824..29c8e3fb 100644 --- a/src-tauri/src/audio/pipeline/output/linux.rs +++ b/src-tauri/src/audio/pipeline/output/linux.rs @@ -13,7 +13,6 @@ use super::{spawn_speaker_worker, speaker_ring, SpeakerIo, SpeakerWorker, Stream pub(in crate::audio::pipeline) struct SpeakerResolved { pub node_id: String, pub sample_rate: u32, - // PipeWire null-sink playback is stereo. pub out_channels: usize, } @@ -24,10 +23,12 @@ 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: usize::from(info.channels), }) } @@ -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/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs index 432f8aaa..e8b57e10 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -4,23 +4,20 @@ use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; -use audio_thread_priority::{ - demote_current_thread_from_real_time, promote_current_thread_to_real_time, RtPriorityHandle, -}; use rtrb::{Producer, RingBuffer}; use serde_json::json; use tauri::{AppHandle, Emitter}; -use tracing::{info, warn}; +use tracing::warn; use crate::audio::clock::{ClockSource, DeviceFillClock, SystemClockTicker}; use crate::audio::effects::{update_meter, MeterHandle, WaveformHandle}; use crate::audio::encoders::{build_encoder, validate_append_target, AudioEncoder}; use crate::audio::graph::{NetCodec, OutputSpec, RecordingFormat, RecordingMode, ValidOutput}; -use crate::audio::resample::MultiResampler; +use crate::audio::resample::FixedRateResampler; use crate::audio::streams; use crate::error::{AppError, AppResult}; -use super::dag::{OutputGraph, DSP_BLOCK_FRAMES}; +use super::dag::{ring_capacity_frames, OutputGraph, DSP_BLOCK_FRAMES}; use super::worker::{dsp_worker, WorkerCtrl}; #[cfg(target_os = "macos")] @@ -41,13 +38,6 @@ pub(super) use platform::{resolve_speaker, start_speaker_stream, SpeakerHandle, // No live inputs -> fall back to 48 kHz for the recorder. const RECORDER_DEFAULT_SR: u32 = 48_000; -// Ring length in frames; multiplied by the device channel count at open. ~1 s -// @ 48 kHz so the adaptive fill target (which follows the device's own buffer -// size) has room to grow on setups that hand out large playback buffers -// (~250 ms on some Linux/PipeWire sessions) while a healthy device still only -// buffers a few blocks. -pub(super) const SPEAKER_RING_CAPACITY_FRAMES: usize = 48_000; - // Floor for the adaptive fill target: enough to absorb a DSP-side spike // without the device clock -- not the wall clock -- ever seeing an empty ring. // 3 blocks = 64 ms @ 48 kHz / DSP_BLOCK_FRAMES. @@ -289,8 +279,10 @@ pub(super) fn speaker_ring( Arc, SpeakerIo, ) { - let (producer, mut consumer) = - RingBuffer::::new(SPEAKER_RING_CAPACITY_FRAMES * out_channels); + // One second at the actual device rate, so high-rate and wide-channel + // streams have the same time capacity as 48 kHz stereo. + let capacity_frames = ring_capacity_frames(device_rate); + let (producer, mut consumer) = RingBuffer::::new(capacity_frames * out_channels); let level = Arc::new(AtomicI64::new(0)); let level_cb = level.clone(); let target = Arc::new(AtomicI64::new(pipeline_frames_to_device_frames( @@ -301,7 +293,7 @@ pub(super) fn speaker_ring( let target_cb = target.clone(); let io = SpeakerIo::new(device_rate, target.clone(), graph_latency_frames); let io_cb = io.clone(); - let fill = move |out: &mut [f32], _frames: usize| { + let fill = move |out: &mut [f32], callback_frames: usize| { let read = streams::bulk_pop(&mut consumer, out); level_cb.fetch_sub((read / out_channels) as i64, Ordering::Relaxed); // Size the fill target to the device's own callback buffer so the ring @@ -319,10 +311,12 @@ pub(super) fn speaker_ring( pipeline_rate, device_rate, ); - let dev_frames = out.len() / out_channels; - let max = SPEAKER_RING_CAPACITY_FRAMES - .saturating_sub(dev_frames + margin) - .max(min); + let dev_frames = if callback_frames == 0 { + out.len() / out_channels + } else { + callback_frames + }; + let max = capacity_frames.saturating_sub(dev_frames + margin).max(min); target_cb.store( (dev_frames + margin).clamp(min, max) as i64, Ordering::Relaxed, @@ -336,34 +330,6 @@ pub(super) fn speaker_ring( (producer, fill, level, target, io) } -// Held for the worker's lifetime: dropping the handle restores normal scheduling. -pub(crate) struct RtThread(Option); - -impl RtThread { - pub(crate) fn promote(worker: &'static str, sample_rate: u32) -> Self { - match promote_current_thread_to_real_time(DSP_BLOCK_FRAMES as u32, sample_rate) { - Ok(handle) => { - info!(worker, "worker thread promoted to real-time"); - Self(Some(handle)) - } - Err(e) => { - warn!(worker, error = %e, "real-time promotion failed, running at normal priority"); - Self(None) - } - } - } -} - -impl Drop for RtThread { - fn drop(&mut self) { - if let Some(handle) = self.0.take() { - if let Err(e) = demote_current_thread_from_real_time(handle) { - warn!(error = %e, "real-time demotion failed"); - } - } - } -} - // Shared by both platforms' `start_speaker_stream`: a device-fill-paced // worker that mixes the output sub-graph and bulk-pushes blocks into the // speaker ring. @@ -391,7 +357,7 @@ pub(super) fn spawn_speaker_worker( let mut resampler = if initial_device_rate == pipeline_rate { None } else { - Some(MultiResampler::new( + Some(FixedRateResampler::new( pipeline_rate, initial_device_rate, DSP_BLOCK_FRAMES, @@ -405,26 +371,36 @@ pub(super) fn spawn_speaker_worker( .map(|r| r.out_max() * channels) .unwrap_or(DSP_BLOCK_FRAMES * channels) ]; + let mut resampled_channels = 0; let join = thread::Builder::new() .name(format!("speaker:{initial_device_rate}")) .spawn(move || { - let _rt = RtThread::promote("speaker", initial_device_rate); - worker.run(stop_thread, clock, |block| { - update_meter(&meter, block, channels); - let device_block = if let Some(resampler) = &mut resampler { - let written = resampler.process_chunk_into(block, &mut resampled)?; - &resampled[..written] - } else { - block - }; - let written = streams::bulk_push_counted( - &mut producer, - device_block, - &crate::audio::health::SPEAKER_RING_OVERRUN_SAMPLES, - ); - level.fetch_add((written / channels) as i64, Ordering::Relaxed); - Ok(()) - }); + worker.run( + stop_thread, + clock, + Some(("speaker", pipeline_rate)), + |block, active_channels| { + update_meter(&meter, block, channels); + let device_block = if let Some(resampler) = &mut resampler { + resampled_channels = resampled_channels.max(active_channels); + let written = resampler.process_chunk_into( + block, + resampled_channels, + &mut resampled, + )?; + &resampled[..written] + } else { + block + }; + let written = streams::bulk_push_counted( + &mut producer, + device_block, + &crate::audio::health::SPEAKER_RING_OVERRUN_SAMPLES, + ); + level.fetch_add((written / channels) as i64, Ordering::Relaxed); + Ok(()) + }, + ); }) .map_err(|e| AppError::Stream(format!("spawn speaker worker: {e}")))?; Ok(( @@ -450,8 +426,12 @@ pub(super) fn start_monitor_worker(graph: OutputGraph) -> AppResult<(RecorderWor let join = thread::Builder::new() .name("monitor".into()) .spawn(move || { - let _rt = RtThread::promote("monitor", sample_rate); - worker.run(stop_thread, Box::new(ticker), |_block| Ok(())); + worker.run( + stop_thread, + Box::new(ticker), + Some(("monitor", sample_rate)), + |_block, _| Ok(()), + ); }) .map_err(|e| AppError::Stream(format!("spawn monitor worker: {e}")))?; Ok(( @@ -480,8 +460,12 @@ pub(super) fn start_wire_sender_worker( let join = thread::Builder::new() .name("netsender".into()) .spawn(move || { - let _rt = RtThread::promote("netsender", sample_rate); - worker.run(stop_thread, Box::new(ticker), |_block| Ok(())); + worker.run( + stop_thread, + Box::new(ticker), + Some(("netsender", sample_rate)), + |_block, _| Ok(()), + ); }) .map_err(|e| AppError::Stream(format!("spawn net sender worker: {e}")))?; Ok(( @@ -556,7 +540,7 @@ pub(super) fn start_recorder_worker( let mut frames_written: u64 = base_frames; let mut encoder = encoder; - worker.run(stop_thread, clock, |block| { + worker.run(stop_thread, clock, None, |block, _| { encoder.write_interleaved(block)?; frames_written += (block.len() / channels_usize) as u64; wave_thread.push_interleaved(block, block.len() / channels_usize, base_frames); diff --git a/src-tauri/src/audio/pipeline/worker.rs b/src-tauri/src/audio/pipeline/worker.rs index 2eeae089..e0b7d31e 100644 --- a/src-tauri/src/audio/pipeline/worker.rs +++ b/src-tauri/src/audio/pipeline/worker.rs @@ -4,7 +4,11 @@ use std::thread; use std::time::Duration; use rtrb::{Consumer, Producer, RingBuffer}; -use tracing::warn; +use tracing::{info, warn}; + +use audio_thread_priority::{ + demote_current_thread_from_real_time, promote_current_thread_to_real_time, RtPriorityHandle, +}; use crate::audio::clock::ClockSource; use crate::error::{AppError, AppResult}; @@ -15,6 +19,34 @@ use super::dag::{OutputGraph, DSP_BLOCK_FRAMES}; /// otherwise the first block is all zeros. pub(super) const DSP_PREROLL: Duration = Duration::from_millis(50); +pub(crate) struct RtThread(Option); + +impl RtThread { + pub(crate) fn promote(worker: &'static str, max_frames: u32, sample_rate: u32) -> Self { + info!( + worker, + max_frames, sample_rate, "promoting worker thread to real-time" + ); + match promote_current_thread_to_real_time(max_frames, sample_rate) { + Ok(handle) => Self(Some(handle)), + Err(e) => { + warn!(worker, error = %e, "real-time promotion failed, running at normal priority"); + Self(None) + } + } + } +} + +impl Drop for RtThread { + fn drop(&mut self) { + if let Some(handle) = self.0.take() { + if let Err(e) = demote_current_thread_from_real_time(handle) { + warn!(error = %e, "real-time demotion failed"); + } + } + } +} + pub(super) struct DspWorker { pub graph: OutputGraph, /// Hot-swap channel: main thread pushes a freshly-built `OutputGraph` @@ -82,22 +114,35 @@ impl DspWorker { mut self, stop: Arc, mut clock: Box, + realtime: Option<(&'static str, u32)>, mut sink: F, ) where - F: FnMut(&[f32]) -> AppResult<()>, + F: FnMut(&[f32], usize) -> AppResult<()>, { thread::sleep(DSP_PREROLL); let mut block = vec![0.0_f32; DSP_BLOCK_FRAMES * self.graph.out_channels()]; + let mut rt = None; loop { self.drain_swaps(); + let promote_after_wait = rt.is_none() && clock.realtime_ready(); let proceed = clock.wait_for_tick(&stop); if !proceed { break; } + if promote_after_wait { + if let Some((name, sample_rate)) = realtime { + rt = Some(RtThread::promote( + name, + DSP_BLOCK_FRAMES as u32, + sample_rate, + )); + } + } self.graph.process_block(&mut block); - if let Err(e) = sink(&block) { + let active_channels = self.graph.active_output_channels(); + if let Err(e) = sink(&block, active_channels) { warn!(error = %e, "DSP worker sink failed; stopping"); break; } diff --git a/src-tauri/src/audio/playback.rs b/src-tauri/src/audio/playback.rs index e16dd148..d48bc541 100644 --- a/src-tauri/src/audio/playback.rs +++ b/src-tauri/src/audio/playback.rs @@ -5,10 +5,7 @@ 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; @@ -33,12 +30,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 +51,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)?; @@ -69,42 +70,56 @@ fn run( *pw::keys::MEDIA_ROLE => "Music", }; props.insert(*pw::keys::TARGET_OBJECT, sink_node_name); + let latency_frames = (sample_rate / 100).max(1); + props.insert( + *pw::keys::NODE_LATENCY, + format!("{latency_frames}/{sample_rate}"), + ); let stream = pw::stream::StreamRc::new(core.clone(), "splitwave-playback", props)?; let user_data = UserData { fill }; + let stride = F32_SIZE * channels; let _listener = stream .add_local_listener_with_user_data(user_data) - .process(|stream, user_data| { - let Some(mut buffer) = stream.dequeue_buffer() else { + .process(move |stream_ref, user_data| { + let Some(mut buffer) = stream_ref.dequeue_buffer() else { return; }; + let requested_frames = buffer.requested() as usize; let datas = buffer.datas_mut(); if datas.is_empty() { return; } let data = &mut datas[0]; let Some(raw) = data.data() else { return }; - let capacity = raw.len() / F32_SIZE; - if capacity == 0 { + let capacity_samples = raw.len() / F32_SIZE; + let capacity_frames = capacity_samples / channels; + let frames = if requested_frames > 0 { + requested_frames.min(capacity_frames) + } else { + capacity_frames + }; + let sample_count = frames * channels; + if sample_count == 0 { return; } - let mut samples = vec![0.0f32; capacity]; - let written = (user_data.fill)(&mut 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()); - } + // F32LE is negotiated below and PipeWire aligns mapped buffers. + let samples = unsafe { + std::slice::from_raw_parts_mut(raw.as_mut_ptr() as *mut f32, sample_count) + }; + let written = (user_data.fill)(samples).min(sample_count); let chunk = data.chunk_mut(); *chunk.offset_mut() = 0; - *chunk.stride_mut() = STRIDE as i32; + *chunk.stride_mut() = stride as i32; *chunk.size_mut() = (written * 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,19 +159,24 @@ 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| { - c.fetch_add(1, Ordering::Relaxed); - t.fetch_add(buf.len(), Ordering::Relaxed); - for f in buf.chunks_mut(2) { - let s = (phase * 2.0 * std::f32::consts::PI * 440.0 / 48000.0).sin() * 0.2; - phase += 1.0; - if f.len() == 2 { - f[0] = s; - f[1] = s; + 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) { + let s = (phase * 2.0 * std::f32::consts::PI * 440.0 / 48000.0).sin() * 0.2; + phase += 1.0; + if f.len() == 2 { + f[0] = s; + f[1] = s; + } } - } - buf.len() - }) + buf.len() + }, + ) .expect("start playback"); std::thread::sleep(std::time::Duration::from_millis(1500)); drop(pb); diff --git a/src-tauri/src/audio/plugins/vst3_backend.rs b/src-tauri/src/audio/plugins/vst3_backend.rs index 0ba4b988..2623ecb2 100644 --- a/src-tauri/src/audio/plugins/vst3_backend.rs +++ b/src-tauri/src/audio/plugins/vst3_backend.rs @@ -563,8 +563,22 @@ mod tests { #[test] fn cid_round_trips() { let cid: TUID = [ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, -0x78, -0x67, -0x56, -0x45, -0x34, - -0x23, -0x12, -0x01, + 0x00_u8 as _, + 0x11_u8 as _, + 0x22_u8 as _, + 0x33_u8 as _, + 0x44_u8 as _, + 0x55_u8 as _, + 0x66_u8 as _, + 0x77_u8 as _, + 0x88_u8 as _, + 0x99_u8 as _, + 0xAA_u8 as _, + 0xBB_u8 as _, + 0xCC_u8 as _, + 0xDD_u8 as _, + 0xEE_u8 as _, + 0xFF_u8 as _, ]; let text = format_cid(&cid); assert_eq!(text, "00112233445566778899AABBCCDDEEFF"); diff --git a/src-tauri/src/audio/pw_enum.rs b/src-tauri/src/audio/pw_enum.rs index 534763a4..2d8ccb0c 100644 --- a/src-tauri/src/audio/pw_enum.rs +++ b/src-tauri/src/audio/pw_enum.rs @@ -2,6 +2,9 @@ use std::cell::RefCell; use std::rc::Rc; use pipewire as pw; +use pw::spa::pod::deserialize::PodDeserializer; +use pw::spa::pod::{ChoiceValue, Pod, Value}; +use pw::spa::utils::{Choice, ChoiceEnum}; use pw::types::ObjectType; use crate::error::{AppError, AppResult}; @@ -10,6 +13,65 @@ pub struct PwNode { pub id: u32, pub name: String, pub description: String, + pub sample_rate: Option, + pub channels: Option, + format_rank: u8, +} + +fn parse_rate(value: &str) -> Option { + let value = value.trim(); + if let Some((numerator, denominator)) = value.split_once('/') { + let numerator: u32 = numerator.trim().parse().ok()?; + let denominator: u32 = denominator.trim().parse().ok()?; + return (numerator == 1 && denominator > 0).then_some(denominator); + } + value.parse().ok().filter(|rate| *rate > 0) +} + +fn parse_positions(value: &str) -> Option { + let brackets: &[char] = &['[', ']']; + let count = value + .trim_matches(brackets) + .split(|c: char| c.is_whitespace() || c == ',') + .filter(|position| !position.is_empty()) + .count(); + u32::try_from(count).ok().filter(|channels| *channels > 0) +} + +fn choice_default(choice: &Choice) -> i32 { + match &choice.1 { + ChoiceEnum::None(value) => *value, + ChoiceEnum::Range { default, .. } + | ChoiceEnum::Step { default, .. } + | ChoiceEnum::Enum { default, .. } + | ChoiceEnum::Flags { default, .. } => *default, + } +} + +fn pod_int(value: &Value) -> Option { + let value = match value { + Value::Int(value) => *value, + Value::Choice(ChoiceValue::Int(choice)) => choice_default(choice), + _ => return None, + }; + u32::try_from(value).ok().filter(|value| *value > 0) +} + +fn parse_audio_format(param: &Pod) -> (Option, Option) { + let Ok((_, Value::Object(object))) = PodDeserializer::deserialize_any_from(param.as_bytes()) + else { + return (None, None); + }; + let mut rate = None; + let mut channels = None; + for property in &object.properties { + if property.key == pw::spa::param::format::FormatProperties::AudioRate.as_raw() { + rate = pod_int(&property.value); + } else if property.key == pw::spa::param::format::FormatProperties::AudioChannels.as_raw() { + channels = pod_int(&property.value); + } + } + (rate, channels) } pub fn nodes_by_class(media_class: &'static str) -> AppResult> { @@ -23,9 +85,14 @@ fn snapshot(media_class: &str) -> AppResult> { let context = pw::context::ContextRc::new(&mainloop, None).map_err(pw_err)?; let core = context.connect_rc(None).map_err(pw_err)?; let registry = core.get_registry_rc().map_err(pw_err)?; + let registry_weak = registry.downgrade(); let nodes: Rc>> = Rc::new(RefCell::new(Vec::new())); let nodes_cb = nodes.clone(); + // Listener must be dropped before its proxy. + let proxies: Rc>> = + Rc::new(RefCell::new(Vec::new())); + let proxies_cb = proxies.clone(); let want = media_class.to_string(); let _reg = registry @@ -46,17 +113,82 @@ fn snapshot(media_class: &str) -> AppResult> { .filter(|d| !d.is_empty()) .unwrap_or(name) .to_string(); + let sample_rate = props + .get("audio.rate") + .and_then(parse_rate) + .or_else(|| props.get("node.rate").and_then(parse_rate)); + let channels = props + .get("audio.channels") + .and_then(|value| value.parse().ok()) + .filter(|channels| *channels > 0) + .or_else(|| props.get("audio.position").and_then(parse_positions)); nodes_cb.borrow_mut().push(PwNode { id: global.id, name: name.to_string(), description, + sample_rate, + channels, + format_rank: 0, }); + + let Some(registry) = registry_weak.upgrade() else { + return; + }; + let node: pw::node::Node = match registry.bind(global) { + Ok(node) => node, + Err(_) => return, + }; + let node_id = global.id; + let formats = nodes_cb.clone(); + let listener = node + .add_listener_local() + .param(move |_, id, _, _, param| { + let rank = match id { + spa_id if spa_id == pw::spa::param::ParamType::Format => 2, + spa_id if spa_id == pw::spa::param::ParamType::EnumFormat => 1, + _ => return, + }; + let Some(param) = param else { return }; + let (rate, channels) = parse_audio_format(param); + if rate.is_none() && channels.is_none() { + return; + } + let mut formats = formats.borrow_mut(); + let Some(entry) = formats.iter_mut().find(|entry| entry.id == node_id) else { + return; + }; + if rank <= entry.format_rank { + return; + } + if let Some(rate) = rate { + entry.sample_rate = Some(rate); + } + if let Some(channels) = channels { + entry.channels = Some(channels); + } + entry.format_rank = rank; + }) + .register(); + node.enum_params(1, Some(pw::spa::param::ParamType::Format), 0, u32::MAX); + node.enum_params(2, Some(pw::spa::param::ParamType::EnumFormat), 0, u32::MAX); + proxies_cb.borrow_mut().push((listener, node)); }) .register(); + // The first round-trip delivers registry globals. enum_params() is issued + // from those callbacks, after this first sync request is already in flight, + // so a second round-trip is required to wait for the parameter replies. + roundtrip(&core, &mainloop)?; + roundtrip(&core, &mainloop)?; + let out = std::mem::take(&mut *nodes.borrow_mut()); + drop(proxies); + Ok(out) +} + +fn roundtrip(core: &pw::core::CoreRc, mainloop: &pw::main_loop::MainLoopRc) -> AppResult<()> { let pending = core.sync(0).map_err(pw_err)?; let ml = mainloop.clone(); - let _core = core + let listener = core .add_listener_local() .done(move |id, seq| { if id == 0 && seq == pending { @@ -64,10 +196,26 @@ fn snapshot(media_class: &str) -> AppResult> { } }) .register(); - mainloop.run(); - let out = std::mem::take(&mut *nodes.borrow_mut()); - Ok(out) + drop(listener); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{parse_positions, parse_rate}; + + #[test] + fn parses_node_rates() { + assert_eq!(parse_rate("44100"), Some(44_100)); + assert_eq!(parse_rate("1/96000"), Some(96_000)); + } + + #[test] + fn counts_channel_positions() { + assert_eq!(parse_positions("[ FL FR ]"), Some(2)); + assert_eq!(parse_positions("[ AUX0, AUX1, AUX2 ]"), Some(3)); + } } fn pw_err(e: impl std::fmt::Display) -> AppError { diff --git a/src-tauri/src/audio/resample.rs b/src-tauri/src/audio/resample.rs index d71cde56..712e0df5 100644 --- a/src-tauri/src/audio/resample.rs +++ b/src-tauri/src/audio/resample.rs @@ -1,8 +1,8 @@ -//! High-quality sinc-based resampler for interleaved f32 streams of any channel count. +//! High-quality resamplers for interleaved f32 streams of any channel count. use rubato::{ - Resampler, SincFixedIn, SincFixedOut, SincInterpolationParameters, SincInterpolationType, - WindowFunction, + FftFixedIn, Resampler, SincFixedIn, SincFixedOut, SincInterpolationParameters, + SincInterpolationType, WindowFunction, }; use crate::error::{AppError, AppResult}; @@ -98,6 +98,84 @@ pub struct MultiResampler { out_max: usize, } +/// Fixed-rate conversion for device outputs. Unlike capture and network +/// clock conversion, a speaker stream keeps the same rate for its lifetime. +pub struct FixedRateResampler { + inner: FftFixedIn, + channels: usize, + in_planar: Vec>, + out_planar: Vec>, + active: Vec, + chunk_in: usize, + out_max: usize, +} + +impl FixedRateResampler { + pub fn new( + from_rate: u32, + to_rate: u32, + chunk_size: usize, + channels: usize, + ) -> AppResult { + const FFT_SUB_CHUNKS: usize = 4; + let inner = FftFixedIn::::new( + from_rate as usize, + to_rate as usize, + chunk_size, + FFT_SUB_CHUNKS, + channels, + ) + .map_err(|e| AppError::Stream(format!("fixed resampler init: {e}")))?; + let out_max = inner.output_frames_max(); + Ok(Self { + inner, + channels, + in_planar: vec![vec![0.0; chunk_size]; channels], + out_planar: vec![vec![0.0; out_max]; channels], + active: vec![true; channels], + chunk_in: chunk_size, + out_max, + }) + } + + pub fn out_max(&self) -> usize { + self.out_max + } + + pub fn process_chunk_into( + &mut self, + interleaved_in: &[f32], + active_channels: usize, + output: &mut [f32], + ) -> AppResult { + debug_assert_eq!(interleaved_in.len(), self.chunk_in * self.channels); + debug_assert!(output.len() >= self.out_max * self.channels); + let active_channels = active_channels.clamp(1, self.channels); + self.active.fill(false); + self.active[..active_channels].fill(true); + + for (i, frame) in interleaved_in.chunks_exact(self.channels).enumerate() { + for c in 0..active_channels { + self.in_planar[c][i] = frame[c]; + } + } + let (_, produced) = self + .inner + .process_into_buffer(&self.in_planar, &mut self.out_planar, Some(&self.active)) + .map_err(|e| AppError::Stream(format!("fixed resampler process: {e}")))?; + for i in 0..produced { + for c in 0..self.channels { + output[i * self.channels + c] = if c < active_channels { + self.out_planar[c][i] + } else { + 0.0 + }; + } + } + Ok(produced * self.channels) + } +} + impl MultiResampler { pub fn new( from_rate: u32, @@ -171,7 +249,7 @@ impl MultiResampler { #[cfg(test)] mod tests { - use super::MultiResampler; + use super::{FixedRateResampler, MultiResampler}; fn produced_frames(from_rate: u32, to_rate: u32) -> usize { const CHANNELS: usize = 2; @@ -219,4 +297,24 @@ mod tests { assert!(written > 0); assert_eq!(written % 2, 0); } + + #[test] + fn fixed_rate_resampler_leaves_trailing_channels_silent() { + const CHANNELS: usize = 16; + let mut resampler = FixedRateResampler::new(48_000, 96_000, 1024, CHANNELS).unwrap(); + let mut input = vec![0.0_f32; 1024 * CHANNELS]; + for frame in input.chunks_exact_mut(CHANNELS) { + frame[0] = 0.25; + frame[1] = -0.25; + } + let mut output = vec![1.0_f32; resampler.out_max() * CHANNELS]; + let written = resampler + .process_chunk_into(&input, 2, &mut output) + .unwrap(); + assert!(written > 0); + assert_eq!(written % CHANNELS, 0); + for frame in output[..written].chunks_exact(CHANNELS) { + assert!(frame[2..].iter().all(|sample| *sample == 0.0)); + } + } } diff --git a/src-tauri/src/audio/streams/cpal_stream.rs b/src-tauri/src/audio/streams/cpal_stream.rs index 7b2cbf01..4f604b90 100644 --- a/src-tauri/src/audio/streams/cpal_stream.rs +++ b/src-tauri/src/audio/streams/cpal_stream.rs @@ -154,7 +154,13 @@ where T: Sample + cpal::SizedSample + cpal::FromSample + Send + 'static, F: FnMut(&mut [f32], usize) + Send + 'static, { - let mut buf: Vec = vec![0.0; 16384]; + const DEFAULT_SCRATCH_FRAMES: usize = 1024; + let configured_frames = match config.buffer_size { + cpal::BufferSize::Fixed(frames) => frames as usize, + cpal::BufferSize::Default => DEFAULT_SCRATCH_FRAMES, + }; + let scratch_samples = configured_frames.max(1) * out_channels.max(1); + let mut buf: Vec = vec![0.0; scratch_samples]; let stream = device .build_output_stream::( config, @@ -162,16 +168,13 @@ where if out_channels == 0 || data.is_empty() { return; } - let total = data.len(); - let frames = total / out_channels; - if buf.len() < total { - buf.resize(total, 0.0); - } - // `fill` supplies interleaved audio already at the device's - // channel width (the DSP worker produces `out_channels`-wide). - fill(&mut buf[..total], frames); - for (out, s) in data.iter_mut().zip(&buf[..total]) { - *out = T::from_sample(*s); + let callback_frames = data.len() / out_channels; + for out_chunk in data.chunks_mut(buf.len()) { + let samples = out_chunk.len(); + fill(&mut buf[..samples], callback_frames); + for (out, sample) in out_chunk.iter_mut().zip(&buf[..samples]) { + *out = T::from_sample(*sample); + } } }, err_cb, diff --git a/src-tauri/src/audio/virtual_device/linux.rs b/src-tauri/src/audio/virtual_device/linux.rs index 3aa26324..242c683e 100644 --- a/src-tauri/src/audio/virtual_device/linux.rs +++ b/src-tauri/src/audio/virtual_device/linux.rs @@ -1,34 +1,31 @@ -use std::path::PathBuf; +use std::sync::{OnceLock, RwLock}; use pipewire as pw; use tauri::AppHandle; +use tauri_plugin_store::StoreExt; use crate::audio::pw_enum::nodes_by_class; use super::{VirtualDeviceConfig, VirtualDriverStatus}; -const CONF_NAME: &str = "50-splitwave-sinks.conf"; const NODE_PREFIX: &str = "splitwave"; +const STORE_NAME: &str = "virtual-devices.json"; +const STORE_KEY: &str = "devices"; +const LEGACY_CONF_NAME: &str = "50-splitwave-sinks.conf"; -fn conf_path() -> Option { - Some( - dirs::config_dir()? - .join("pipewire/pipewire.conf.d") - .join(CONF_NAME), - ) +fn configs() -> &'static RwLock> { + static CONFIGS: OnceLock>> = OnceLock::new(); + CONFIGS.get_or_init(|| RwLock::new(Vec::new())) } -// node.name is the stable handle, node.description is the label shown in -// system settings. Quotes would break both the .conf and the created node -// props, so drop them from the label. fn clean_label(name: &str) -> String { - name.replace(['"', '\''], "") + name.replace('\0', "") } // PipeWire channel map. Standard names for mono/stereo; generic AUX for wider -// layouts so any channel count is accepted. +// layouts. fn positions(channels: u32) -> String { - let list: Vec = match channels.clamp(1, 256) { + let list: Vec = match channels.clamp(1, pw::spa::param::audio::MAX_CHANNELS as u32) { 1 => vec!["MONO".into()], 2 => vec!["FL".into(), "FR".into()], n => (0..n).map(|i| format!("AUX{i}")).collect(), @@ -54,59 +51,68 @@ pub fn install(_app: &AppHandle) -> Result<(), String> { } pub fn uninstall() -> Result<(), String> { - unload_runtime_sinks(); - if let Some(p) = conf_path() { - let _ = std::fs::remove_file(p); - } + unload_runtime_sinks()?; + remove_legacy_config()?; + *configs() + .write() + .map_err(|_| "virtual device cache poisoned")? = Vec::new(); Ok(()) } pub fn apply_virtual_devices(devices: Vec) -> Result<(), String> { - unload_runtime_sinks(); - - let conf = conf_path().ok_or("no config directory")?; - if devices.is_empty() { - let _ = std::fs::remove_file(&conf); - return Ok(()); - } - - if let Some(parent) = conf.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("create conf dir: {e}"))?; + for device in &devices { + if !(1..=pw::spa::param::audio::MAX_CHANNELS as u32).contains(&device.channels) { + return Err(format!("invalid channel count for {:?}", device.name)); + } + if device.sample_rate == 0 { + return Err(format!("invalid sample rate for {:?}", device.name)); + } } - std::fs::write(&conf, conf_contents(&devices)).map_err(|e| format!("write conf: {e}"))?; - + remove_legacy_config()?; + unload_runtime_sinks()?; for d in &devices { create_runtime_sink(&d.id, &clean_label(&d.name), d.channels, d.sample_rate)?; } + *configs() + .write() + .map_err(|_| "virtual device cache poisoned")? = devices; Ok(()) } -fn conf_contents(devices: &[VirtualDeviceConfig]) -> String { - let mut out = - String::from("# Auto-generated by Splitwave. Do not edit.\n\ncontext.objects = [\n"); - for d in devices { - let desc = clean_label(&d.name); - out.push_str(" {\n"); - out.push_str(" factory = adapter\n"); - out.push_str(" args = {\n"); - out.push_str(" factory.name = support.null-audio-sink\n"); - out.push_str(&format!( - " node.name = \"{NODE_PREFIX}.{}\"\n", - d.id - )); - out.push_str(&format!(" node.description = \"{desc}\"\n")); - out.push_str(" media.class = Audio/Sink\n"); - out.push_str(&format!( - " audio.position = [ {} ]\n", - positions(d.channels) - )); - out.push_str(&format!(" audio.rate = {}\n", d.sample_rate)); - out.push_str(" object.linger = true\n"); - out.push_str(" }\n"); - out.push_str(" }\n"); +fn remove_legacy_config() -> Result<(), String> { + let Some(path) = + dirs::config_dir().map(|dir| dir.join("pipewire/pipewire.conf.d").join(LEGACY_CONF_NAME)) + else { + return Ok(()); + }; + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("remove legacy PipeWire config: {error}")), } - out.push_str("]\n"); - out +} + +pub fn restore(app: &AppHandle) -> Result<(), String> { + let store = app + .store(STORE_NAME) + .map_err(|e| format!("open virtual device store: {e}"))?; + let devices = store + .get(STORE_KEY) + .map(serde_json::from_value) + .transpose() + .map_err(|e| format!("read virtual device store: {e}"))? + .unwrap_or_default(); + apply_virtual_devices(devices) +} + +pub fn config_for_node(node_name: &str) -> Option { + let id = node_name.strip_prefix(&format!("{NODE_PREFIX}."))?; + configs() + .read() + .ok()? + .iter() + .find(|device| device.id == id) + .cloned() } // Connect a short-lived PipeWire session, apply `f`, and wait one round-trip so @@ -142,9 +148,6 @@ fn roundtrip(core: &pw::core::CoreRc, mainloop: &pw::main_loop::MainLoopRc) -> R Ok(()) } -// Create the sink in the running session so it shows up immediately. The .conf -// only takes effect on the next PipeWire start; object.linger keeps the node -// alive after we disconnect. fn create_runtime_sink( id: &str, label: &str, @@ -168,20 +171,18 @@ fn create_runtime_sink( }) } -fn unload_runtime_sinks() { +fn unload_runtime_sinks() -> Result<(), String> { let prefix = format!("{NODE_PREFIX}."); - let Ok(nodes) = nodes_by_class("Audio/Sink") else { - return; - }; + let nodes = nodes_by_class("Audio/Sink").map_err(|error| error.to_string())?; let mine: Vec = nodes .iter() .filter(|n| n.name.starts_with(&prefix)) .map(|n| n.id) .collect(); if mine.is_empty() { - return; + return Ok(()); } - let _ = with_session(|core, mainloop| { + with_session(|core, mainloop| { let registry = core .get_registry() .map_err(|e| format!("pipewire registry: {e}"))?; @@ -192,5 +193,5 @@ fn unload_runtime_sinks() { .map_err(|e| format!("destroy sink {id}: {e:?}"))?; } roundtrip(core, mainloop) - }); + }) } diff --git a/src-tauri/src/audio/virtual_device/mod.rs b/src-tauri/src/audio/virtual_device/mod.rs index 1c4f1e5c..60bc2d5c 100644 --- a/src-tauri/src/audio/virtual_device/mod.rs +++ b/src-tauri/src/audio/virtual_device/mod.rs @@ -29,11 +29,91 @@ pub struct VirtualDriverStatus { pub needs_update: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WindowsVirtualCableState { + NotInstalled, + InstalledExternal, + InstalledManaged, + Partial, + RebootRequired, + RemovalPendingReboot, + UnknownOwnership, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WindowsVirtualCableOwnership { + External, + Managed, + Unknown, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowsVirtualCableStatus { + pub state: WindowsVirtualCableState, + pub usable: bool, + pub provider: String, + pub installed_version: Option, + pub render_endpoint_name: Option, + pub capture_endpoint_name: Option, + pub ownership: WindowsVirtualCableOwnership, + pub managed_by_splitwave: bool, + pub reboot_required: bool, + pub detail: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowsVirtualCableError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub installer_exit_code: Option, +} + +impl WindowsVirtualCableError { + pub fn operation_failed(message: impl Into) -> Self { + Self::new("operationFailed", message) + } + + pub(crate) fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + installer_exit_code: None, + } + } +} + +impl std::fmt::Display for WindowsVirtualCableError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for WindowsVirtualCableError {} + +#[cfg(target_os = "windows")] pub mod windows_cable; -pub use windows_cable::{ - install_windows_virtual_cable, windows_virtual_cable_status, WindowsVirtualCableError, - WindowsVirtualCableStatus, -}; +#[cfg(target_os = "windows")] +pub use windows_cable::{install_windows_virtual_cable, windows_virtual_cable_status}; + +#[cfg(not(target_os = "windows"))] +pub fn windows_virtual_cable_status() -> Result +{ + Err(WindowsVirtualCableError::new( + "unsupportedPlatform", + "VB-CABLE integration is available only on Windows", + )) +} + +#[cfg(not(target_os = "windows"))] +pub fn install_windows_virtual_cable() -> Result +{ + windows_virtual_cable_status() +} #[cfg(target_os = "macos")] mod macos; @@ -43,7 +123,7 @@ 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, config_for_node, install, restore, status, uninstall}; #[cfg(target_os = "windows")] mod windows; diff --git a/src-tauri/src/audio/virtual_device/windows_cable.rs b/src-tauri/src/audio/virtual_device/windows_cable.rs index a3d29fbd..e9fbe343 100644 --- a/src-tauri/src/audio/virtual_device/windows_cable.rs +++ b/src-tauri/src/audio/virtual_device/windows_cable.rs @@ -5,58 +5,15 @@ use serde::{Deserialize, Serialize}; +use super::{ + WindowsVirtualCableError, WindowsVirtualCableOwnership, WindowsVirtualCableState, + WindowsVirtualCableStatus, +}; + const MANIFEST_SCHEMA_VERSION: u32 = 1; const PROVIDER_NAME: &str = "VB-Audio VB-CABLE"; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum WindowsVirtualCableState { - NotInstalled, - InstalledExternal, - InstalledManaged, - Partial, - RebootRequired, - RemovalPendingReboot, - UnknownOwnership, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum WindowsVirtualCableOwnership { - External, - Managed, - Unknown, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WindowsVirtualCableStatus { - pub state: WindowsVirtualCableState, - pub usable: bool, - pub provider: String, - pub installed_version: Option, - pub render_endpoint_name: Option, - pub capture_endpoint_name: Option, - pub ownership: WindowsVirtualCableOwnership, - pub managed_by_splitwave: bool, - pub reboot_required: bool, - pub detail: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WindowsVirtualCableError { - pub code: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub installer_exit_code: Option, -} - impl WindowsVirtualCableError { - pub fn operation_failed(message: impl Into) -> Self { - Self::new("operationFailed", message) - } - pub fn confirmation_required() -> Self { Self::new( "confirmationRequired", @@ -64,28 +21,12 @@ impl WindowsVirtualCableError { ) } - fn new(code: impl Into, message: impl Into) -> Self { - Self { - code: code.into(), - message: message.into(), - installer_exit_code: None, - } - } - fn with_installer_exit_code(mut self, installer_exit_code: Option) -> Self { self.installer_exit_code = installer_exit_code; self } } -impl std::fmt::Display for WindowsVirtualCableError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {}", self.code, self.message) - } -} - -impl std::error::Error for WindowsVirtualCableError {} - #[derive(Debug, Clone, PartialEq, Eq)] struct CablePackage { provider: String, @@ -451,35 +392,14 @@ fn verified_package_after_setup<'a>( }) } -#[cfg(target_os = "windows")] mod platform; -#[cfg(target_os = "windows")] pub use platform::{install, status}; -#[cfg(target_os = "windows")] pub fn run_helper() -> Option { platform::run_helper() } -#[cfg(not(target_os = "windows"))] -pub fn status() -> Result { - Err(WindowsVirtualCableError::new( - "unsupportedPlatform", - "VB-CABLE integration is available only on Windows", - )) -} - -#[cfg(not(target_os = "windows"))] -pub fn install() -> Result { - status() -} - -#[cfg(not(target_os = "windows"))] -pub fn run_helper() -> Option { - None -} - pub fn windows_virtual_cable_status() -> Result { status() diff --git a/src-tauri/src/audio/volume/linux.rs b/src-tauri/src/audio/volume/linux.rs index 97e5ecfb..7cd6a16b 100644 --- a/src-tauri/src/audio/volume/linux.rs +++ b/src-tauri/src/audio/volume/linux.rs @@ -77,6 +77,11 @@ fn device_volume_from(volume: &ChannelVolumes, mute: bool) -> DeviceVolume { // "default"/"pipewire"/"sysdefault" are route aliases, not PulseAudio sink // names; resolve them to the server's actual default sink/source name. fn resolve_device(intro: &Introspector, kind: DeviceKind, name: &str) -> Option { + if kind == DeviceKind::Input { + if let Some(sink) = name.strip_prefix("monitor:") { + return Some(format!("{sink}.monitor")); + } + } match name { "default" | "pipewire" | "sysdefault" => { let out = Arc::new(Mutex::new(None)); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 17ce6e24..2739d6ec 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -184,9 +184,28 @@ pub fn clear_logs() { /// (a real panic, not a faked event). Crashes the app on purpose. #[tauri::command] pub fn debug_panic(app: AppHandle) { - let _ = app.run_on_main_thread(|| { - panic!("debug: intentional test panic"); - }); + #[cfg(debug_assertions)] + { + let _ = app.run_on_main_thread(|| { + panic!("debug: intentional test panic"); + }); + } + #[cfg(not(debug_assertions))] + let _ = app; +} + +/// Dev-only: exercises the platform native signal/exception crash reporter. +#[tauri::command] +pub fn debug_native_crash() { + #[cfg(debug_assertions)] + crate::native_crash::trigger(); +} + +/// Dev-only: exits without panic or a catchable signal to test the session marker. +#[tauri::command] +pub fn debug_unexpected_exit() { + #[cfg(debug_assertions)] + std::process::exit(86); } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 46f6189c..318e46a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod audio; mod commands; mod error; mod logs; +mod native_crash; mod state; use std::path::PathBuf; @@ -20,10 +21,62 @@ const MENU_EVENT: &str = "menu://action"; static APP_HANDLE: OnceLock = OnceLock::new(); -// Set at startup. A panic can kill the app before the live `PANIC_EVENT` -// reaches the UI, so each panic is also appended here (one JSON object per -// line) and replayed on the next launch. +// Set at startup. Reports are persisted before termination and replayed on the +// next launch because a dying process cannot reliably notify the webview. static CRASH_FILE: OnceLock = OnceLock::new(); +static SESSION_FILE: OnceLock = OnceLock::new(); + +fn append_report(path: &std::path::Path, payload: &serde_json::Value) { + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + use std::io::Write; + let _ = writeln!(file, "{payload}"); + } +} + +fn initialize_crash_reporting(dir: &std::path::Path) { + let crash_file = dir.join("crashes.jsonl"); + let session_file = dir.join("running-session"); + let crash_len = std::fs::metadata(&crash_file).map_or(0, |meta| meta.len()); + + if let Ok(baseline) = std::fs::read_to_string(&session_file) { + let baseline = baseline.trim().parse::().unwrap_or(0); + if crash_len <= baseline { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0); + append_report( + &crash_file, + &json!({ + "kind": "unexpectedExit", + "message": "Splitwave did not shut down cleanly", + "backtrace": "No native stack was captured. The process may have been killed by the OS, an out-of-memory condition, or a native crash without a dump.", + "thread": "", + "version": env!("CARGO_PKG_VERSION"), + "ts": ts, + }), + ); + } + } + + let current_len = std::fs::metadata(&crash_file).map_or(0, |meta| meta.len()); + let _ = std::fs::write(&session_file, current_len.to_string()); + let _ = CRASH_FILE.set(crash_file.clone()); + let _ = SESSION_FILE.set(session_file); + if let Err(error) = native_crash::install(&crash_file) { + tracing::error!(%error, "failed to install native crash handler"); + } +} + +fn mark_clean_exit() { + if let Some(path) = SESSION_FILE.get() { + let _ = std::fs::remove_file(path); + } +} #[cfg(target_os = "windows")] pub fn run_windows_vb_cable_helper() -> Option { @@ -34,8 +87,7 @@ pub fn app_handle() -> Option<&'static AppHandle> { APP_HANDLE.get() } -/// Reads and clears persisted crash reports (best-effort). Called once at -/// startup so the UI can surface crashes from a previous run. +/// Reads and clears persisted crash reports (best-effort). pub fn take_crash_reports() -> Vec { let Some(path) = CRASH_FILE.get() else { return Vec::new(); @@ -43,7 +95,13 @@ pub fn take_crash_reports() -> Vec { let Ok(contents) = std::fs::read_to_string(path) else { return Vec::new(); }; - let _ = std::fs::remove_file(path); + let _ = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(path); + if let Some(session) = SESSION_FILE.get() { + let _ = std::fs::write(session, "0"); + } contents .lines() .filter(|l| !l.trim().is_empty()) @@ -127,6 +185,7 @@ fn install_panic_hook() { .map(|d| d.as_millis() as u64) .unwrap_or(0); let payload = json!({ + "kind": "rustPanic", "message": info.to_string(), "backtrace": backtrace, "thread": std::thread::current().name().unwrap_or(""), @@ -160,6 +219,9 @@ fn install_panic_hook() { /// chance to install its own. pub fn reinstall_panic_hook() { install_panic_hook(); + if let Err(error) = native_crash::reinstall() { + tracing::error!(%error, "failed to reinstall native crash handler"); + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -186,11 +248,20 @@ pub fn run() { let handle = app.handle().clone(); let _ = APP_HANDLE.set(handle.clone()); + #[cfg(target_os = "linux")] + pipewire::init(); + if let Ok(dir) = handle.path().app_log_dir() { let _ = std::fs::create_dir_all(&dir); - let file = dir.join("crashes.jsonl"); - info!(path = %file.display(), "crash log"); - let _ = CRASH_FILE.set(file); + initialize_crash_reporting(&dir); + if let Some(file) = CRASH_FILE.get() { + info!(path = %file.display(), "crash log"); + } + } + + #[cfg(target_os = "linux")] + if let Err(error) = audio::virtual_device::restore(&handle) { + tracing::error!(%error, "failed to restore PipeWire virtual devices"); } // Native menu only on macOS (top menu bar). On Linux GTK renders it @@ -230,6 +301,8 @@ pub fn run() { commands::get_logs, commands::clear_logs, commands::debug_panic, + commands::debug_native_crash, + commands::debug_unexpected_exit, commands::list_input_devices, commands::list_output_devices, commands::play_cue, @@ -279,6 +352,11 @@ pub fn run() { commands::webrtc_set_identity, commands::webrtc_session_state, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|_, event| { + if matches!(event, tauri::RunEvent::Exit) { + mark_clean_exit(); + } + }); } diff --git a/src-tauri/src/native_crash.rs b/src-tauri/src/native_crash.rs new file mode 100644 index 00000000..f996e1a1 --- /dev/null +++ b/src-tauri/src/native_crash.rs @@ -0,0 +1,167 @@ +use std::fs::OpenOptions; +use std::io; +use std::path::Path; + +#[cfg(unix)] +mod platform { + use std::os::fd::IntoRawFd; + use std::sync::atomic::{AtomicI32, Ordering}; + + use super::*; + + static CRASH_FD: AtomicI32 = AtomicI32::new(-1); + + macro_rules! report { + ($signal:literal) => { + concat!( + "{\"kind\":\"nativeCrash\",\"message\":\"Native crash: ", + $signal, + "\",\"backtrace\":\"The process terminated before a Rust backtrace could be captured. Use the OS crash dump for the native stack.\",\"thread\":\"\",\"version\":\"", + env!("CARGO_PKG_VERSION"), + "\"}\n" + ) + .as_bytes() + }; + } + + const SIGABRT_REPORT: &[u8] = report!("SIGABRT"); + const SIGBUS_REPORT: &[u8] = report!("SIGBUS"); + const SIGFPE_REPORT: &[u8] = report!("SIGFPE"); + const SIGILL_REPORT: &[u8] = report!("SIGILL"); + const SIGSEGV_REPORT: &[u8] = report!("SIGSEGV"); + const SIGSYS_REPORT: &[u8] = report!("SIGSYS"); + const SIGTRAP_REPORT: &[u8] = report!("SIGTRAP"); + + extern "C" fn fatal_signal(signal: libc::c_int) { + let report = match signal { + libc::SIGABRT => SIGABRT_REPORT, + libc::SIGBUS => SIGBUS_REPORT, + libc::SIGFPE => SIGFPE_REPORT, + libc::SIGILL => SIGILL_REPORT, + libc::SIGSEGV => SIGSEGV_REPORT, + libc::SIGSYS => SIGSYS_REPORT, + libc::SIGTRAP => SIGTRAP_REPORT, + _ => b"", + }; + let fd = CRASH_FD.load(Ordering::Relaxed); + if fd >= 0 && !report.is_empty() { + let mut written = 0; + while written < report.len() { + let result = unsafe { + libc::write( + fd, + report[written..].as_ptr().cast(), + report.len() - written, + ) + }; + if result <= 0 { + break; + } + written += result as usize; + } + } + if unsafe { libc::raise(signal) } != 0 { + unsafe { libc::_exit(128 + signal) }; + } + } + + fn register(signal: libc::c_int) -> io::Result<()> { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = fatal_signal as *const () as usize; + action.sa_flags = libc::SA_RESETHAND; + unsafe { libc::sigemptyset(&mut action.sa_mask) }; + if unsafe { libc::sigaction(signal, &action, std::ptr::null_mut()) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + + pub fn install(path: &Path) -> io::Result<()> { + if CRASH_FD.load(Ordering::Relaxed) < 0 { + let file = OpenOptions::new().create(true).append(true).open(path)?; + CRASH_FD.store(file.into_raw_fd(), Ordering::Relaxed); + } + reinstall() + } + + pub fn reinstall() -> io::Result<()> { + for signal in [ + libc::SIGABRT, + libc::SIGBUS, + libc::SIGFPE, + libc::SIGILL, + libc::SIGSEGV, + libc::SIGSYS, + libc::SIGTRAP, + ] { + register(signal)?; + } + Ok(()) + } + + #[cfg(debug_assertions)] + pub fn trigger() { + unsafe { libc::raise(libc::SIGABRT) }; + std::process::abort(); + } +} + +#[cfg(windows)] +mod platform { + use std::os::windows::io::IntoRawHandle; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::WriteFile; + use windows::Win32::System::Diagnostics::Debug::{ + SetUnhandledExceptionFilter, EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, + }; + + use super::*; + + static CRASH_HANDLE: AtomicUsize = AtomicUsize::new(0); + const REPORT: &[u8] = concat!("{\"kind\":\"nativeCrash\",\"message\":\"Unhandled native Windows exception\",\"backtrace\":\"The process terminated before a Rust backtrace could be captured. Use the Windows crash dump for the native stack.\",\"thread\":\"\",\"version\":\"", env!("CARGO_PKG_VERSION"), "\"}\n").as_bytes(); + + unsafe extern "system" fn unhandled_exception(_: *const EXCEPTION_POINTERS) -> i32 { + let raw = CRASH_HANDLE.load(Ordering::Relaxed); + if raw != 0 { + let handle = HANDLE(raw as *mut _); + let mut written = 0; + let _ = unsafe { WriteFile(handle, Some(REPORT), Some(&mut written), None) }; + } + EXCEPTION_CONTINUE_SEARCH + } + + pub fn install(path: &Path) -> io::Result<()> { + if CRASH_HANDLE.load(Ordering::Relaxed) == 0 { + let file = OpenOptions::new().create(true).append(true).open(path)?; + CRASH_HANDLE.store(file.into_raw_handle() as usize, Ordering::Relaxed); + } + reinstall() + } + + pub fn reinstall() -> io::Result<()> { + unsafe { SetUnhandledExceptionFilter(Some(unhandled_exception)) }; + Ok(()) + } + + #[cfg(debug_assertions)] + pub fn trigger() { + unsafe { windows::Win32::System::Diagnostics::Debug::RaiseException(0xE053_5743, 1, None) }; + std::process::abort(); + } +} + +pub fn install(path: &Path) -> io::Result<()> { + platform::install(path) +} + +pub fn reinstall() -> io::Result<()> { + platform::reinstall() +} + +#[cfg(debug_assertions)] +pub fn trigger() { + platform::trigger() +} diff --git a/src/lib/components/format.ts b/src/lib/components/format.ts index a21d4465..4c4ae6d1 100644 --- a/src/lib/components/format.ts +++ b/src/lib/components/format.ts @@ -108,4 +108,3 @@ export function formatGain(db: number): string { export function formatDb(db: number, floor: number = -96): string { return Number.isFinite(db) && db > floor ? db.toFixed(1) : '−∞'; } - diff --git a/src/lib/modules/debug/ui/debug_panel.svelte b/src/lib/modules/debug/ui/debug_panel.svelte index 502054d2..d378151c 100644 --- a/src/lib/modules/debug/ui/debug_panel.svelte +++ b/src/lib/modules/debug/ui/debug_panel.svelte @@ -25,6 +25,14 @@ invoke('debug_panic').catch(() => {}); } + function nativeCrash() { + invoke('debug_native_crash').catch(() => {}); + } + + function unexpectedExit() { + invoke('debug_unexpected_exit').catch(() => {}); + } + function fakeJsError() { errorStore.report({ source: 'jsError', @@ -89,6 +97,8 @@ + + diff --git a/src/lib/modules/error/init.ts b/src/lib/modules/error/init.ts index 7b63eddc..af9bf17f 100644 --- a/src/lib/modules/error/init.ts +++ b/src/lib/modules/error/init.ts @@ -4,7 +4,8 @@ import { errorStore } from './stores.svelte'; const PANIC_EVENT = 'error://panic'; -interface PanicPayload { +interface CrashPayload { + kind?: 'rustPanic' | 'nativeCrash' | 'unexpectedExit'; message: string; backtrace: string; thread: string; @@ -19,7 +20,7 @@ export async function installErrorHandlers(): Promise { if (installed) return; installed = true; - unlistenPanic = await listen(PANIC_EVENT, (e) => { + unlistenPanic = await listen(PANIC_EVENT, (e) => { errorStore.report({ source: 'rustPanic', message: e.payload.message, @@ -29,13 +30,13 @@ export async function installErrorHandlers(): Promise { }); }); - // A panic that killed the app last run never delivered its live event; the - // backend persisted it, so surface it now. - invoke('take_crash_reports') + // Fatal native failures cannot reach the live webview; replay every report + // persisted by the backend during the previous run. + invoke('take_crash_reports') .then((reports) => { for (const r of reports) { errorStore.report({ - source: 'rustPanic', + source: r.kind ?? 'rustPanic', message: r.message, stack: r.backtrace, thread: r.thread, diff --git a/src/lib/modules/error/stores.svelte.ts b/src/lib/modules/error/stores.svelte.ts index b64af7c9..90e1e1cf 100644 --- a/src/lib/modules/error/stores.svelte.ts +++ b/src/lib/modules/error/stores.svelte.ts @@ -1,4 +1,4 @@ -export type ErrorSource = 'rustPanic' | 'jsError' | 'unhandledRejection'; +export type ErrorSource = 'rustPanic' | 'nativeCrash' | 'unexpectedExit' | 'jsError' | 'unhandledRejection'; export interface ErrorEntry { source: ErrorSource; diff --git a/src/lib/modules/error/ui/error_modal.svelte b/src/lib/modules/error/ui/error_modal.svelte index 8ba019a4..60d69f24 100644 --- a/src/lib/modules/error/ui/error_modal.svelte +++ b/src/lib/modules/error/ui/error_modal.svelte @@ -63,6 +63,10 @@ switch (s) { case 'rustPanic': return 'Rust panic'; + case 'nativeCrash': + return 'Native crash'; + case 'unexpectedExit': + return 'Unexpected exit'; case 'jsError': return 'JS error'; case 'unhandledRejection': diff --git a/src/lib/modules/flow/ui/effect/eq.svelte b/src/lib/modules/flow/ui/effect/eq.svelte index 439b775f..7fe2ee27 100644 --- a/src/lib/modules/flow/ui/effect/eq.svelte +++ b/src/lib/modules/flow/ui/effect/eq.svelte @@ -161,7 +161,6 @@ (e.currentTarget as HTMLInputElement).blur(); } } - diff --git a/src/lib/modules/flow/ui/effect/level_meter.svelte b/src/lib/modules/flow/ui/effect/level_meter.svelte index a5a0eaf2..d6ad5ca3 100644 --- a/src/lib/modules/flow/ui/effect/level_meter.svelte +++ b/src/lib/modules/flow/ui/effect/level_meter.svelte @@ -63,7 +63,6 @@ return (pct / 100) * -DB_FLOOR + DB_FLOOR; } - function hoverLabel(pct: number): string { return pctToDb(pct).toFixed(1); } diff --git a/src/lib/modules/flow/ui/effect/mute.svelte b/src/lib/modules/flow/ui/effect/mute.svelte index 852ffb5e..070398fd 100644 --- a/src/lib/modules/flow/ui/effect/mute.svelte +++ b/src/lib/modules/flow/ui/effect/mute.svelte @@ -120,7 +120,6 @@ flow.updateNodeData(id, { cueVolume: v }); } - function clearHotkey() { bindError = ''; flow.updateNodeData(id, { hotkey: undefined }); diff --git a/src/lib/modules/flow/ui/input/_input_meter.svelte b/src/lib/modules/flow/ui/input/_input_meter.svelte index d6e6e981..8e0b9c25 100644 --- a/src/lib/modules/flow/ui/input/_input_meter.svelte +++ b/src/lib/modules/flow/ui/input/_input_meter.svelte @@ -1,5 +1,4 @@ diff --git a/src/lib/modules/flow/ui/input/audio_file.svelte b/src/lib/modules/flow/ui/input/audio_file.svelte index 401a7c56..3c0fecdc 100644 --- a/src/lib/modules/flow/ui/input/audio_file.svelte +++ b/src/lib/modules/flow/ui/input/audio_file.svelte @@ -158,7 +158,6 @@ return i >= 0 ? p.slice(i + 1) : p; } - function extension(p: string | null): string { const i = p?.lastIndexOf('.') ?? -1; return i > 0 ? (p as string).slice(i + 1).toUpperCase() : ''; @@ -177,7 +176,6 @@ audioMethods.setInputVolume(id, scalar).catch(() => {}); } - let volumePct = $derived((data.volume ?? 1) * 100); let srcTooltip = $derived.by(() => { @@ -207,7 +205,7 @@ {/if} {#if sampleRate > 0} -
+
{formatHz(sampleRate)} · {channelLabel} {extension(data.filePath)}
diff --git a/src/lib/modules/flow/ui/input/net_receiver.svelte b/src/lib/modules/flow/ui/input/net_receiver.svelte index 582e8778..c71c5882 100644 --- a/src/lib/modules/flow/ui/input/net_receiver.svelte +++ b/src/lib/modules/flow/ui/input/net_receiver.svelte @@ -144,7 +144,7 @@
-
+
codec @@ -168,7 +168,7 @@
-
+
diff --git a/src/lib/modules/flow/ui/output/file_recording.svelte b/src/lib/modules/flow/ui/output/file_recording.svelte index f2581dc2..d3379b54 100644 --- a/src/lib/modules/flow/ui/output/file_recording.svelte +++ b/src/lib/modules/flow/ui/output/file_recording.svelte @@ -436,7 +436,6 @@ return idx >= 0 ? p.slice(idx + 1) : p; } - const WAV_BIT_DEPTHS: { value: WavBitDepth; label: string; sub: string }[] = [ { value: 'i16', label: '16-bit', sub: 'PCM' }, { value: 'i24', label: '24-bit', sub: 'PCM' }, @@ -797,7 +796,7 @@ {/if}
-
+
{formatLabelFor(recording && committedFormat !== null ? committedFormat : data.format)} @@ -810,7 +809,7 @@ {/if}
- + Waveform {#if !isAppendable(data.format)} diff --git a/src/lib/modules/flow/ui/output/net_sender.svelte b/src/lib/modules/flow/ui/output/net_sender.svelte index 0bee2bab..f4b7f2ef 100644 --- a/src/lib/modules/flow/ui/output/net_sender.svelte +++ b/src/lib/modules/flow/ui/output/net_sender.svelte @@ -170,7 +170,7 @@
-
+
Sending {formatRate(rate)}
diff --git a/src/lib/modules/updater/ui/update_banner.svelte b/src/lib/modules/updater/ui/update_banner.svelte index 77225dfb..03b54194 100644 --- a/src/lib/modules/updater/ui/update_banner.svelte +++ b/src/lib/modules/updater/ui/update_banner.svelte @@ -28,7 +28,6 @@ return Math.min(100, Math.round((s.downloaded / s.total) * 100)); } - function dismiss() { updaterStore.state = { phase: 'idle' }; } diff --git a/src/routes/virtual-devices/+page.svelte b/src/routes/virtual-devices/+page.svelte index ff35d352..a5fe3a9c 100644 --- a/src/routes/virtual-devices/+page.svelte +++ b/src/routes/virtual-devices/+page.svelte @@ -14,6 +14,7 @@ const isLinux = platform() === 'linux'; const isWindows = platform() === 'windows'; + const maxChannels = isLinux ? 64 : 256; const store = new LazyStore('virtual-devices.json'); const STORE_KEY = 'devices'; @@ -62,7 +63,7 @@ } function setChannels(id: string, channels: number) { - const clamped = Math.min(Math.max(Math.round(channels) || 2, 1), 256); + const clamped = Math.min(Math.max(Math.round(channels) || 2, 1), maxChannels); devices = devices.map((d) => (d.id === id ? { ...d, channels: clamped } : d)); } @@ -205,7 +206,7 @@ setChannels(d.id, v)} /> @@ -224,7 +225,7 @@ {/each}
- Appears as input + output · up to 256 channels + Appears as input + output · up to {maxChannels} channels