Skip to content

Commit fa91e47

Browse files
committed
Refactor audio drift correction logic and tests
1 parent 22af337 commit fa91e47

File tree

2 files changed

+163
-131
lines changed

2 files changed

+163
-131
lines changed

crates/recording/src/output_pipeline/core.rs

Lines changed: 108 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const LARGE_FORWARD_JUMP_SECS: f64 = 5.0;
3232
struct AudioDriftTracker {
3333
baseline_offset_secs: Option<f64>,
3434
drift_warning_logged: bool,
35+
first_frame_timestamp_secs: Option<f64>,
3536
}
3637

3738
const AUDIO_WALL_CLOCK_TOLERANCE_SECS: f64 = 0.1;
@@ -42,38 +43,48 @@ impl AudioDriftTracker {
4243
Self {
4344
baseline_offset_secs: None,
4445
drift_warning_logged: false,
46+
first_frame_timestamp_secs: None,
4547
}
4648
}
4749

4850
fn calculate_timestamp(
4951
&mut self,
50-
samples_before_frame: u64,
51-
sample_rate: u32,
52+
frame_timestamp_secs: f64,
5253
wall_clock_secs: f64,
53-
total_input_duration_secs: f64,
5454
) -> Option<Duration> {
55-
let sample_time_secs = samples_before_frame as f64 / sample_rate as f64;
55+
if frame_timestamp_secs < 0.0 {
56+
return None;
57+
}
58+
59+
let first_timestamp = *self
60+
.first_frame_timestamp_secs
61+
.get_or_insert(frame_timestamp_secs);
62+
let frame_elapsed_secs = frame_timestamp_secs - first_timestamp;
63+
64+
if frame_elapsed_secs < 0.0 {
65+
return None;
66+
}
5667

57-
if sample_time_secs > wall_clock_secs + AUDIO_WALL_CLOCK_TOLERANCE_SECS {
68+
if frame_elapsed_secs > wall_clock_secs + AUDIO_WALL_CLOCK_TOLERANCE_SECS {
5869
return None;
5970
}
6071

61-
if wall_clock_secs >= 2.0 && total_input_duration_secs >= 2.0 {
72+
if wall_clock_secs >= 2.0 && frame_elapsed_secs >= 2.0 {
6273
if self.baseline_offset_secs.is_none() {
63-
let offset = total_input_duration_secs - wall_clock_secs;
74+
let offset = frame_elapsed_secs - wall_clock_secs;
6475
debug!(
6576
wall_clock_secs,
66-
total_input_duration_secs,
77+
frame_elapsed_secs,
6778
baseline_offset_secs = offset,
6879
"Capturing audio baseline offset after warmup"
6980
);
7081
self.baseline_offset_secs = Some(offset);
7182
}
7283

7384
let baseline = self.baseline_offset_secs.unwrap_or(0.0);
74-
let adjusted_input_duration = total_input_duration_secs - baseline;
75-
let drift_ratio = if adjusted_input_duration > 0.0 {
76-
wall_clock_secs / adjusted_input_duration
85+
let adjusted_frame_elapsed = frame_elapsed_secs - baseline;
86+
let drift_ratio = if adjusted_frame_elapsed > 0.0 {
87+
wall_clock_secs / adjusted_frame_elapsed
7788
} else {
7889
1.0
7990
};
@@ -82,15 +93,18 @@ impl AudioDriftTracker {
8293
warn!(
8394
drift_ratio,
8495
wall_clock_secs,
85-
adjusted_input_duration,
96+
adjusted_frame_elapsed,
8697
baseline,
8798
"Significant audio clock drift detected"
8899
);
89100
self.drift_warning_logged = true;
90101
}
102+
103+
let corrected_secs = adjusted_frame_elapsed * drift_ratio;
104+
return Some(Duration::from_secs_f64(corrected_secs.max(0.0)));
91105
}
92106

93-
Some(Duration::from_secs_f64(sample_time_secs))
107+
Some(Duration::from_secs_f64(frame_elapsed_secs.max(0.0)))
94108
}
95109
}
96110

@@ -1056,7 +1070,6 @@ impl PreparedAudioSources {
10561070
let _ = first_tx.send(frame.timestamp);
10571071
}
10581072

1059-
let samples_before_frame = total_samples;
10601073
let frame_samples = frame.inner.samples() as u64;
10611074
total_samples += frame_samples;
10621075

@@ -1066,34 +1079,33 @@ impl PreparedAudioSources {
10661079
let effective_wall_clock =
10671080
raw_wall_clock.saturating_sub(total_pause_duration);
10681081
let wall_clock_secs = effective_wall_clock.as_secs_f64();
1069-
let total_input_duration_secs =
1070-
total_samples as f64 / sample_rate as f64;
1082+
1083+
let frame_timestamp_secs =
1084+
frame.timestamp.signed_duration_since_secs(timestamps);
10711085

10721086
if wall_clock_secs >= 5.0 && (wall_clock_secs as u64).is_multiple_of(10)
10731087
{
1074-
let drift_ratio = if total_input_duration_secs > 0.0 {
1075-
wall_clock_secs / total_input_duration_secs
1088+
let total_input_duration_secs =
1089+
total_samples as f64 / sample_rate as f64;
1090+
let drift_ratio = if frame_timestamp_secs > 0.0 {
1091+
wall_clock_secs / frame_timestamp_secs
10761092
} else {
10771093
1.0
10781094
};
10791095
debug!(
10801096
wall_clock_secs,
10811097
total_input_duration_secs,
1098+
frame_timestamp_secs,
10821099
drift_ratio,
1083-
samples_before_frame,
10841100
total_samples,
10851101
baseline_offset = drift_tracker.baseline_offset_secs,
10861102
total_pause_ms = total_pause_duration.as_millis(),
10871103
"Audio drift correction status"
10881104
);
10891105
}
10901106

1091-
let timestamp = drift_tracker.calculate_timestamp(
1092-
samples_before_frame,
1093-
sample_rate,
1094-
wall_clock_secs,
1095-
total_input_duration_secs,
1096-
);
1107+
let timestamp = drift_tracker
1108+
.calculate_timestamp(frame_timestamp_secs, wall_clock_secs);
10971109

10981110
if let Some(timestamp) = timestamp
10991111
&& let Err(e) =
@@ -1483,24 +1495,18 @@ mod tests {
14831495
mod audio_drift_tracker {
14841496
use super::*;
14851497

1486-
const SAMPLE_RATE: u32 = 48000;
1487-
1488-
fn samples_for_duration(duration_secs: f64) -> u64 {
1489-
(duration_secs * SAMPLE_RATE as f64) as u64
1490-
}
1491-
14921498
#[test]
1493-
fn returns_sample_based_time_during_warmup() {
1499+
fn returns_frame_based_time_during_warmup() {
14941500
let mut tracker = AudioDriftTracker::new();
1495-
let samples = samples_for_duration(1.0);
1501+
let frame_timestamp = 1.0;
1502+
let wall_clock = 1.5;
14961503
let result = tracker
1497-
.calculate_timestamp(samples, SAMPLE_RATE, 1.5, 1.5)
1498-
.expect("Should not be capped when sample time < wall clock");
1499-
let expected = Duration::from_secs_f64(1.0);
1504+
.calculate_timestamp(frame_timestamp, wall_clock)
1505+
.expect("Should not be capped when frame time < wall clock");
1506+
let expected = Duration::ZERO;
15001507
assert!(
15011508
(result.as_secs_f64() - expected.as_secs_f64()).abs() < 0.001,
1502-
"Expected ~{:.3}s, got {:.3}s",
1503-
expected.as_secs_f64(),
1509+
"First frame should have ~0s timestamp, got {:.3}s",
15041510
result.as_secs_f64()
15051511
);
15061512
assert!(
@@ -1513,11 +1519,12 @@ mod tests {
15131519
fn captures_baseline_after_warmup() {
15141520
let mut tracker = AudioDriftTracker::new();
15151521
let buffer_delay = 0.05;
1516-
let wall_clock = 2.0;
1517-
let input_duration = 2.0 + buffer_delay;
1518-
let samples = samples_for_duration(input_duration);
15191522

1520-
tracker.calculate_timestamp(samples, SAMPLE_RATE, wall_clock, input_duration);
1523+
tracker.calculate_timestamp(0.0, 0.0);
1524+
1525+
let frame_timestamp = 2.0 + buffer_delay;
1526+
let wall_clock = 2.0;
1527+
tracker.calculate_timestamp(frame_timestamp, wall_clock);
15211528

15221529
assert!(tracker.baseline_offset_secs.is_some());
15231530
let baseline = tracker.baseline_offset_secs.unwrap();
@@ -1528,30 +1535,26 @@ mod tests {
15281535
}
15291536

15301537
#[test]
1531-
fn returns_sample_based_time_after_warmup() {
1538+
fn applies_drift_correction_after_warmup() {
15321539
let mut tracker = AudioDriftTracker::new();
15331540
let buffer_delay = 0.05;
15341541

1542+
tracker.calculate_timestamp(0.0, 0.0);
1543+
1544+
let frame_timestamp_1 = 2.0 + buffer_delay;
15351545
let wall_clock_1 = 2.0;
1536-
let input_duration_1 = 2.0 + buffer_delay;
1537-
tracker.calculate_timestamp(
1538-
samples_for_duration(input_duration_1),
1539-
SAMPLE_RATE,
1540-
wall_clock_1,
1541-
input_duration_1,
1542-
);
1546+
tracker.calculate_timestamp(frame_timestamp_1, wall_clock_1);
15431547

1548+
let frame_timestamp_2 = 10.0 + buffer_delay;
15441549
let wall_clock_2 = 10.0;
1545-
let input_duration_2 = 10.0 + buffer_delay;
1546-
let samples_2 = samples_for_duration(input_duration_2);
15471550
let result = tracker
1548-
.calculate_timestamp(samples_2, SAMPLE_RATE, wall_clock_2, input_duration_2)
1551+
.calculate_timestamp(frame_timestamp_2, wall_clock_2)
15491552
.expect("Should not be capped");
15501553

1551-
let expected = Duration::from_secs_f64(input_duration_2);
1554+
let expected = Duration::from_secs_f64(wall_clock_2);
15521555
assert!(
1553-
(result.as_secs_f64() - expected.as_secs_f64()).abs() < 0.001,
1554-
"Expected sample-based time ~{:.3}s, got {:.3}s",
1556+
(result.as_secs_f64() - expected.as_secs_f64()).abs() < 0.1,
1557+
"Expected drift-corrected time ~{:.3}s, got {:.3}s",
15551558
expected.as_secs_f64(),
15561559
result.as_secs_f64()
15571560
);
@@ -1561,14 +1564,11 @@ mod tests {
15611564
fn continuous_timestamps_no_gaps() {
15621565
let mut tracker = AudioDriftTracker::new();
15631566

1564-
let mut samples = 0u64;
15651567
let mut last_timestamp = Duration::ZERO;
15661568
for i in 0..100 {
1569+
let frame_timestamp = i as f64 * 0.02;
15671570
let wall_clock = i as f64 * 0.02;
1568-
let input_duration = samples as f64 / SAMPLE_RATE as f64;
1569-
if let Some(result) =
1570-
tracker.calculate_timestamp(samples, SAMPLE_RATE, wall_clock, input_duration)
1571-
{
1571+
if let Some(result) = tracker.calculate_timestamp(frame_timestamp, wall_clock) {
15721572
if i > 0 {
15731573
let gap = result.as_secs_f64() - last_timestamp.as_secs_f64();
15741574
assert!(
@@ -1579,88 +1579,97 @@ mod tests {
15791579

15801580
last_timestamp = result;
15811581
}
1582-
samples += 960;
15831582
}
15841583
}
15851584

15861585
#[test]
15871586
fn continuous_across_warmup_boundary() {
15881587
let mut tracker = AudioDriftTracker::new();
15891588

1590-
let samples_at_2s = samples_for_duration(2.0);
1589+
tracker.calculate_timestamp(0.0, 0.0);
1590+
1591+
let frame_timestamp_1 = 2.0;
1592+
let wall_clock_1 = 2.1;
15911593
let result1 = tracker
1592-
.calculate_timestamp(
1593-
samples_at_2s,
1594-
SAMPLE_RATE,
1595-
2.1,
1596-
samples_at_2s as f64 / SAMPLE_RATE as f64,
1597-
)
1594+
.calculate_timestamp(frame_timestamp_1, wall_clock_1)
15981595
.expect("Should not be capped");
15991596

1600-
let samples_after = samples_at_2s + 960;
1597+
let frame_timestamp_2 = 2.02;
1598+
let wall_clock_2 = 2.12;
16011599
let result2 = tracker
1602-
.calculate_timestamp(
1603-
samples_after,
1604-
SAMPLE_RATE,
1605-
2.2,
1606-
samples_after as f64 / SAMPLE_RATE as f64,
1607-
)
1600+
.calculate_timestamp(frame_timestamp_2, wall_clock_2)
16081601
.expect("Should not be capped");
16091602

16101603
let gap = result2.as_secs_f64() - result1.as_secs_f64();
1611-
let expected_gap = 960.0 / SAMPLE_RATE as f64;
1604+
let expected_gap = 0.02;
16121605
assert!(
1613-
(gap - expected_gap).abs() < 0.001,
1606+
(gap - expected_gap).abs() < 0.01,
16141607
"Gap across warmup boundary should be continuous: expected {expected_gap:.3}s, got {gap:.3}s"
16151608
);
16161609
}
16171610

16181611
#[test]
1619-
fn simulates_real_world_scenario() {
1612+
fn simulates_real_world_scenario_with_drift() {
16201613
let mut tracker = AudioDriftTracker::new();
1621-
let initial_buffer = 0.05;
1614+
let initial_offset = 0.05;
16221615
let drift_rate = 0.004;
16231616

1624-
let mut total_audio = initial_buffer;
1617+
let mut frame_time = initial_offset;
16251618
let mut wall_time = 0.0;
16261619
let step = 0.5;
16271620

16281621
while wall_time < 60.0 {
1629-
wall_time += step;
1630-
total_audio += step * (1.0 + drift_rate);
1631-
1632-
let samples = samples_for_duration(total_audio);
1633-
if let Some(result) =
1634-
tracker.calculate_timestamp(samples, SAMPLE_RATE, wall_time, total_audio)
1635-
{
1636-
let expected = samples as f64 / SAMPLE_RATE as f64;
1637-
let error = (result.as_secs_f64() - expected).abs();
1638-
assert!(
1639-
error < 0.001,
1640-
"At wall_time={:.1}s: result {:.3}s should equal sample time {:.3}s",
1641-
wall_time,
1642-
result.as_secs_f64(),
1643-
expected
1644-
);
1622+
if let Some(result) = tracker.calculate_timestamp(frame_time, wall_time) {
1623+
if wall_time >= 2.0 {
1624+
let error = (result.as_secs_f64() - wall_time).abs();
1625+
assert!(
1626+
error < 0.5,
1627+
"At wall_time={:.1}s: result {:.3}s should be close to wall clock",
1628+
wall_time,
1629+
result.as_secs_f64()
1630+
);
1631+
}
16451632
}
1633+
1634+
wall_time += step;
1635+
frame_time += step * (1.0 + drift_rate);
16461636
}
16471637
}
16481638

16491639
#[test]
16501640
fn preserves_baseline_across_multiple_calls() {
16511641
let mut tracker = AudioDriftTracker::new();
16521642

1653-
tracker.calculate_timestamp(samples_for_duration(2.1), SAMPLE_RATE, 2.0, 2.1);
1643+
tracker.calculate_timestamp(0.0, 0.0);
1644+
tracker.calculate_timestamp(2.1, 2.0);
16541645

16551646
let first_baseline = tracker.baseline_offset_secs;
16561647

1657-
tracker.calculate_timestamp(samples_for_duration(10.1), SAMPLE_RATE, 10.0, 10.1);
1648+
tracker.calculate_timestamp(10.1, 10.0);
16581649

16591650
assert_eq!(
16601651
first_baseline, tracker.baseline_offset_secs,
16611652
"Baseline should not change after initial capture"
16621653
);
16631654
}
1655+
1656+
#[test]
1657+
fn rejects_negative_timestamps() {
1658+
let mut tracker = AudioDriftTracker::new();
1659+
let result = tracker.calculate_timestamp(-1.0, 1.0);
1660+
assert!(result.is_none(), "Negative timestamps should be rejected");
1661+
}
1662+
1663+
#[test]
1664+
fn rejects_timestamps_too_far_ahead_of_wall_clock() {
1665+
let mut tracker = AudioDriftTracker::new();
1666+
tracker.calculate_timestamp(0.0, 0.0);
1667+
let result = tracker.calculate_timestamp(5.0, 1.0);
1668+
assert!(
1669+
result.is_none(),
1670+
"Timestamps too far ahead of wall clock should be rejected"
1671+
);
1672+
}
16641673
}
16651674

16661675
mod video_drift_tracker {

0 commit comments

Comments
 (0)