-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix(timestamp): correct mach tick conversion in Duration arithmetic + compensate mic input latency #1985
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(timestamp): correct mach tick conversion in Duration arithmetic + compensate mic input latency #1985
Changes from all commits
01ca5f8
53a8f2b
a1b53cf
6d5cec6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fix requires resolving the macOS device by name (or device UID) instead of defaulting to Prompt To Fix With AIThis 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, | ||
|
|
@@ -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>, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| 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<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; | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same idea for
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| 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:?}" | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Device::name()is already aString; can avoid the extra allocation here.