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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/CONCEPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ A backend that cannot support the feature returns an error; it does not substitu
- 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.

### Capture & Sample Rate Negotiation per Platform

App Audio and System Audio capture adhere to OS-specific hardware/driver semantics:

- **macOS (CoreAudio Process Tap / ScreenCaptureKit)**:
- Process Taps (`AudioHardwareCreateProcessTap`) tap directly into the system audio server buffer before delivery to hardware.
- Rate is strictly dictated by the active hardware output device's nominal sample rate (configured in Audio MIDI Setup). CoreAudio taps do not provide arbitrary client-side resampling.
- _In plain terms_: the sample rate shown in the App/System Audio UI will always equal your macOS output device's sample rate (not the pipeline setting). When Pipeline Sample Rate matches the output device, capture is bit-transparent (no resamplers active).
- **Windows (WASAPI)**:
- _System Audio_: standard WASAPI Loopback on the default render endpoint. The sample rate matches the physical output endpoint's configured mix format (`IAudioClient::GetMixFormat`).
- _App Audio_: process loopback via `VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK`. The virtual capture endpoint accepts any target sample rate at initialization; we initialize it directly with the Pipeline sample rate.
- _In plain terms_: App Audio runs at whatever sample rate is configured in your pipeline settings (zero internal resamplers). System Audio runs at your default Windows playback device's sample rate.
- **Linux (PipeWire)**:
- Both App and System Audio negotiate the Pipeline sample rate directly via SPA format parameters (`spa::param::ParamType::Format`).
- PipeWire natively delivers frames matching the requested pipeline quantum/rate without extra application-level resampling if negotiated.
- _In plain terms_: both App Audio and System Audio sample rates will always equal whatever sample rate is configured in your pipeline settings.

### 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.
Expand Down
48 changes: 48 additions & 0 deletions src-tauri/src/audio/capture/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,51 @@ pub use linux::Capture;
mod windows;
#[cfg(target_os = "windows")]
pub use windows::{loopback_mix_rate, Capture};

use crate::audio::device::NativeDeviceInfo;
use crate::error::AppResult;

pub fn capture_device_info(
kind: &str,
pipeline_sample_rate: Option<u32>,
) -> AppResult<NativeDeviceInfo> {
#[cfg(target_os = "macos")]
{
let _ = (kind, pipeline_sample_rate);
Ok(NativeDeviceInfo {
sample_rate: capture_rate(),
channels: 2,
sample_format: "f32",
})
}
#[cfg(target_os = "windows")]
{
let sample_rate = match kind {
"system" => loopback_mix_rate()?,
_ => pipeline_sample_rate.unwrap_or(48_000),
};
Ok(NativeDeviceInfo {
sample_rate,
channels: 2,
sample_format: "f32",
})
}
#[cfg(target_os = "linux")]
{
let _ = kind;
Ok(NativeDeviceInfo {
sample_rate: pipeline_sample_rate.unwrap_or(48_000),
channels: 2,
sample_format: "f32",
})
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = (kind, pipeline_sample_rate);
Ok(NativeDeviceInfo {
sample_rate: 48_000,
channels: 2,
sample_format: "f32",
})
}
}
31 changes: 18 additions & 13 deletions src-tauri/src/audio/capture/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,15 @@ impl Capture {

pub fn start_app(
bundle_id: &str,
_sample_rate: u32,
sample_rate: u32,
_channels: u32,
bridge: BroadcastRx,
) -> AppResult<Self> {
let pid = crate::audio::system_audio::pid_for_exe(bundle_id).ok_or_else(|| {
AppError::Stream(format!("no active audio session found for {bundle_id:?}"))
})?;
Ok(spawn(bridge, move |stop, bridge| {
run_process_loopback(pid, stop, bridge)
run_process_loopback(pid, sample_rate, stop, bridge)
}))
}
}
Expand Down Expand Up @@ -154,8 +154,14 @@ fn run_loopback(stop: Arc<AtomicBool>, bridge: BroadcastRx) -> AppResult<()> {
}

