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
2 changes: 2 additions & 0 deletions apps/desktop/src/routes/(window-chrome)/settings/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ const coversDefaultExclusion = (
type ExtendedGeneralSettingsStore = GeneralSettingsStore;

const MAX_FPS_OPTIONS = [
{ value: 24, label: "24 FPS" },
{ value: 25, label: "25 FPS" },
{ value: 30, label: "30 FPS" },
{ value: 60, label: "60 FPS (Recommended)" },
{ value: 120, label: "120 FPS" },
Expand Down
27 changes: 25 additions & 2 deletions crates/audio/src/latency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,12 +574,35 @@ mod macos {
pub(super) fn estimate_input_latency(
sample_rate: u32,
buffer_size_frames: u32,
_device_name: Option<&str>,
device_name: Option<&str>,
) -> Option<InputLatencyInfo> {
let device = System::default_input_device().ok()?;
// Resolve the actual device being recorded from; the system-default
// input may be a different device with very different latency
// characteristics (e.g. a Bluetooth headset while recording from a
// wired interface).
let device = input_device_by_name(device_name)
.or_else(|| System::default_input_device().ok())?;
compute_input_latency(&device, sample_rate, buffer_size_frames).ok()
}

fn input_device_by_name(device_name: Option<&str>) -> Option<Device> {
let target = device_name?;
System::devices().ok()?.into_iter().find(|device| {
has_input_streams(device)
&& device
.name()
.is_ok_and(|name| name.to_string() == target)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Device::name() is already a String; can avoid the extra allocation here.

Suggested change
.is_ok_and(|name| name.to_string() == target)
.is_ok_and(|name| name == target)

})
}

fn has_input_streams(device: &Device) -> bool {
device.streams().is_ok_and(|streams| {
streams
.iter()
.any(|stream| is_input_stream(stream).unwrap_or(false))
})
}

fn compute_input_latency(
device: &Device,
sample_rate: u32,
Expand Down
29 changes: 28 additions & 1 deletion crates/recording/src/feeds/microphone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,24 @@ impl MicrophoneFeed {
CallbackSampleRateEstimator::new(callback_sample_rate);
let mut pending_samples = VecDeque::new();

let latency_info = estimate_input_latency(
callback_sample_rate,
buffer_size_frames.unwrap_or(1024),
Some(&label),
Comment on lines +700 to +703

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 macOS ignores device name, always queries system-default input

estimate_input_latency on macOS internally calls System::default_input_device() and ignores the device_name argument entirely (the parameter is named _device_name in crates/audio/src/latency.rs:577). If the user has selected a non-default microphone — say a USB audio interface — while their system default input is a Bluetooth headset, the latency returned will be ≥120ms (the wireless floor) even though the actual wired device latency is ~10ms, overcompensating by ~100ms and making the mic track appear to start 100ms too early. The previous behavior (no compensation) was better in that case.

The fix requires resolving the macOS device by name (or device UID) instead of defaulting to System::default_input_device().

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/feeds/microphone.rs
Line: 700-703

Comment:
**macOS ignores device name, always queries system-default input**

`estimate_input_latency` on macOS internally calls `System::default_input_device()` and ignores the `device_name` argument entirely (the parameter is named `_device_name` in `crates/audio/src/latency.rs:577`). If the user has selected a non-default microphone — say a USB audio interface — while their system default input is a Bluetooth headset, the latency returned will be ≥120ms (the wireless floor) even though the actual wired device latency is ~10ms, overcompensating by ~100ms and making the mic track appear to start 100ms too early. The previous behavior (no compensation) was better in that case.

The fix requires resolving the macOS device by name (or device UID) instead of defaulting to `System::default_input_device()`.

How can I resolve this? If you propose a fix, please make it concise.

);
let capture_latency = Duration::from_secs_f64(
latency_info
.device_latency_secs
.clamp(0.0, MAX_CAPTURE_LATENCY_COMPENSATION_SECS),
);
if !capture_latency.is_zero() {
info!(
"🎤 Compensating capture timestamps by {:.1}ms input pipeline latency (transport: {:?})",
capture_latency.as_secs_f64() * 1000.0,
latency_info.transport
);
}

let stream = match device.build_input_stream_raw(
&stream_config,
sample_format,
Expand Down Expand Up @@ -730,7 +748,8 @@ impl MicrophoneFeed {
sample_rate: effective_sample_rate.sample_rate,
channels: callback_channels,
info: info.clone(),
timestamp: Timestamp::from_cpal(input_timestamp.capture),
timestamp: Timestamp::from_cpal(input_timestamp.capture)
- capture_latency,
};

if !effective_sample_rate.settled {
Expand Down Expand Up @@ -914,6 +933,14 @@ const WIRELESS_TARGET_LATENCY_MS: u32 = 80;
const WIRELESS_MIN_LATENCY_MS: u32 = 50;
const WIRELESS_MAX_LATENCY_MS: u32 = 200;

// The cpal capture timestamp (mHostTime / QPC) marks when samples left the
// audio HAL, not when the sound reached the microphone. The gap between the
// two is the input pipeline latency (device latency + safety offset + stream
// latency), which otherwise lands in the recording as the mic track running
// late relative to video. Buffer latency is deliberately excluded: the
// callback timestamp already refers to the first frame of the buffer.
const MAX_CAPTURE_LATENCY_COMPENSATION_SECS: f64 = 0.5;

fn stream_config_with_latency(
config: &SupportedStreamConfig,
device_name: Option<&str>,
Expand Down
55 changes: 51 additions & 4 deletions crates/timestamp/src/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,12 @@ impl Add<Duration> for MachAbsoluteTimestamp {

fn add(self, rhs: Duration) -> Self::Output {
let info = TimeBaseInfo::new();
let freq = info.numer as f64 / info.denom as f64;
// ns = ticks * numer / denom, so ticks = ns * denom / numer.
// On Apple Silicon (numer/denom = 125/3) multiplying by numer/denom
// instead would add ~1736x the intended duration.
let ticks = rhs.as_nanos() as f64 * info.denom as f64 / info.numer as f64;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit: since this was a correctness bug, it might be worth avoiding the f64 conversion here (precision loss for large Durations) and doing the ns→ticks conversion in integer space.

Suggested change
let ticks = rhs.as_nanos() as f64 * info.denom as f64 / info.numer as f64;
let ticks = rhs
.as_nanos()
.saturating_mul(info.denom as u128)
/ info.numer as u128;
Self(self.0.saturating_add(ticks.min(u64::MAX as u128) as u64))


Self((self.0 as f64 + rhs.as_nanos() as f64 * freq) as u64)
Self((self.0 as f64 + ticks) as u64)
}
}

Expand All @@ -75,8 +78,52 @@ impl Sub<Duration> for MachAbsoluteTimestamp {

fn sub(self, rhs: Duration) -> Self::Output {
let info = TimeBaseInfo::new();
let freq = info.numer as f64 / info.denom as f64;
let ticks = rhs.as_nanos() as f64 * info.denom as f64 / info.numer as f64;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same idea for sub: integer math + saturating_sub keeps the intent clear and avoids float rounding.

Suggested change
let ticks = rhs.as_nanos() as f64 * info.denom as f64 / info.numer as f64;
let ticks = rhs
.as_nanos()
.saturating_mul(info.denom as u128)
/ info.numer as u128;
Self(self.0.saturating_sub(ticks.min(u64::MAX as u128) as u64))


Self((self.0 as f64 - ticks).max(0.0) as u64)
}
}

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

#[test]
fn add_duration_roundtrips_through_duration_since() {
let base = MachAbsoluteTimestamp::now();
let added = base + Duration::from_millis(35);

let roundtrip = added.duration_since(base);
let error_us = (roundtrip.as_micros() as i128 - 35_000).abs();
assert!(
error_us < 10,
"35ms add must survive the tick conversion roundtrip, got {roundtrip:?}"
);
}

#[test]
fn sub_duration_roundtrips_through_duration_since() {
let base = MachAbsoluteTimestamp::now();
let subtracted = base - Duration::from_millis(120);

let roundtrip = base.duration_since(subtracted);
let error_us = (roundtrip.as_micros() as i128 - 120_000).abs();
assert!(
error_us < 10,
"120ms sub must survive the tick conversion roundtrip, got {roundtrip:?}"
);
}

Self((self.0 as f64 - rhs.as_nanos() as f64 * freq) as u64)
#[test]
fn add_then_sub_is_identity() {
let base = MachAbsoluteTimestamp::now();
let d = Duration::from_millis(500);
let roundtrip = (base + d) - d;

let drift = roundtrip.duration_since(base).max(base.duration_since(roundtrip));
assert!(
drift < Duration::from_micros(10),
"add/sub of the same duration must cancel out, drifted {drift:?}"
);
}
}
Loading