diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index a654d34e1de..28a8052ad28 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -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" }, diff --git a/crates/audio/src/latency.rs b/crates/audio/src/latency.rs index 3482b69a8e4..cdd61819f9c 100644 --- a/crates/audio/src/latency.rs +++ b/crates/audio/src/latency.rs @@ -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 { - 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 { + 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) + }) + } + + 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, diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index a123537a23c..224b1754071 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -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), + ); + 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, @@ -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 { @@ -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>, diff --git a/crates/timestamp/src/macos.rs b/crates/timestamp/src/macos.rs index 0a2e0539d2e..48e7c7b6726 100644 --- a/crates/timestamp/src/macos.rs +++ b/crates/timestamp/src/macos.rs @@ -64,9 +64,12 @@ impl Add 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; - Self((self.0 as f64 + rhs.as_nanos() as f64 * freq) as u64) + Self((self.0 as f64 + ticks) as u64) } } @@ -75,8 +78,52 @@ impl Sub 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; + + 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:?}" + ); } }