// Per-app capture via the Win10 2004+ process-loopback activation. The virtual
// device has no mix format, so we ask for 48 kHz stereo f32 explicitly.
fn run_process_loopback(pid: u32, stop: Arc<AtomicBool>, bridge: BroadcastRx) -> AppResult<()> {
// device has no fixed mix format, so we initialize it directly with the desired
// pipeline sample rate to avoid unnecessary resampling.
fn run_process_loopback(
pid: u32,
sample_rate: u32,
stop: Arc<AtomicBool>,
bridge: BroadcastRx,
) -> AppResult<()> {
unsafe {
ensure_com();
let mut params = AUDIOCLIENT_ACTIVATION_PARAMS {
Expand Down Expand Up @@ -200,11 +206,16 @@ fn run_process_loopback(pid: u32, stop: Arc<AtomicBool>, bridge: BroadcastRx) ->
.cast()
.map_err(com_err)?;

let rate = if sample_rate > 0 {
sample_rate
} else {
TARGET_RATE
};
let wfx = WAVEFORMATEX {
wFormatTag: 3, // WAVE_FORMAT_IEEE_FLOAT
nChannels: TARGET_CHANNELS,
nSamplesPerSec: TARGET_RATE,
nAvgBytesPerSec: TARGET_RATE * TARGET_CHANNELS as u32 * 4,
nSamplesPerSec: rate,
nAvgBytesPerSec: rate * TARGET_CHANNELS as u32 * 4,
nBlockAlign: TARGET_CHANNELS * 4,
wBitsPerSample: 32,
cbSize: 0,
Expand All @@ -222,13 +233,7 @@ fn run_process_loopback(pid: u32, stop: Arc<AtomicBool>, bridge: BroadcastRx) ->

let capture: IAudioCaptureClient = client.GetService().map_err(com_err)?;
client.Start().map_err(com_err)?;
let r = pump(
&capture,
TARGET_CHANNELS as usize,
TARGET_RATE,
&stop,
bridge,
);
let r = pump(&capture, TARGET_CHANNELS as usize, rate, &stop, bridge);
let _ = client.Stop();
r
}
Expand Down
87 changes: 73 additions & 14 deletions src-tauri/src/audio/pipeline/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ pub(super) fn start_audio_file(
Ok(InputHandle::AudioFile(reader))
}

fn input_resampler(
native_rate: u32,
target_sample_rate: u32,
channels: usize,
) -> AppResult<Option<MultiResampler>> {
if native_rate == target_sample_rate {
Ok(None)
} else {
MultiResampler::new(native_rate, target_sample_rate, RESAMPLE_CHUNK, channels).map(Some)
}
}

/// 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),
Expand Down Expand Up @@ -238,14 +250,9 @@ pub(super) fn start_input_stream(
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 {
match MultiResampler::new(native_rate, target_sample_rate, RESAMPLE_CHUNK, channels)
{
Ok(resampler) => Some(resampler),
Err(_) => return,
}
let mut resampler = match input_resampler(native_rate, target_sample_rate, channels) {
Ok(resampler) => resampler,
Err(_) => return,
};
let mut output_buf = Vec::with_capacity(
resampler
Expand All @@ -271,12 +278,9 @@ pub(super) fn start_input_stream(
}
}
native_rate = rate;
resampler = if rate == target_sample_rate {
None
} else {
MultiResampler::new(rate, target_sample_rate, RESAMPLE_CHUNK, channels)
.ok()
};
resampler = input_resampler(rate, target_sample_rate, channels)
.ok()
.flatten();
output_buf = Vec::with_capacity(
resampler
.as_ref()
Expand Down Expand Up @@ -322,3 +326,58 @@ pub(super) fn start_input_stream(
join: Some(join),
}))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_input_resampler_bypassed_when_rates_match() {
// When native_rate == target_sample_rate (e.g. 96 kHz App Audio and 96 kHz pipeline),
// no resampler must be allocated, ensuring bit-transparent passthrough with zero quality loss.
for rate in [44_100, 48_000, 96_000, 192_000] {
let resampler = input_resampler(rate, rate, 2).unwrap();
assert!(
resampler.is_none(),
"resampler should be None for matching rate {rate}"
);
}
}

#[test]
fn test_input_resampler_only_allocated_when_rates_differ() {
let resampler = input_resampler(48_000, 96_000, 2).unwrap();
assert!(
resampler.is_some(),
"resampler must be Some when rates differ"
);
}

#[test]
fn test_input_bit_transparent_sample_passthrough() {
// Verify that when resampler is None, samples pass through bit-for-bit without any modification.
let mut input_buf = vec![0.0f32; RESAMPLE_CHUNK * 2];
for (i, sample) in input_buf.iter_mut().enumerate() {
*sample = ((i as f32) * 0.001).sin();
}

let mut resampler = input_resampler(96_000, 96_000, 2).unwrap();
let mut output_buf = Vec::new();

let normalized = if let Some(resampler) = &mut resampler {
output_buf.clear();
resampler
.process_chunk(&input_buf, &mut output_buf)
.unwrap();
output_buf.as_slice()
} else {
input_buf.as_slice()
};

assert_eq!(normalized.len(), input_buf.len());
// Verify bit-exact equality (no rounding, no sinc filtering)
for (a, b) in normalized.iter().zip(input_buf.iter()) {
assert_eq!(a.to_bits(), b.to_bits());
}
}
}
12 changes: 6 additions & 6 deletions src-tauri/src/audio/pipeline/input/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ use crate::error::AppResult;
use super::super::native::native_config;
use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput};

const LOOPBACK_FALLBACK_RATE: u32 = 48_000;

const LOOPBACK_CHANNELS: usize = 2;

pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult<ResolvedInput> {
pub(in crate::audio::pipeline) fn resolve_input(
inp: &ValidInput,
target_sample_rate: u32,
) -> AppResult<ResolvedInput> {
match &inp.spec {
InputSpec::Microphone { device_id } => {
let device = device::find(DeviceKind::Input, device_id)?;
Expand All @@ -35,12 +36,11 @@ pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult<R
InputSpec::SystemAudio {
exclude_current_app,
} => Ok(ResolvedInput::SystemAudio {
sample_rate: crate::audio::capture::loopback_mix_rate()
.unwrap_or(LOOPBACK_FALLBACK_RATE),
sample_rate: crate::audio::capture::loopback_mix_rate()?,
exclude_current_app: *exclude_current_app,
}),
InputSpec::AppAudio { bundle_id } => Ok(ResolvedInput::AppAudio {
sample_rate: LOOPBACK_FALLBACK_RATE,
sample_rate: target_sample_rate,
bundle_id: bundle_id.clone(),
}),
InputSpec::AudioFile { file_path } => resolve_audio_file(file_path),
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/audio/pipeline/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,9 +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")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
let resolved = resolve_input(inp, pipeline_sr)?;
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
let resolved = resolve_input(inp)?;
let sr = match &resolved {
ResolvedInput::AudioFile { sample_rate, .. } => *sample_rate,
Expand Down
81 changes: 71 additions & 10 deletions src-tauri/src/audio/pipeline/output/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,18 @@ pub(super) fn speaker_ring(
(producer, fill, level, target, io)
}

fn output_resampler(
pipeline_rate: u32,
device_rate: u32,
channels: usize,
) -> AppResult<Option<FixedRateResampler>> {
if pipeline_rate == device_rate {
Ok(None)
} else {
FixedRateResampler::new(pipeline_rate, device_rate, DSP_BLOCK_FRAMES, channels).map(Some)
}
}

// 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.
Expand All @@ -354,16 +366,7 @@ pub(super) fn spawn_speaker_worker(
target,
));
let initial_device_rate = device_sample_rate.load(Ordering::Relaxed);
let mut resampler = if initial_device_rate == pipeline_rate {
None
} else {
Some(FixedRateResampler::new(
pipeline_rate,
initial_device_rate,
DSP_BLOCK_FRAMES,
channels,
)?)
};
let mut resampler = output_resampler(pipeline_rate, initial_device_rate, channels)?;
let mut resampled = vec![
0.0_f32;
resampler
Expand Down Expand Up @@ -594,3 +597,61 @@ pub(super) fn start_recorder_worker(
wave,
))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::audio::pipeline::dag::RESAMPLE_CHUNK;

#[test]
fn test_output_resampler_bypassed_when_rates_match() {
// When initial_device_rate == pipeline_rate (e.g. 96 kHz pipeline and 96 kHz speaker),
// no output resampler must be allocated, preserving 1:1 bit-transparent playback.
for rate in [44_100, 48_000, 96_000, 192_000] {
let resampler = output_resampler(rate, rate, 2).unwrap();
assert!(
resampler.is_none(),
"output resampler should be None for matching rate {rate}"
);
}
}

#[test]
fn test_output_resampler_only_allocated_when_rates_differ() {
let resampler = output_resampler(96_000, 48_000, 2).unwrap();
assert!(
resampler.is_some(),
"output resampler must be Some when rates differ"
);
}

#[test]
fn test_output_bit_transparent_sample_passthrough() {
// Verify that when output resampler is None, samples pass directly to the device buffer.
let total_samples = RESAMPLE_CHUNK * 2;
let mut block = vec![0.0f32; total_samples];
for (i, sample) in block.iter_mut().enumerate() {
*sample = ((i as f32) * 0.005).cos();
}

let mut resampler = output_resampler(96_000, 96_000, 2).unwrap();
let mut resampled = Vec::new();
let active_channels = 2;
let mut resampled_channels = 0;

let device_block = if let Some(resampler) = &mut resampler {
resampled_channels = resampled_channels.max(active_channels);
let written = resampler
.process_chunk_into(&block[..total_samples], resampled_channels, &mut resampled)
.unwrap();
&resampled[..written]
} else {
&block[..total_samples]
};

assert_eq!(device_block.len(), total_samples);
for (a, b) in device_block.iter().zip(block.iter()) {
assert_eq!(a.to_bits(), b.to_bits());
}
}
}
Loading
Loading