From 3721ef3dc9ff48cfa23b3077070eab91080832b6 Mon Sep 17 00:00:00 2001
From: Horuse <39675195+Horuse@users.noreply.github.com>
Date: Sun, 6 Sep 2026 15:46:35 +0300
Subject: [PATCH 1/9] feat(audio): configurable pipeline and virtual device
sample rates (#32)
- Add pipeline sample rate setting in Settings (presets: 44.1k, 48k default, 88.2k, 96k, 176.4k, 192k + 1 Hz custom stepper).
- Add sample rate configuration per virtual device in Virtual Devices UI and HAL driver.
- Ensure bit-transparent signal path without resampling when device and pipeline rates match.
- Add input normalizer and output rate adaptation to eliminate Issue #32 capture startup mismatch crashes.
- Bump virtual driver version to 5.
---
src-tauri/native/CATapCapture.swift | 5 +
src-tauri/native/virtual_driver/Info.plist | 2 +-
.../virtual_driver/SplitAudioDriver.cpp | 25 ++-
src-tauri/src/audio/capture/macos_backend.rs | 8 +
src-tauri/src/audio/capture/macos_tap.rs | 29 ++++
src-tauri/src/audio/capture/mod.rs | 2 +-
src-tauri/src/audio/clock.rs | 33 ++--
src-tauri/src/audio/graph.rs | 56 ++++++
src-tauri/src/audio/pipeline/dag.rs | 1 +
src-tauri/src/audio/pipeline/input/macos.rs | 14 +-
src-tauri/src/audio/pipeline/input/mod.rs | 164 +++++++++++++++++-
src-tauri/src/audio/pipeline/meter.rs | 7 +-
src-tauri/src/audio/pipeline/mod.rs | 83 +++++----
src-tauri/src/audio/pipeline/output/linux.rs | 10 +-
src-tauri/src/audio/pipeline/output/macos.rs | 10 +-
src-tauri/src/audio/pipeline/output/mod.rs | 81 +++++++--
.../src/audio/pipeline/output/windows.rs | 10 +-
src-tauri/src/audio/resample.rs | 83 +++++++--
src-tauri/src/audio/virtual_device/linux.rs | 11 +-
src-tauri/src/audio/virtual_device/macos.rs | 5 +-
src-tauri/src/audio/virtual_device/mod.rs | 31 +++-
src/lib/modules/audio/methods.ts | 7 +-
src/lib/modules/audio/types.ts | 2 +
src/lib/modules/settings/stores.svelte.ts | 28 ++-
src/routes/settings/+page.svelte | 73 +++++++-
src/routes/virtual-devices/+page.svelte | 43 ++++-
26 files changed, 698 insertions(+), 125 deletions(-)
diff --git a/src-tauri/native/CATapCapture.swift b/src-tauri/native/CATapCapture.swift
index 2a25fd3e..8337fe53 100644
--- a/src-tauri/native/CATapCapture.swift
+++ b/src-tauri/native/CATapCapture.swift
@@ -227,6 +227,11 @@ private final class Tap {
var format: (rate: Double, channels: Int) {
lock.lock()
defer { lock.unlock() }
+ if aggregateID != 0,
+ let rate = readValue(aggregateID, kAudioDevicePropertyNominalSampleRate, Double(0)),
+ rate > 0 {
+ sampleRate = rate
+ }
return (sampleRate, tapChannels)
}
diff --git a/src-tauri/native/virtual_driver/Info.plist b/src-tauri/native/virtual_driver/Info.plist
index 1fbbbc5b..fdea67d4 100644
--- a/src-tauri/native/virtual_driver/Info.plist
+++ b/src-tauri/native/virtual_driver/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString2.0CFBundleVersion
- 4
+ 5CFPlugInFactories8C69103F-A4D0-44EA-97DC-A928A89637BF
diff --git a/src-tauri/native/virtual_driver/SplitAudioDriver.cpp b/src-tauri/native/virtual_driver/SplitAudioDriver.cpp
index ec321fce..29356bdc 100644
--- a/src-tauri/native/virtual_driver/SplitAudioDriver.cpp
+++ b/src-tauri/native/virtual_driver/SplitAudioDriver.cpp
@@ -76,7 +76,7 @@ class SplitIOHandler : public aspl::IORequestHandler,
}
};
-struct DeviceConfig { std::string id; std::string name; uint32_t channels; };
+struct DeviceConfig { std::string id; std::string name; uint32_t channels; uint32_t sampleRate; };
static std::string CFStr(CFStringRef s) {
if (!s) return {};
@@ -126,7 +126,14 @@ static std::vector ReadConfig() {
CFNumberGetValue(ch, kCFNumberIntType, &v);
if (v >= 1 && v <= 256) channels = (uint32_t)v;
}
- if (!id.empty() && !name.empty()) out.push_back({id, name, channels});
+ uint32_t sampleRate = 48000;
+ CFNumberRef sr = (CFNumberRef)CFDictionaryGetValue(d, CFSTR("sampleRate"));
+ if (sr && CFGetTypeID(sr) == CFNumberGetTypeID()) {
+ int v = 0;
+ CFNumberGetValue(sr, kCFNumberIntType, &v);
+ if (v >= 8000 && v <= 384000) sampleRate = (uint32_t)v;
+ }
+ if (!id.empty() && !name.empty()) out.push_back({id, name, channels, sampleRate});
}
CFRelease(plist);
return out;
@@ -136,6 +143,7 @@ struct DeviceEntry {
std::shared_ptr device;
std::shared_ptr handler;
uint32_t channels;
+ uint32_t sampleRate;
};
static std::shared_ptr gContext;
@@ -144,9 +152,9 @@ static std::map gDevices;
static std::mutex gDevicesMutex;
// libASPL streams default to 16-bit int; our IO is float.
-static AudioStreamBasicDescription FloatFormat(UInt32 channels) {
+static AudioStreamBasicDescription FloatFormat(UInt32 channels, Float64 sampleRate) {
AudioStreamBasicDescription f = {};
- f.mSampleRate = 48000;
+ f.mSampleRate = sampleRate;
f.mFormatID = kAudioFormatLinearPCM;
f.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagsNativeEndian |
kAudioFormatFlagIsPacked;
@@ -167,7 +175,7 @@ static DeviceEntry BuildDevice(const DeviceConfig& cfg) {
params.Manufacturer = "Splitwave";
params.DeviceUID = "com.horuse.splitwave.audio." + cfg.id;
params.ModelUID = "com.horuse.splitwave.audio.model";
- params.SampleRate = 48000;
+ params.SampleRate = cfg.sampleRate;
params.ChannelCount = cfg.channels;
params.EnableMixing = true;
@@ -177,15 +185,15 @@ static DeviceEntry BuildDevice(const DeviceConfig& cfg) {
aspl::StreamParameters outStream;
outStream.Direction = aspl::Direction::Output;
- outStream.Format = FloatFormat(params.ChannelCount);
+ outStream.Format = FloatFormat(params.ChannelCount, params.SampleRate);
device->AddStreamWithControlsAsync(outStream);
aspl::StreamParameters inStream;
inStream.Direction = aspl::Direction::Input;
- inStream.Format = FloatFormat(params.ChannelCount);
+ inStream.Format = FloatFormat(params.ChannelCount, params.SampleRate);
device->AddStreamWithControlsAsync(inStream);
- return {device, handler, cfg.channels};
+ return {device, handler, cfg.channels, cfg.sampleRate};
}
// Reconciles the live device set against the config file. A channel count change
@@ -200,6 +208,7 @@ static void SyncDevices() {
return nullptr;
}();
if (!cfg || cfg->channels != it->second.channels
+ || cfg->sampleRate != it->second.sampleRate
|| cfg->name != it->second.device->GetName()) {
gPlugin->RemoveDevice(it->second.device);
it = gDevices.erase(it);
diff --git a/src-tauri/src/audio/capture/macos_backend.rs b/src-tauri/src/audio/capture/macos_backend.rs
index be66d656..6fd6877a 100644
--- a/src-tauri/src/audio/capture/macos_backend.rs
+++ b/src-tauri/src/audio/capture/macos_backend.rs
@@ -111,10 +111,18 @@ impl Capture {
}
}
+ #[allow(dead_code)]
pub fn sample_rate(&self) -> u32 {
match self {
Capture::Tap(tap) => tap.sample_rate(),
Capture::Sck(_) => SCK_RATE,
}
}
+
+ pub fn tap_rate_probe(&self) -> Option {
+ match self {
+ Capture::Tap(tap) => Some(tap.rate_probe()),
+ Capture::Sck(_) => None,
+ }
+ }
}
diff --git a/src-tauri/src/audio/capture/macos_tap.rs b/src-tauri/src/audio/capture/macos_tap.rs
index 16128d64..35bb5411 100644
--- a/src-tauri/src/audio/capture/macos_tap.rs
+++ b/src-tauri/src/audio/capture/macos_tap.rs
@@ -96,6 +96,7 @@ pub struct TapCapture {
handle: *mut c_void,
state: Arc,
channels: u32,
+ #[allow(dead_code)]
sample_rate: u32,
}
@@ -224,9 +225,37 @@ impl TapCapture {
self.channels
}
+ #[allow(dead_code)]
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
+
+ pub fn rate_probe(&self) -> TapRateProbe {
+ TapRateProbe {
+ handle: self.handle,
+ }
+ }
+}
+
+/// Non-owning format probe used only by the normalizer thread. `TapCapture`
+/// outlives that thread (NormalizedInput joins it before dropping capture), so
+/// the native handle remains valid for every query.
+#[derive(Clone, Copy)]
+pub struct TapRateProbe {
+ handle: *mut c_void,
+}
+
+unsafe impl Send for TapRateProbe {}
+
+impl TapRateProbe {
+ pub fn sample_rate(&self) -> Option {
+ let mut sample_rate = 0.0f64;
+ let mut channels = 0i32;
+ let rc = ResultCode::from_raw(unsafe {
+ ba_tap_format(self.handle, &mut sample_rate, &mut channels)
+ });
+ (rc == ResultCode::Ok && sample_rate > 0.0 && channels > 0).then_some(sample_rate as u32)
+ }
}
impl Drop for TapCapture {
diff --git a/src-tauri/src/audio/capture/mod.rs b/src-tauri/src/audio/capture/mod.rs
index 2f9d1832..78a45619 100644
--- a/src-tauri/src/audio/capture/mod.rs
+++ b/src-tauri/src/audio/capture/mod.rs
@@ -3,7 +3,7 @@ mod macos;
#[cfg(target_os = "macos")]
mod macos_backend;
#[cfg(target_os = "macos")]
-mod macos_tap;
+pub(crate) mod macos_tap;
#[cfg(target_os = "macos")]
pub use macos_backend::{capture_rate, uses_taps, Capture};
diff --git a/src-tauri/src/audio/clock.rs b/src-tauri/src/audio/clock.rs
index 96b80efc..9fac344a 100644
--- a/src-tauri/src/audio/clock.rs
+++ b/src-tauri/src/audio/clock.rs
@@ -1,6 +1,6 @@
//! Pacing source for the DSP worker.
-use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
@@ -99,8 +99,9 @@ const FILL_CLOCK_MAX_SLEEP: Duration = Duration::from_millis(5);
/// target, so the worker produces the next block immediately and never loses
/// the notion of "how far behind" the way a deadline reset would.
pub struct DeviceFillClock {
- sample_rate: u32,
- block_frames: usize,
+ pipeline_sample_rate: u32,
+ device_sample_rate: Arc,
+ engine_block_frames: usize,
level: Arc,
/// Fill target, sized to the device's own buffer by the audio callback
/// (see `speaker_ring`). Read here every tick so the ring always bridges
@@ -113,14 +114,16 @@ pub struct DeviceFillClock {
impl DeviceFillClock {
pub fn new(
- sample_rate: u32,
- block_frames: usize,
+ pipeline_sample_rate: u32,
+ device_sample_rate: Arc,
+ engine_block_frames: usize,
level: Arc,
target: Arc,
) -> Self {
Self {
- sample_rate,
- block_frames,
+ pipeline_sample_rate,
+ device_sample_rate,
+ engine_block_frames,
level,
target,
primed: false,
@@ -136,24 +139,26 @@ impl ClockSource for DeviceFillClock {
}
let target_frames = self.target.load(Ordering::Relaxed).max(0) as usize;
let queued = self.level.load(Ordering::Relaxed).max(0) as usize;
- if queued + self.block_frames <= target_frames {
+ let dev_sr = self.device_sample_rate.load(Ordering::Relaxed).max(1);
+ 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 < self.block_frames {
+ if self.primed && queued < block_frames {
health::bump(&health::CLOCK_LATE_BLOCKS, 1);
}
return true;
}
self.primed = true;
- let overshoot = queued + self.block_frames - target_frames;
- let drain = Duration::from_nanos(
- (overshoot as u64 * 1_000_000_000) / self.sample_rate.max(1) as u64,
- );
+ 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));
}
}
fn sample_rate(&self) -> u32 {
- self.sample_rate
+ self.device_sample_rate.load(Ordering::Relaxed)
}
}
diff --git a/src-tauri/src/audio/graph.rs b/src-tauri/src/audio/graph.rs
index 2c860186..21793fbd 100644
--- a/src-tauri/src/audio/graph.rs
+++ b/src-tauri/src/audio/graph.rs
@@ -10,6 +10,8 @@ use crate::error::{AppError, AppResult};
pub struct GraphSpec {
pub nodes: Vec,
pub edges: Vec,
+ #[serde(default)]
+ pub sample_rate: Option,
}
#[derive(Debug, Deserialize)]
@@ -708,6 +710,7 @@ pub struct ValidGraph {
pub outputs: Vec,
pub effects: Vec,
pub edges: Vec,
+ pub sample_rate: u32,
}
/// One node of the expanded graph. A dual-role UI node appears once per role it
@@ -893,11 +896,22 @@ impl GraphSpec {
})
.collect();
+ let sample_rate = match self.sample_rate {
+ Some(sr) if !(8_000..=384_000).contains(&sr) => {
+ return Err(AppError::Validation(format!(
+ "pipeline sample rate {sr} out of bounds (8000..=384000)"
+ )));
+ }
+ Some(sr) => sr,
+ None => 48_000,
+ };
+
Ok(ValidGraph {
inputs,
outputs,
effects,
edges,
+ sample_rate,
})
}
}
@@ -1329,6 +1343,7 @@ mod tests {
#[test]
fn send_only_collaborator_is_an_output() {
let g = GraphSpec {
+ sample_rate: None,
nodes: vec![mic("m"), collab("w")],
edges: vec![edge("e", "m", None, "w", Some("ch1"))],
};
@@ -1351,6 +1366,7 @@ mod tests {
#[test]
fn recv_only_collaborator_is_an_input() {
let g = GraphSpec {
+ sample_rate: None,
nodes: vec![collab("w"), speaker("s")],
edges: vec![edge("e", "w", Some("peer:p:0"), "s", None)],
};
@@ -1371,6 +1387,7 @@ mod tests {
#[test]
fn duplex_collaborator_is_both() {
let g = GraphSpec {
+ sample_rate: None,
nodes: vec![mic("m"), collab("w"), speaker("s")],
edges: vec![
edge("e1", "m", None, "w", Some("ch1")),
@@ -1387,6 +1404,7 @@ mod tests {
#[test]
fn unwired_collaborator_is_not_a_routing_error() {
let g = GraphSpec {
+ sample_rate: None,
nodes: vec![collab("w")],
edges: vec![],
};
@@ -1400,6 +1418,7 @@ mod tests {
#[test]
fn unrouted_output_is_valid_and_streams_silence() {
let g = GraphSpec {
+ sample_rate: None,
nodes: vec![speaker("s")],
edges: vec![],
};
@@ -1416,6 +1435,7 @@ mod tests {
}
let g = GraphSpec {
+ sample_rate: None,
nodes: vec![gain("g"), speaker("s")],
edges: vec![edge("e", "g", None, "s", None)],
};
@@ -1429,4 +1449,40 @@ mod tests {
assert_eq!(v.outputs[0].id, "s");
assert_eq!(v.edges.len(), 1);
}
+
+ #[test]
+ fn default_sample_rate_is_48000() {
+ let g = GraphSpec {
+ sample_rate: None,
+ nodes: vec![speaker("s")],
+ edges: vec![],
+ };
+ let v = g.validate().expect("graph valid");
+ assert_eq!(v.sample_rate, 48_000);
+ }
+
+ #[test]
+ fn custom_sample_rate_is_preserved() {
+ for sr in [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 384_000] {
+ let g = GraphSpec {
+ sample_rate: Some(sr),
+ nodes: vec![speaker("s")],
+ edges: vec![],
+ };
+ let v = g.validate().expect("graph valid");
+ assert_eq!(v.sample_rate, sr);
+ }
+ }
+
+ #[test]
+ fn out_of_bounds_sample_rate_is_rejected() {
+ for sr in [0, 4_000, 7_999, 384_001, 1_000_000] {
+ let g = GraphSpec {
+ sample_rate: Some(sr),
+ nodes: vec![speaker("s")],
+ edges: vec![],
+ };
+ assert!(g.validate().is_err());
+ }
+ }
}
diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs
index 78cc90d3..962b28b1 100644
--- a/src-tauri/src/audio/pipeline/dag.rs
+++ b/src-tauri/src/audio/pipeline/dag.rs
@@ -1883,6 +1883,7 @@ pub(super) fn reachable_backward(output_id: &str, valid: &ValidGraph) -> HashSet
seen
}
+#[allow(dead_code)]
pub(super) fn inputs_feeding_output<'a>(output_id: &str, valid: &'a ValidGraph) -> Vec<&'a str> {
let reachable = reachable_backward(output_id, valid);
valid
diff --git a/src-tauri/src/audio/pipeline/input/macos.rs b/src-tauri/src/audio/pipeline/input/macos.rs
index 68f9fedf..6fa48960 100644
--- a/src-tauri/src/audio/pipeline/input/macos.rs
+++ b/src-tauri/src/audio/pipeline/input/macos.rs
@@ -22,14 +22,10 @@ use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput};
/// mistimed audio.
const CAPTURE_CHANNELS: u32 = 2;
-fn check_capture_format(
- capture: &crate::audio::capture::Capture,
- expected_rate: u32,
-) -> AppResult<()> {
- if capture.sample_rate() != expected_rate || capture.channels() != CAPTURE_CHANNELS {
+fn check_capture_format(capture: &crate::audio::capture::Capture) -> AppResult<()> {
+ if capture.channels() != CAPTURE_CHANNELS {
return Err(AppError::Stream(format!(
- "capture format changed while starting: expected {expected_rate} Hz / {CAPTURE_CHANNELS} ch, got {} Hz / {} ch",
- capture.sample_rate(),
+ "capture channel layout changed while starting: expected {CAPTURE_CHANNELS} ch, got {} ch",
capture.channels()
)));
}
@@ -118,7 +114,7 @@ pub(in crate::audio::pipeline) fn start_input_stream(
sample_rate,
bridge,
)?;
- check_capture_format(&capture, sample_rate)?;
+ check_capture_format(&capture)?;
Ok(InputHandle::Capture(capture))
}
ResolvedInput::AppAudio {
@@ -127,7 +123,7 @@ pub(in crate::audio::pipeline) fn start_input_stream(
} => {
let capture =
crate::audio::capture::Capture::start_app(&bundle_id, sample_rate, bridge)?;
- check_capture_format(&capture, sample_rate)?;
+ check_capture_format(&capture)?;
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 e45a04ee..bac23e76 100644
--- a/src-tauri/src/audio/pipeline/input/mod.rs
+++ b/src-tauri/src/audio/pipeline/input/mod.rs
@@ -1,16 +1,22 @@
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
+use std::thread::{self, JoinHandle};
+use std::time::Duration;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use cpal::traits::StreamTrait;
+use rtrb::RingBuffer;
use tauri::AppHandle;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use tracing::warn;
-use crate::audio::input_bridge::BroadcastRx;
-use crate::error::AppResult;
+use crate::audio::effects::{update_meter, MeterHandle};
+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::file_reader::{probe_audio_file, start_audio_file_reader, AudioFileReader};
#[cfg(target_os = "macos")]
@@ -26,7 +32,8 @@ mod windows;
#[cfg(target_os = "windows")]
use windows as platform;
-pub(super) use platform::{resolve_input, start_input_stream};
+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.
@@ -41,6 +48,41 @@ pub(super) enum InputHandle {
Cpal(cpal::Stream),
Capture(crate::audio::capture::Capture),
AudioFile(AudioFileReader),
+ Normalized(NormalizedInput),
+}
+
+pub(super) struct NormalizedInput {
+ _input: Box,
+ stop: Arc,
+ join: Option>,
+}
+
+impl Drop for NormalizedInput {
+ fn drop(&mut self) {
+ self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
+ if let Some(join) = self.join.take() {
+ let _ = join.join();
+ }
+ }
+}
+
+impl InputHandle {
+ pub fn audio_file_reader(&self) -> Option<&AudioFileReader> {
+ match self {
+ InputHandle::AudioFile(r) => Some(r),
+ InputHandle::Normalized(n) => n._input.audio_file_reader(),
+ _ => None,
+ }
+ }
+
+ #[cfg(target_os = "macos")]
+ fn tap_rate_probe(&self) -> Option {
+ match self {
+ InputHandle::Capture(capture) => capture.tap_rate_probe(),
+ InputHandle::Normalized(n) => n._input.tap_rate_probe(),
+ _ => None,
+ }
+ }
}
// cpal's coreaudio backend never stops a non-default device's AudioUnit just
@@ -148,3 +190,119 @@ pub(super) fn start_audio_file(
)?;
Ok(InputHandle::AudioFile(reader))
}
+
+/// Capture callbacks only enqueue native-rate samples. A dedicated worker
+/// normalizes each input once before the dynamic fan-out reaches the DSP graph.
+/// If `sample_rate == target_sample_rate`, NO RESAMPLING is performed (resampler is None),
+/// providing bit-transparent 1:1 passthrough.
+pub(super) fn start_input_stream(
+ node_id: &str,
+ resolved: ResolvedInput,
+ bridge: BroadcastRx,
+ target_sample_rate: u32,
+ paused: Option>,
+ meter: Option,
+ app: &AppHandle,
+) -> AppResult {
+ 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));
+ 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();
+ let stop = Arc::new(AtomicBool::new(false));
+ let stop_thread = stop.clone();
+ let label = node_id.to_string();
+ let join = thread::Builder::new()
+ .name(format!("normalize:{label}"))
+ .spawn(move || {
+ let mut bridge = bridge;
+ let mut input_buf = vec![0.0; RESAMPLE_CHUNK * channels];
+ let mut native_rate = sample_rate;
+ let mut resampler = if native_rate == target_sample_rate {
+ None
+ } else {
+ match MultiResampler::new(native_rate, target_sample_rate, RESAMPLE_CHUNK, channels)
+ {
+ Ok(resampler) => Some(resampler),
+ Err(_) => return,
+ }
+ };
+ let mut output_buf = Vec::with_capacity(
+ resampler
+ .as_ref()
+ .map(|r| r.out_max() * channels)
+ .unwrap_or(input_buf.len()),
+ );
+ 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()) {
+ if rate == native_rate {
+ // Nothing to do; avoid perturbing the sinc state.
+ } else {
+ // Raw samples on either side of a device-rate transition
+ // cannot share a sinc state. Drop only this input's raw
+ // backlog, then rebuild the non-RT normalizer; the tap and
+ // every engine/output worker keep running.
+ let pending = raw_consumer.slots();
+ if pending > 0 {
+ if let Ok(chunk) = raw_consumer.read_chunk(pending) {
+ chunk.commit_all();
+ }
+ }
+ native_rate = rate;
+ resampler = if rate == target_sample_rate {
+ None
+ } else {
+ MultiResampler::new(rate, target_sample_rate, RESAMPLE_CHUNK, channels)
+ .ok()
+ };
+ output_buf = Vec::with_capacity(
+ resampler
+ .as_ref()
+ .map(|r| r.out_max() * channels)
+ .unwrap_or(input_buf.len()),
+ );
+ }
+ }
+ if raw_consumer.slots() < input_buf.len() {
+ thread::sleep(Duration::from_millis(1));
+ continue;
+ }
+ let Ok(chunk) = raw_consumer.read_chunk(input_buf.len()) else {
+ continue;
+ };
+ let (first, second) = chunk.as_slices();
+ let n = first.len();
+ input_buf[..n].copy_from_slice(first);
+ input_buf[n..].copy_from_slice(second);
+ chunk.commit_all();
+ let normalized = if let Some(resampler) = &mut resampler {
+ output_buf.clear();
+ if resampler
+ .process_chunk(&input_buf, &mut output_buf)
+ .is_err()
+ {
+ break;
+ }
+ output_buf.as_slice()
+ } else {
+ input_buf.as_slice()
+ };
+ if let Some(meter) = &meter {
+ update_meter(meter, normalized, channels);
+ }
+ bridge.broadcast(normalized);
+ }
+ })
+ .map_err(|e| AppError::Stream(format!("spawn input normalizer: {e}")))?;
+ Ok(InputHandle::Normalized(NormalizedInput {
+ _input: Box::new(input),
+ stop,
+ join: Some(join),
+ }))
+}
diff --git a/src-tauri/src/audio/pipeline/meter.rs b/src-tauri/src/audio/pipeline/meter.rs
index a3534d56..7e814703 100644
--- a/src-tauri/src/audio/pipeline/meter.rs
+++ b/src-tauri/src/audio/pipeline/meter.rs
@@ -250,8 +250,11 @@ pub(super) fn spawn_xrun_thread(
(requested_delta, read_delta, callbacks_delta)
});
let io_off_rate = io.is_some_and(|(requested_delta, _, callbacks_delta)| {
- let expected_samples =
- o.sample_rate as f64 * o.channels as f64 * elapsed_secs;
+ let expected_samples = o.io.as_ref().map_or(o.sample_rate, |speaker| {
+ speaker.sample_rate.load(Ordering::Relaxed)
+ }) as f64
+ * o.channels as f64
+ * elapsed_secs;
// The device's own buffer size, measured rather than
// assumed: cpal opens with `BufferSize::Default`.
let quantum = if callbacks_delta > 0 {
diff --git a/src-tauri/src/audio/pipeline/mod.rs b/src-tauri/src/audio/pipeline/mod.rs
index 8847cb51..f68bd403 100644
--- a/src-tauri/src/audio/pipeline/mod.rs
+++ b/src-tauri/src/audio/pipeline/mod.rs
@@ -41,10 +41,7 @@ pub(crate) use output::RtThread;
mod sig;
mod worker;
-use dag::{
- build_output_graph, inputs_feeding_output, OutputGraph, OutputMeta, SourceMeta,
- RING_CAPACITY_FRAMES,
-};
+use dag::{build_output_graph, OutputGraph, OutputMeta, SourceMeta, RING_CAPACITY_FRAMES};
use input::{resolve_input, start_input_stream, InputHandle, ResolvedInput};
use meter::{spawn_meter_thread, spawn_xrun_thread, MeterTickThread, XrunTickThread};
use output::{
@@ -236,7 +233,7 @@ impl ActivePipeline {
/// no-op when the node isn't an AudioFile or the pipeline is stopped.
pub fn seek_audio_file(&self, node_id: &str, frame: i64) {
if let Some(state) = self.inputs.get(node_id) {
- if let InputHandle::AudioFile(reader) = &state._handle {
+ if let Some(reader) = state._handle.audio_file_reader() {
reader.seek_to().store(frame.max(0), Ordering::SeqCst);
}
if let Some(d) = &state.drain {
@@ -250,7 +247,7 @@ impl ActivePipeline {
/// stopped.
pub fn set_audio_file_loop(&self, node_id: &str, enabled: bool) {
if let Some(state) = self.inputs.get(node_id) {
- if let InputHandle::AudioFile(reader) = &state._handle {
+ if let Some(reader) = state._handle.audio_file_reader() {
reader.loop_enabled().store(enabled, Ordering::SeqCst);
}
}
@@ -369,12 +366,20 @@ impl ActivePipeline {
GraphSwap,
Drop,
}
+ let sample_rate_changed = self
+ .current
+ .as_ref()
+ .map_or(false, |c| c.sample_rate != new_graph.sample_rate);
let mut cats: HashMap = HashMap::new();
for (id, new_sig) in &new_sigs {
- let cat = match self.current_output_sig(id) {
- Some(old) if old == new_sig => Cat::Full,
- Some(old) if old.output_spec == new_sig.output_spec => Cat::GraphSwap,
- _ => Cat::Drop,
+ let cat = if sample_rate_changed {
+ Cat::Drop
+ } else {
+ match self.current_output_sig(id) {
+ Some(old) if old == new_sig => Cat::Full,
+ Some(old) if old.output_spec == new_sig.output_spec => Cat::GraphSwap,
+ _ => Cat::Drop,
+ }
};
cats.insert(id.clone(), cat);
}
@@ -468,6 +473,9 @@ impl ActivePipeline {
.inputs
.keys()
.filter(|id| {
+ if sample_rate_changed {
+ return true;
+ }
match (
old_input_specs.get(id.as_str()),
new_input_specs.get(id.as_str()),
@@ -494,6 +502,9 @@ impl ActivePipeline {
let Some(current) = &self.current else {
return false;
};
+ if current.sample_rate != graph.sample_rate {
+ return false;
+ }
let cur_inputs: HashMap<&str, &InputSpec> = current
.inputs
.iter()
@@ -589,6 +600,7 @@ impl ActivePipeline {
/// state -- the caller is responsible for calling `teardown`.
fn apply_full(&mut self, graph: &ValidGraph, app: AppHandle) -> AppResult<()> {
let monitor_mode = monitor_mode(graph);
+ let pipeline_sr = graph.sample_rate;
let mut input_native_sr: HashMap = HashMap::new();
let mut input_native_channels: HashMap = HashMap::new();
@@ -607,7 +619,7 @@ impl ActivePipeline {
input_native_channels.insert(inp.id.clone(), state.channels);
} else {
let resolved = resolve_input(inp)?;
- input_native_sr.insert(inp.id.clone(), resolved.sample_rate());
+ input_native_sr.insert(inp.id.clone(), pipeline_sr);
input_native_channels.insert(inp.id.clone(), resolved.native_channels());
input_runtime.insert(inp.id.clone(), resolved);
}
@@ -730,20 +742,11 @@ impl ActivePipeline {
OutputSpec::FileRecording {
format: RecordingFormat::Aac { .. },
..
- } => {
- let max_in = inputs_feeding_output(out.id.as_str(), graph)
- .into_iter()
- .filter_map(|input_id| input_native_sr.get(input_id).copied())
- .max();
- match max_in {
- Some(sr @ (32_000 | 44_100 | 48_000)) => Some(sr),
- _ => Some(48_000),
- }
- }
- OutputSpec::FileRecording { .. } => inputs_feeding_output(out.id.as_str(), graph)
- .into_iter()
- .filter_map(|input_id| input_native_sr.get(input_id).copied())
- .max(),
+ } => match pipeline_sr {
+ sr @ (32_000 | 44_100 | 48_000) => Some(sr),
+ _ => Some(48_000),
+ },
+ OutputSpec::FileRecording { .. } => Some(pipeline_sr),
_ => None,
};
let resolved = resolve_output(out, file_sr_hint)?;
@@ -770,10 +773,14 @@ impl ActivePipeline {
if !output_runtime.contains_key(&out.id) {
continue;
}
- let output_sr = output_runtime
- .get(&out.id)
- .map(|o| o.sample_rate())
- .ok_or_else(|| AppError::Validation("missing output runtime".into()))?;
+ let output_sr = match &out.spec {
+ OutputSpec::Speaker { .. } => pipeline_sr,
+ OutputSpec::FileRecording { .. } => output_runtime
+ .get(&out.id)
+ .map(|o| o.sample_rate())
+ .unwrap_or(pipeline_sr),
+ OutputSpec::NetSender { .. } | OutputSpec::WebRtcSend { .. } => pipeline_sr,
+ };
let mut my_pairs: Vec<(String, Producer)> = Vec::new();
let cut_leaves = pending_cuts.remove(&out.id).unwrap_or_default();
let mut built = build_output_graph(
@@ -847,7 +854,7 @@ impl ActivePipeline {
let needs_build =
monitor_forced || self.monitor.as_ref().map_or(true, |m| m.sig != new_sig);
if needs_build {
- let monitor_sr = input_native_sr.values().copied().max().unwrap_or(48_000);
+ let monitor_sr = pipeline_sr;
let mut my_pairs: Vec<(String, Producer)> = Vec::new();
// Realtime: the monitor consumes live sources forever, so it must
// drop backlog like any other live path. Without this its ring
@@ -938,7 +945,7 @@ impl ActivePipeline {
let resolved = input_runtime.remove(&input_id).ok_or_else(|| {
AppError::Validation(format!("input runtime missing for {input_id}"))
})?;
- let sample_rate = resolved.sample_rate();
+ let sample_rate = pipeline_sr;
let channels = resolved.native_channels();
let meter = new_input_meters
.remove(&input_id)
@@ -957,8 +964,15 @@ impl ActivePipeline {
captured.push((input_id.clone(), out_id.clone(), capture));
bridges_by_output.entry(out_id).or_default().push(slot);
}
- let handle =
- start_input_stream(&input_id, resolved, bridge_rx, paused.clone(), None, &app)?;
+ let handle = start_input_stream(
+ &input_id,
+ resolved,
+ bridge_rx,
+ pipeline_sr,
+ paused.clone(),
+ None,
+ &app,
+ )?;
self.inputs.insert(
input_id,
InputState {
@@ -997,7 +1011,7 @@ impl ActivePipeline {
if matches!(resolved, ResolvedInput::AudioFile { .. }) {
continue;
}
- let sample_rate = resolved.sample_rate();
+ let sample_rate = pipeline_sr;
let channels = resolved.native_channels();
let meter = new_input_meters
.remove(&input_id)
@@ -1013,6 +1027,7 @@ impl ActivePipeline {
&input_id,
resolved,
bridge_rx,
+ pipeline_sr,
paused.clone(),
Some(meter),
&app,
diff --git a/src-tauri/src/audio/pipeline/output/linux.rs b/src-tauri/src/audio/pipeline/output/linux.rs
index c77fcca7..8d0b1824 100644
--- a/src-tauri/src/audio/pipeline/output/linux.rs
+++ b/src-tauri/src/audio/pipeline/output/linux.rs
@@ -41,8 +41,12 @@ pub(in crate::audio::pipeline) fn start_speaker_stream(
info!(node = %spec.node_id, sample_rate = spec.sample_rate, "opening speaker stream (PipeWire)");
let dead = Arc::new(AtomicBool::new(false));
- let (producer, mut fill, level, target, io) =
- speaker_ring(spec.out_channels, graph.latency_frames());
+ let (producer, mut fill, level, target, io) = speaker_ring(
+ spec.out_channels,
+ graph.sample_rate(),
+ spec.sample_rate,
+ graph.latency_frames(),
+ );
let fill_pw = move |out: &mut [f32]| {
fill(out, 0);
out.len()
@@ -53,7 +57,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream(
producer,
level,
target,
- spec.sample_rate,
+ io.sample_rate.clone(),
spec.out_channels,
graph,
meter,
diff --git a/src-tauri/src/audio/pipeline/output/macos.rs b/src-tauri/src/audio/pipeline/output/macos.rs
index aab2f84b..f646b2cd 100644
--- a/src-tauri/src/audio/pipeline/output/macos.rs
+++ b/src-tauri/src/audio/pipeline/output/macos.rs
@@ -115,8 +115,12 @@ pub(in crate::audio::pipeline) fn start_speaker_stream(
let mut io_holder: Option = None;
let mut stream_holder: Option = None;
for attempt in 1..=SPEAKER_MAX_ATTEMPTS {
- let (producer, fill, level, target, io) =
- speaker_ring(spec.out_channels, graph.latency_frames());
+ let (producer, fill, level, target, io) = speaker_ring(
+ spec.out_channels,
+ graph.sample_rate(),
+ spec.sample_rate,
+ graph.latency_frames(),
+ );
let app_err = app.clone();
let dead_cb = dead.clone();
let node_id_cb = node_id.to_string();
@@ -168,7 +172,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream(
producer,
level,
target,
- spec.sample_rate,
+ io.sample_rate.clone(),
spec.out_channels,
graph,
meter,
diff --git a/src-tauri/src/audio/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs
index 9ceded0c..c7c70602 100644
--- a/src-tauri/src/audio/pipeline/output/mod.rs
+++ b/src-tauri/src/audio/pipeline/output/mod.rs
@@ -1,5 +1,5 @@
use std::path::PathBuf;
-use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
@@ -16,10 +16,11 @@ 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::{OutputSpec, RecordingFormat, RecordingMode, ValidOutput};
+use crate::audio::resample::MultiResampler;
use crate::audio::streams;
use crate::error::{AppError, AppResult};
-use super::dag::{OutputGraph, DSP_BLOCK_FRAMES};
+use super::dag::{OutputGraph, DSP_BLOCK_FRAMES, RESAMPLE_CHUNK};
use super::worker::{dsp_worker, WorkerCtrl};
#[cfg(target_os = "macos")]
@@ -56,6 +57,12 @@ pub(super) const SPEAKER_TARGET_FILL_BLOCKS: usize = 3;
// sits exactly empty when the next callback lands.
const SPEAKER_TARGET_MARGIN_BLOCKS: usize = 2;
+#[inline]
+fn pipeline_frames_to_device_frames(frames: usize, pipeline_rate: u32, device_rate: u32) -> usize {
+ ((frames as u64 * device_rate.max(1) as u64 + pipeline_rate.max(1) as u64 / 2)
+ / pipeline_rate.max(1) as u64) as usize
+}
+
pub(super) enum ResolvedOutput {
Speaker(SpeakerResolved),
File {
@@ -201,6 +208,9 @@ impl Drop for RecorderWorker {
// `Ordering::Relaxed`, no allocation, no other sync.
#[derive(Clone)]
pub(super) struct SpeakerIo {
+ /// Native clock rate of the physical output stream. This differs from the
+ /// graph's pipeline rate when the output resampler is active.
+ pub sample_rate: Arc,
/// Samples cpal's `fill` was asked for (`out.len()`), summed across callbacks.
pub requested: Arc,
/// Samples actually popped off the ring (`bulk_pop`'s return), summed across callbacks.
@@ -217,8 +227,9 @@ pub(super) struct SpeakerIo {
}
impl SpeakerIo {
- fn new(target_frames: Arc, graph_latency_frames: usize) -> Self {
+ fn new(sample_rate: u32, target_frames: Arc, graph_latency_frames: usize) -> Self {
Self {
+ sample_rate: Arc::new(AtomicU32::new(sample_rate)),
requested: Arc::new(AtomicU64::new(0)),
read: Arc::new(AtomicU64::new(0)),
callbacks: Arc::new(AtomicU64::new(0)),
@@ -256,6 +267,8 @@ impl Drop for StreamGuard {
// worker's clock steers toward -- one ring shape for all platforms.
pub(super) fn speaker_ring(
out_channels: usize,
+ pipeline_rate: u32,
+ device_rate: u32,
graph_latency_frames: usize,
) -> (
Producer,
@@ -268,11 +281,13 @@ pub(super) fn speaker_ring(
RingBuffer::::new(SPEAKER_RING_CAPACITY_FRAMES * out_channels);
let level = Arc::new(AtomicI64::new(0));
let level_cb = level.clone();
- let target = Arc::new(AtomicI64::new(
- (SPEAKER_TARGET_FILL_BLOCKS * DSP_BLOCK_FRAMES) as i64,
- ));
+ let target = Arc::new(AtomicI64::new(pipeline_frames_to_device_frames(
+ SPEAKER_TARGET_FILL_BLOCKS * DSP_BLOCK_FRAMES,
+ pipeline_rate,
+ device_rate,
+ ) as i64));
let target_cb = target.clone();
- let io = SpeakerIo::new(target.clone(), graph_latency_frames);
+ 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 read = streams::bulk_pop(&mut consumer, out);
@@ -282,8 +297,16 @@ pub(super) fn speaker_ring(
// blocks and the floor holds; a large-buffer device (PipeWire handing
// out ~250 ms buffers) grows the target and runs at that latency instead
// of underrunning at a fraction of real time.
- let min = SPEAKER_TARGET_FILL_BLOCKS * DSP_BLOCK_FRAMES;
- let margin = SPEAKER_TARGET_MARGIN_BLOCKS * DSP_BLOCK_FRAMES;
+ let min = pipeline_frames_to_device_frames(
+ SPEAKER_TARGET_FILL_BLOCKS * DSP_BLOCK_FRAMES,
+ pipeline_rate,
+ device_rate,
+ );
+ let margin = pipeline_frames_to_device_frames(
+ SPEAKER_TARGET_MARGIN_BLOCKS * DSP_BLOCK_FRAMES,
+ pipeline_rate,
+ device_rate,
+ );
let dev_frames = out.len() / out_channels;
let max = SPEAKER_RING_CAPACITY_FRAMES
.saturating_sub(dev_frames + margin)
@@ -336,29 +359,59 @@ pub(super) fn spawn_speaker_worker(
mut producer: Producer,
level: Arc,
target: Arc,
- sample_rate: u32,
+ device_sample_rate: Arc,
channels: usize,
graph: OutputGraph,
meter: MeterHandle,
) -> AppResult<(SpeakerWorker, WorkerCtrl)> {
+ let pipeline_rate = graph.sample_rate();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let (worker, ctrl) = dsp_worker(graph);
let clock: Box = Box::new(DeviceFillClock::new(
- sample_rate,
+ pipeline_rate,
+ device_sample_rate.clone(),
DSP_BLOCK_FRAMES,
level.clone(),
target,
));
+ let initial_device_rate = device_sample_rate.load(Ordering::Relaxed);
+ let mut resampler = if initial_device_rate == pipeline_rate {
+ None
+ } else {
+ Some(MultiResampler::new(
+ pipeline_rate,
+ initial_device_rate,
+ RESAMPLE_CHUNK,
+ channels,
+ )?)
+ };
+ let mut resampled = vec![
+ 0.0_f32;
+ resampler
+ .as_ref()
+ .map(|r| r.out_max() * channels * (DSP_BLOCK_FRAMES / RESAMPLE_CHUNK))
+ .unwrap_or(DSP_BLOCK_FRAMES * channels)
+ ];
let join = thread::Builder::new()
- .name(format!("speaker:{sample_rate}"))
+ .name(format!("speaker:{initial_device_rate}"))
.spawn(move || {
- let _rt = RtThread::promote("speaker", sample_rate);
+ 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 mut written = 0;
+ for chunk in block.chunks_exact(RESAMPLE_CHUNK * channels) {
+ written +=
+ resampler.process_chunk_into(chunk, &mut resampled[written..])?;
+ }
+ &resampled[..written]
+ } else {
+ block
+ };
let written = streams::bulk_push_counted(
&mut producer,
- block,
+ device_block,
&crate::audio::health::SPEAKER_RING_OVERRUN_SAMPLES,
);
level.fetch_add((written / channels) as i64, Ordering::Relaxed);
diff --git a/src-tauri/src/audio/pipeline/output/windows.rs b/src-tauri/src/audio/pipeline/output/windows.rs
index cde36a13..e018b017 100644
--- a/src-tauri/src/audio/pipeline/output/windows.rs
+++ b/src-tauri/src/audio/pipeline/output/windows.rs
@@ -73,8 +73,12 @@ pub(in crate::audio::pipeline) fn start_speaker_stream(
let dead = Arc::new(AtomicBool::new(false));
- let (producer, fill, level, target, io) =
- speaker_ring(spec.out_channels, graph.latency_frames());
+ let (producer, fill, level, target, io) = speaker_ring(
+ spec.out_channels,
+ graph.sample_rate(),
+ spec.sample_rate,
+ graph.latency_frames(),
+ );
let app_err = app.clone();
let dead_cb = dead.clone();
let node_id_cb = node_id.to_string();
@@ -103,7 +107,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream(
producer,
level,
target,
- spec.sample_rate,
+ io.sample_rate.clone(),
spec.out_channels,
graph,
meter,
diff --git a/src-tauri/src/audio/resample.rs b/src-tauri/src/audio/resample.rs
index 951803a1..d71cde56 100644
--- a/src-tauri/src/audio/resample.rs
+++ b/src-tauri/src/audio/resample.rs
@@ -111,7 +111,7 @@ impl MultiResampler {
let out_max = inner.output_frames_max();
let in_planar = vec![vec![0.0_f32; chunk_size]; channels];
- let out_planar = vec![Vec::with_capacity(out_max); channels];
+ let out_planar = vec![vec![0.0_f32; out_max]; channels];
Ok(Self {
inner,
@@ -130,8 +130,16 @@ impl MultiResampler {
self.out_max
}
- pub fn process_chunk(&mut self, interleaved_in: &[f32], dst: &mut Vec) -> AppResult<()> {
+ /// Resample one fixed input chunk into a caller-owned, preallocated
+ /// interleaved buffer. Returns the number of written samples. This is the
+ /// RT-safe form used by workers: it never grows a `Vec` in the DSP path.
+ pub fn process_chunk_into(
+ &mut self,
+ interleaved_in: &[f32],
+ output: &mut [f32],
+ ) -> AppResult {
debug_assert_eq!(interleaved_in.len(), self.chunk_in * self.channels);
+ debug_assert!(output.len() >= self.out_max * self.channels);
for (i, frame) in interleaved_in.chunks_exact(self.channels).enumerate() {
for c in 0..self.channels {
@@ -139,25 +147,76 @@ impl MultiResampler {
}
}
- // Rubato 0.16 writes INTO existing slots (`AsMut<[T]>`) and uses
- // `len()` to know the available space. Reserving capacity isn't enough
- // -- we must resize so `len >= out_max`.
- for v in &mut self.out_planar {
- v.resize(self.out_max, 0.0);
- }
-
let (_in_used, produced) = self
.inner
.process_into_buffer(&self.in_planar, &mut self.out_planar, None)
.map_err(|e| AppError::Stream(format!("resampler process: {e}")))?;
- // `produced` is the number of valid output frames per channel; the rest
- // of out_planar may be unwritten zero-padding.
for i in 0..produced {
for c in 0..self.channels {
- dst.push(self.out_planar[c][i]);
+ output[i * self.channels + c] = self.out_planar[c][i];
}
}
+ Ok(produced * self.channels)
+ }
+
+ pub fn process_chunk(&mut self, interleaved_in: &[f32], dst: &mut Vec) -> AppResult<()> {
+ let start = dst.len();
+ dst.resize(start + self.out_max * self.channels, 0.0);
+ let written = self.process_chunk_into(interleaved_in, &mut dst[start..])?;
+ dst.truncate(start + written);
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::MultiResampler;
+
+ fn produced_frames(from_rate: u32, to_rate: u32) -> usize {
+ const CHANNELS: usize = 2;
+ const CHUNKS: usize = 32;
+ let mut resampler = MultiResampler::new(from_rate, to_rate, 256, CHANNELS).unwrap();
+ let input = vec![0.25_f32; 256 * CHANNELS];
+ let mut output = Vec::with_capacity(resampler.out_max() * CHANNELS);
+ let mut frames = 0;
+ for _ in 0..CHUNKS {
+ output.clear();
+ resampler.process_chunk(&input, &mut output).unwrap();
+ assert_eq!(output.len() % CHANNELS, 0);
+ frames += output.len() / CHANNELS;
+ }
+ frames
+ }
+
+ #[test]
+ fn converts_48k_to_44k1() {
+ let frames = produced_frames(48_000, 44_100);
+ let expected = 32 * 256 * 44_100 / 48_000;
+ assert!((frames as isize - expected as isize).unsigned_abs() <= 256);
+ }
+
+ #[test]
+ fn converts_44k1_to_48k() {
+ let frames = produced_frames(44_100, 48_000);
+ let expected = 32 * 256 * 48_000 / 44_100;
+ assert!((frames as isize - expected as isize).unsigned_abs() <= 256);
+ }
+
+ #[test]
+ fn converts_48k_to_96k() {
+ let frames = produced_frames(48_000, 96_000);
+ let expected = 32 * 256 * 96_000 / 48_000;
+ assert!((frames as isize - expected as isize).unsigned_abs() <= 512);
+ }
+
+ #[test]
+ fn process_chunk_into_matches_expected() {
+ let mut resampler = MultiResampler::new(48_000, 44_100, 256, 2).unwrap();
+ let input = vec![0.1_f32; 256 * 2];
+ let mut out = vec![0.0_f32; resampler.out_max() * 2];
+ let written = resampler.process_chunk_into(&input, &mut out).unwrap();
+ assert!(written > 0);
+ assert_eq!(written % 2, 0);
+ }
+}
diff --git a/src-tauri/src/audio/virtual_device/linux.rs b/src-tauri/src/audio/virtual_device/linux.rs
index cb2ffefd..3aa26324 100644
--- a/src-tauri/src/audio/virtual_device/linux.rs
+++ b/src-tauri/src/audio/virtual_device/linux.rs
@@ -76,7 +76,7 @@ pub fn apply_virtual_devices(devices: Vec) -> Result<(), St
std::fs::write(&conf, conf_contents(&devices)).map_err(|e| format!("write conf: {e}"))?;
for d in &devices {
- create_runtime_sink(&d.id, &clean_label(&d.name), d.channels)?;
+ create_runtime_sink(&d.id, &clean_label(&d.name), d.channels, d.sample_rate)?;
}
Ok(())
}
@@ -100,6 +100,7 @@ fn conf_contents(devices: &[VirtualDeviceConfig]) -> String {
" 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");
@@ -144,7 +145,12 @@ fn roundtrip(core: &pw::core::CoreRc, mainloop: &pw::main_loop::MainLoopRc) -> R
// 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, channels: u32) -> Result<(), String> {
+fn create_runtime_sink(
+ id: &str,
+ label: &str,
+ channels: u32,
+ sample_rate: u32,
+) -> Result<(), String> {
with_session(|core, mainloop| {
let mut props = pw::properties::properties! {
*pw::keys::FACTORY_NAME => "support.null-audio-sink",
@@ -154,6 +160,7 @@ fn create_runtime_sink(id: &str, label: &str, channels: u32) -> Result<(), Strin
props.insert(*pw::keys::NODE_NAME, format!("{NODE_PREFIX}.{id}"));
props.insert(*pw::keys::NODE_DESCRIPTION, label.to_string());
props.insert("audio.position", positions(channels));
+ props.insert("audio.rate", sample_rate.to_string());
let _node: pw::node::Node = core
.create_object("adapter", &props)
.map_err(|e| format!("create null sink: {e}"))?;
diff --git a/src-tauri/src/audio/virtual_device/macos.rs b/src-tauri/src/audio/virtual_device/macos.rs
index 2c6a5dff..77d528d7 100644
--- a/src-tauri/src/audio/virtual_device/macos.rs
+++ b/src-tauri/src/audio/virtual_device/macos.rs
@@ -74,10 +74,11 @@ fn build_plist(devices: &[VirtualDeviceConfig]) -> String {
);
for d in devices {
plist.push_str(&format!(
- "\t\n\t\tid{}\n\t\tname{}\n\t\tchannels{}\n\t\n",
+ "\t\n\t\tid{}\n\t\tname{}\n\t\tchannels{}\n\t\tsampleRate{}\n\t\n",
xml_escape(&d.id),
xml_escape(&d.name),
- d.channels.clamp(1, 256)
+ d.channels.clamp(1, 256),
+ d.sample_rate.clamp(8_000, 384_000)
));
}
plist.push_str("\n\n");
diff --git a/src-tauri/src/audio/virtual_device/mod.rs b/src-tauri/src/audio/virtual_device/mod.rs
index 5ee1b3c9..1c4f1e5c 100644
--- a/src-tauri/src/audio/virtual_device/mod.rs
+++ b/src-tauri/src/audio/virtual_device/mod.rs
@@ -1,17 +1,24 @@
-#[derive(Debug, Clone, serde::Deserialize)]
+#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
pub struct VirtualDeviceConfig {
pub id: String,
pub name: String,
#[serde(default = "default_channels")]
pub channels: u32,
+ #[serde(default = "default_sample_rate")]
+ pub sample_rate: u32,
}
fn default_channels() -> u32 {
2
}
+fn default_sample_rate() -> u32 {
+ 48_000
+}
+
// Bump with any driver bundle change; keep in sync with Info.plist CFBundleVersion.
-pub const DRIVER_VERSION: u32 = 4;
+pub const DRIVER_VERSION: u32 = 5;
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
@@ -42,3 +49,23 @@ pub use linux::{apply_virtual_devices, install, status, uninstall};
mod windows;
#[cfg(target_os = "windows")]
pub use windows::{apply_virtual_devices, install, status, uninstall};
+
+#[cfg(test)]
+mod tests {
+ use super::VirtualDeviceConfig;
+
+ #[test]
+ fn deserializes_with_default_sample_rate() {
+ let json = r#"{"id":"v1","name":"Virtual Mic","channels":2}"#;
+ let cfg: VirtualDeviceConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(cfg.sample_rate, 48_000);
+ assert_eq!(cfg.channels, 2);
+ }
+
+ #[test]
+ fn deserializes_with_custom_sample_rate() {
+ let json = r#"{"id":"v1","name":"Virtual Mic","channels":2,"sampleRate":96000}"#;
+ let cfg: VirtualDeviceConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(cfg.sample_rate, 96_000);
+ }
+}
diff --git a/src/lib/modules/audio/methods.ts b/src/lib/modules/audio/methods.ts
index a89a62bd..94734884 100644
--- a/src/lib/modules/audio/methods.ts
+++ b/src/lib/modules/audio/methods.ts
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
+import { appSettings } from '$lib/modules/settings/stores.svelte';
import type {
AudioApplication,
AudioDevice,
@@ -53,11 +54,13 @@ export const methods = {
}> => invoke('read_file_peaks', { path, startFrame, framesPerBin, binCount }),
isPipelineRunning: (): Promise => invoke('is_pipeline_running'),
getOutputLatency: (): Promise => invoke('output_latency_ms'),
- startPipeline: (graph: StartPipelinePayload): Promise => invoke('start_pipeline', { graph }),
+ startPipeline: (graph: StartPipelinePayload): Promise =>
+ invoke('start_pipeline', { graph: { sampleRate: appSettings.pipelineSampleRate, ...graph } }),
stopPipeline: (): Promise => invoke('stop_pipeline'),
/** Hot-reconfigure a running pipeline. Errors with `NotRunning` if no
* pipeline is active — callers should fall back to `startPipeline`. */
- reconcilePipeline: (graph: StartPipelinePayload): Promise => invoke('reconcile_pipeline', { graph }),
+ reconcilePipeline: (graph: StartPipelinePayload): Promise =>
+ invoke('reconcile_pipeline', { graph: { sampleRate: appSettings.pipelineSampleRate, ...graph } }),
/** No-op when the pipeline isn't running; callers can fire-and-forget. */
updateEffect: (nodeId: string, data: Record): Promise => invoke('update_effect', { nodeId, data }),
/** Seek an AudioFile input. No-op when not running. */
diff --git a/src/lib/modules/audio/types.ts b/src/lib/modules/audio/types.ts
index 90a3adc2..80d21ccd 100644
--- a/src/lib/modules/audio/types.ts
+++ b/src/lib/modules/audio/types.ts
@@ -80,6 +80,7 @@ export interface VirtualDeviceConfig {
id: string;
name: string;
channels: number;
+ sampleRate?: number;
}
export type WindowsVirtualCableState =
@@ -117,4 +118,5 @@ export type AudioStateEvent = { kind: 'started' } | { kind: 'stopped' } | { kind
export interface StartPipelinePayload {
nodes: PipelineNode[];
edges: PipelineEdge[];
+ sampleRate?: number;
}
diff --git a/src/lib/modules/settings/stores.svelte.ts b/src/lib/modules/settings/stores.svelte.ts
index 3c3feba5..ed397b29 100644
--- a/src/lib/modules/settings/stores.svelte.ts
+++ b/src/lib/modules/settings/stores.svelte.ts
@@ -11,6 +11,7 @@ interface Stored {
launchOnStartup: boolean;
confirmOverwriteChanges: boolean;
keepRunningOnDisconnect: boolean;
+ pipelineSampleRate: number;
}
const DEFAULTS: Stored = {
@@ -20,11 +21,13 @@ const DEFAULTS: Stored = {
gridSize: 20,
launchOnStartup: false,
confirmOverwriteChanges: true,
- keepRunningOnDisconnect: true
+ keepRunningOnDisconnect: true,
+ pipelineSampleRate: 48_000
};
export const SNAPSHOT_LIMITS = [10, 20, 50, 100] as const;
export const GRID_SIZES = [10, 20, 40] as const;
+export const PIPELINE_SAMPLE_RATE_PRESETS = [44100, 48000, 88200, 96000, 176400, 192000] as const;
function load(): Stored {
if (!browser) return DEFAULTS;
@@ -44,13 +47,32 @@ class AppSettings {
launchOnStartup = $state(this.#initial.launchOnStartup);
confirmOverwriteChanges = $state(this.#initial.confirmOverwriteChanges);
keepRunningOnDisconnect = $state(this.#initial.keepRunningOnDisconnect);
+ pipelineSampleRate = $state(this.#initial.pipelineSampleRate ?? 48_000);
persist(): void {
if (!browser) return;
- const { checkUpdatesOnLaunch, maxSnapshots, snapToGrid, gridSize, launchOnStartup, confirmOverwriteChanges, keepRunningOnDisconnect } = this;
+ const {
+ checkUpdatesOnLaunch,
+ maxSnapshots,
+ snapToGrid,
+ gridSize,
+ launchOnStartup,
+ confirmOverwriteChanges,
+ keepRunningOnDisconnect,
+ pipelineSampleRate
+ } = this;
window.localStorage.setItem(
KEY,
- JSON.stringify({ checkUpdatesOnLaunch, maxSnapshots, snapToGrid, gridSize, launchOnStartup, confirmOverwriteChanges, keepRunningOnDisconnect })
+ JSON.stringify({
+ checkUpdatesOnLaunch,
+ maxSnapshots,
+ snapToGrid,
+ gridSize,
+ launchOnStartup,
+ confirmOverwriteChanges,
+ keepRunningOnDisconnect,
+ pipelineSampleRate
+ })
);
}
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte
index a5f66963..ced6a28b 100644
--- a/src/routes/settings/+page.svelte
+++ b/src/routes/settings/+page.svelte
@@ -7,7 +7,8 @@
import EdgeShapeIcon from '$lib/modules/flow/ui/_edge_shape_icon.svelte';
import Toggle from '$lib/components/toggle.svelte';
import { themeStore, type ThemePref } from '$lib/modules/theme/stores';
- import { appSettings, GRID_SIZES, SNAPSHOT_LIMITS } from '$lib/modules/settings/stores.svelte';
+ import { appSettings, GRID_SIZES, SNAPSHOT_LIMITS, PIPELINE_SAMPLE_RATE_PRESETS } from '$lib/modules/settings/stores.svelte';
+ import NumberStepper from '$lib/components/number_stepper.svelte';
import PresetsSection from './_presets_section.svelte';
const SHAPES: { value: EdgeShape; label: string; hint: string }[] = [
@@ -39,10 +40,18 @@
void disableAutostart();
}
- function setApp(
- key: K,
- value: (typeof appSettings)[K]
- ) {
+ let customRateSelected = $state(false);
+
+ function setApp<
+ K extends
+ | 'checkUpdatesOnLaunch'
+ | 'maxSnapshots'
+ | 'snapToGrid'
+ | 'gridSize'
+ | 'confirmOverwriteChanges'
+ | 'keepRunningOnDisconnect'
+ | 'pipelineSampleRate'
+ >(key: K, value: (typeof appSettings)[K]) {
appSettings[key] = value;
appSettings.persist();
}
@@ -214,6 +223,60 @@
onChange={() => setApp('confirmOverwriteChanges', !appSettings.confirmOverwriteChanges)} />
+
+
+
Pipeline sample rate
+
+ Working sample rate for mixing and DSP. Matching this rate across your input and output devices ensures bit-transparent audio with zero
+ resampling artifacts, preserving low-level details and preventing intersample clipping.
+