Add `RtcpRttCalculator`.

This class takes a sequences of RTCP messages (SR, RR, XR) and
calculates individual RTT samples. It will be integrated into the video
timing simulator in an upcoming change.

Bug: b/423646186
Change-Id: I59661a50a1491cd0dcf79b68d9d9d91519a8d1cd
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/478882
Commit-Queue: Rasmus Brandt <brandtr@webrtc.org>
Reviewed-by: Åsa Persson <asapersson@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#47922}
diff --git a/video/timing/simulator/BUILD.gn b/video/timing/simulator/BUILD.gn
index 57f1770..b65d6e8 100644
--- a/video/timing/simulator/BUILD.gn
+++ b/video/timing/simulator/BUILD.gn
@@ -30,6 +30,8 @@
       "results_base.h",
       "rtc_event_log_driver.cc",
       "rtc_event_log_driver.h",
+      "rtcp_rtt_calculator.cc",
+      "rtcp_rtt_calculator.h",
       "rtp_packet_simulator.cc",
       "rtp_packet_simulator.h",
       "stream_base.h",
@@ -61,6 +63,7 @@
       "../../../logging:rtc_event_rtp_rtcp",
       "../../../logging:rtc_event_video",
       "../../../modules/rtp_rtcp",
+      "../../../modules/rtp_rtcp:ntp_time_util",
       "../../../modules/rtp_rtcp:rtp_rtcp_format",
       "../../../modules/video_coding:nack_requester",
       "../../../modules/video_coding/timing:timing_module",
@@ -68,6 +71,7 @@
       "../../../rtc_base:logging",
       "../../../rtc_base:macromagic",
       "../../../rtc_base:rtc_numerics",
+      "../../../system_wrappers",
       "../../../test/time_controller:simulated_time_task_queue_controller",
       "../../../video",
       "../../../video:task_queue_frame_decode_scheduler",
@@ -79,6 +83,7 @@
       "//third_party/abseil-cpp/absl/container:flat_hash_set",
       "//third_party/abseil-cpp/absl/container:inlined_vector",
       "//third_party/abseil-cpp/absl/functional:any_invocable",
+      "//third_party/abseil-cpp/absl/hash",
       "//third_party/abseil-cpp/absl/strings:string_view",
     ]
   }
@@ -97,6 +102,7 @@
         "rendering_tracker_unittest.cc",
         "results_base_unittest.cc",
         "rtc_event_log_driver_unittest.cc",
+        "rtcp_rtt_calculator_unittest.cc",
         "rtp_packet_simulator_unittest.cc",
         "stream_base_unittest.cc",
       ]
@@ -115,12 +121,14 @@
         "../../../api/video:video_frame",
         "../../../logging:rtc_event_log_parser",
         "../../../logging:rtc_event_rtp_rtcp",
+        "../../../modules/rtp_rtcp:ntp_time_util",
         "../../../modules/rtp_rtcp:rtp_rtcp_format",
         "../../../modules/video_coding/timing:timing_module",
         "../../../rtc_base:checks",
         "../../../rtc_base:macromagic",
         "../../../system_wrappers",
         "../../../test:create_test_environment",
+        "../../../test:near_matcher",
         "../../../test:test_support",
         "test",
         "//third_party/abseil-cpp/absl/algorithm:container",
diff --git a/video/timing/simulator/rtcp_rtt_calculator.cc b/video/timing/simulator/rtcp_rtt_calculator.cc
new file mode 100644
index 0000000..4c4380e
--- /dev/null
+++ b/video/timing/simulator/rtcp_rtt_calculator.cc
@@ -0,0 +1,185 @@
+/*
+ *  Copyright (c) 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#include "video/timing/simulator/rtcp_rtt_calculator.h"
+
+#include <cstdint>
+#include <optional>
+#include <vector>
+
+#include "absl/container/flat_hash_map.h"
+#include "api/sequence_checker.h"
+#include "api/units/time_delta.h"
+#include "api/units/timestamp.h"
+#include "modules/rtp_rtcp/source/ntp_time_util.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/extended_reports.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/receiver_report.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/report_block.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/sender_report.h"
+#include "rtc_base/checks.h"
+#include "rtc_base/logging.h"
+
+namespace webrtc::video_timing_simulator {
+
+RtcpRttCalculator::RtcpRttCalculator() = default;
+
+RtcpRttCalculator::~RtcpRttCalculator() {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+}
+
+void RtcpRttCalculator::OnOutgoingSenderReport(const rtcp::SenderReport& sr,
+                                               Timestamp now) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  RTC_DCHECK(now.IsFinite());
+  CleanOldReports(now);
+  // https://www.rfc-editor.org/info/rfc3550/#section-6.4.1
+  uint32_t compact_ntp = CompactNtp(sr.ntp());
+  if (compact_ntp == 0) {
+    return;
+  }
+  outgoing_srs_[{sr.sender_ssrc(), compact_ntp}] =
+      SentReportValue{.sent_time = now};
+}
+
+void RtcpRttCalculator::OnOutgoingExtendedReports(
+    const rtcp::ExtendedReports& xr,
+    Timestamp now) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  RTC_DCHECK(now.IsFinite());
+  CleanOldReports(now);
+  if (!xr.rrtr().has_value()) {
+    return;
+  }
+  // https://www.rfc-editor.org/info/rfc3611/#section-4.4
+  uint32_t compact_ntp = CompactNtp(xr.rrtr()->ntp());
+  if (compact_ntp == 0) {
+    return;
+  }
+  outgoing_xrs_[{xr.sender_ssrc(), compact_ntp}] =
+      SentReportValue{.sent_time = now};
+}
+
+std::vector<TimeDelta> RtcpRttCalculator::OnIncomingSenderReport(
+    const rtcp::SenderReport& sr,
+    Timestamp now) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  RTC_DCHECK(now.IsFinite());
+  CleanOldReports(now);
+  return ProcessReportBlocks(sr.report_blocks(), now);
+}
+
+std::vector<TimeDelta> RtcpRttCalculator::OnIncomingReceiverReport(
+    const rtcp::ReceiverReport& rr,
+    Timestamp now) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  RTC_DCHECK(now.IsFinite());
+  CleanOldReports(now);
+  return ProcessReportBlocks(rr.report_blocks(), now);
+}
+
+std::vector<TimeDelta> RtcpRttCalculator::OnIncomingExtendedReports(
+    const rtcp::ExtendedReports& xr,
+    Timestamp now) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  RTC_DCHECK(now.IsFinite());
+  CleanOldReports(now);
+  std::vector<TimeDelta> rtt_samples;
+  rtt_samples.reserve(xr.dlrr().sub_blocks().size());
+  for (const auto& block : xr.dlrr().sub_blocks()) {
+    uint32_t sender_ssrc = block.ssrc;
+    uint32_t last_rr = block.last_rr;
+    // (Quotes from https://www.rfc-editor.org/info/rfc3611/#section-4.5)
+    // "If no such block has been received, the field is set to zero."
+    if (last_rr == 0) {
+      continue;
+    }
+    if (auto it = outgoing_xrs_.find({sender_ssrc, last_rr});
+        it != outgoing_xrs_.end()) {
+      if (block.delay_since_last_rr == 0) {
+        // "If a Receiver Reference Time Report Block has yet to be received"
+        // "from SSRC_n, the DLRR field is set to zero (or the DLRR is "
+        // "omitted entirely).
+        continue;
+      }
+      TimeDelta delay_since_last_rr =
+          CompactNtpIntervalToTimeDelta(block.delay_since_last_rr);
+      // "It calculates the total round-trip time A-LRR using the"
+      // "last RR timestamp (LRR) field, and then subtracting this field to"
+      // "leave the round-trip propagation delay as A-LRR-DLRR."
+      TimeDelta rtt = now - it->second.sent_time - delay_since_last_rr;
+      if (rtt <= TimeDelta::Zero()) {
+        RTC_LOG(LS_INFO) << "Ignoring non-positive RTT: " << rtt.ms()
+                         << "ms (now: " << now.ms()
+                         << ", sent: " << it->second.sent_time.ms()
+                         << ", delay_since_last_rr: "
+                         << delay_since_last_rr.ms() << ")";
+        continue;
+      }
+      rtt_samples.push_back(rtt);
+    }
+  }
+  return rtt_samples;
+}
+
+std::vector<TimeDelta> RtcpRttCalculator::ProcessReportBlocks(
+    const std::vector<rtcp::ReportBlock>& report_blocks,
+    Timestamp now) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  RTC_DCHECK(now.IsFinite());
+  std::vector<TimeDelta> rtt_samples;
+  rtt_samples.reserve(report_blocks.size());
+  for (const auto& block : report_blocks) {
+    uint32_t sender_ssrc = block.source_ssrc();
+    uint32_t last_sr = block.last_sr();
+    // (Quotes from https://www.rfc-editor.org/info/rfc3550/#section-6.4.1)
+    if (last_sr == 0) {
+      // "If no SR has been received yet, the field is set to zero."
+      continue;
+    }
+    if (auto it = outgoing_srs_.find({sender_ssrc, last_sr});
+        it != outgoing_srs_.end()) {
+      if (block.delay_since_last_sr() == 0) {
+        // "If no SR packet has been received yet from SSRC_n, the DLSR field "
+        // "is set to zero."
+        continue;
+      }
+      TimeDelta delay_since_last_sr =
+          CompactNtpIntervalToTimeDelta(block.delay_since_last_sr());
+      // "It calculates the total round-trip time A-LSR using the"
+      // "last SR timestamp (LSR) field, and then subtracting this field to"
+      // "leave the round-trip propagation delay as (A - LSR - DLSR)."
+      TimeDelta rtt = now - it->second.sent_time - delay_since_last_sr;
+      if (rtt <= TimeDelta::Zero()) {
+        RTC_LOG(LS_INFO) << "Ignoring non-positive RTT: " << rtt.ms()
+                         << "ms (now: " << now.ms()
+                         << ", sent: " << it->second.sent_time.ms()
+                         << ", delay_since_last_sr: "
+                         << delay_since_last_sr.ms() << ")";
+        continue;
+      }
+      rtt_samples.push_back(rtt);
+    }
+  }
+  return rtt_samples;
+}
+
+void RtcpRttCalculator::CleanOldReports(Timestamp now) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  RTC_DCHECK(now.IsFinite());
+  constexpr TimeDelta kCleanupTimeout = TimeDelta::Minutes(1);
+  absl::erase_if(outgoing_srs_, [&](const auto& kv) {
+    return now - kv.second.sent_time > kCleanupTimeout;
+  });
+  absl::erase_if(outgoing_xrs_, [&](const auto& kv) {
+    return now - kv.second.sent_time > kCleanupTimeout;
+  });
+}
+
+}  // namespace webrtc::video_timing_simulator
diff --git a/video/timing/simulator/rtcp_rtt_calculator.h b/video/timing/simulator/rtcp_rtt_calculator.h
new file mode 100644
index 0000000..4835d44
--- /dev/null
+++ b/video/timing/simulator/rtcp_rtt_calculator.h
@@ -0,0 +1,100 @@
+/*
+ *  Copyright (c) 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#ifndef VIDEO_TIMING_SIMULATOR_RTCP_RTT_CALCULATOR_H_
+#define VIDEO_TIMING_SIMULATOR_RTCP_RTT_CALCULATOR_H_
+
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+#include "absl/container/flat_hash_map.h"
+#include "api/sequence_checker.h"
+#include "api/units/time_delta.h"
+#include "api/units/timestamp.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/extended_reports.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/receiver_report.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/report_block.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/sender_report.h"
+#include "rtc_base/thread_annotations.h"
+
+namespace webrtc::video_timing_simulator {
+
+// Calculates Round Trip Time (RTT) samples by pairing outgoing reports with
+// incoming feedback. It covers all standard RTCP RTT mechanisms:
+//
+// 1. Sender RTT (SR/RR):
+//    Maps outgoing Sender Reports (SR) to incoming Receiver Reports (RR) or
+//    incoming Sender Reports (SR) containing reception report blocks.
+//
+// 2. Receiver RTT (XR RRTR/DLRR):
+//    Maps outgoing Extended Reports (XR) containing Receiver Reference Time
+//    Reports (RRTR) to incoming XRs containing Delay since Last RR (DLRR).
+//
+// Outgoing reports are cached for up to 1 minute before being cleaned up.
+class RtcpRttCalculator {
+ public:
+  RtcpRttCalculator();
+  ~RtcpRttCalculator();
+
+  RtcpRttCalculator(const RtcpRttCalculator&) = delete;
+  RtcpRttCalculator& operator=(const RtcpRttCalculator&) = delete;
+
+  // Registers an outgoing SR to map incoming RRs to.
+  void OnOutgoingSenderReport(const rtcp::SenderReport& sr, Timestamp now);
+
+  // Registers an outgoing XR (specifically RRTR) to map incoming XRs (DLRR) to.
+  void OnOutgoingExtendedReports(const rtcp::ExtendedReports& xr,
+                                 Timestamp now);
+
+  // Processes an incoming SR and returns a list of calculated RTTs.
+  std::vector<TimeDelta> OnIncomingSenderReport(const rtcp::SenderReport& sr,
+                                                Timestamp now);
+
+  // Processes an incoming RR and returns a list of calculated RTTs.
+  std::vector<TimeDelta> OnIncomingReceiverReport(
+      const rtcp::ReceiverReport& rr,
+      Timestamp now);
+
+  // Processes an incoming XR and returns a list of calculated RTTs.
+  std::vector<TimeDelta> OnIncomingExtendedReports(
+      const rtcp::ExtendedReports& xr,
+      Timestamp now);
+
+ private:
+  // See https://www.rfc-editor.org/info/rfc3550/#section-6.4.1.
+  using SentReportKey =
+      std::pair</*sender_ssrc=*/uint32_t, /*compact_ntp=*/uint32_t>;
+  struct SentReportValue {
+    Timestamp sent_time = Timestamp::MinusInfinity();
+  };
+
+  // Helper to process report blocks from either SR or RR.
+  std::vector<TimeDelta> ProcessReportBlocks(
+      const std::vector<rtcp::ReportBlock>& report_blocks,
+      Timestamp now);
+
+  // Cleans up registered outgoing reports that are older than 1 minute.
+  void CleanOldReports(Timestamp now);
+
+  SequenceChecker sequence_checker_;
+
+  // Outgoing SRs: (sender_ssrc, lsr) -> sent_time.
+  absl::flat_hash_map<SentReportKey, SentReportValue> outgoing_srs_
+      RTC_GUARDED_BY(sequence_checker_);
+
+  // Outgoing XRs: (sender_ssrc, lrr) -> sent_time.
+  absl::flat_hash_map<SentReportKey, SentReportValue> outgoing_xrs_
+      RTC_GUARDED_BY(sequence_checker_);
+};
+
+}  // namespace webrtc::video_timing_simulator
+
+#endif  // VIDEO_TIMING_SIMULATOR_RTCP_RTT_CALCULATOR_H_
diff --git a/video/timing/simulator/rtcp_rtt_calculator_unittest.cc b/video/timing/simulator/rtcp_rtt_calculator_unittest.cc
new file mode 100644
index 0000000..bc5a527
--- /dev/null
+++ b/video/timing/simulator/rtcp_rtt_calculator_unittest.cc
@@ -0,0 +1,242 @@
+/*
+ *  Copyright (c) 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#include "video/timing/simulator/rtcp_rtt_calculator.h"
+
+#include <cstdint>
+#include <vector>
+
+#include "api/units/time_delta.h"
+#include "api/units/timestamp.h"
+#include "modules/rtp_rtcp/source/ntp_time_util.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/dlrr.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/extended_reports.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/receiver_report.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/report_block.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/rrtr.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/sender_report.h"
+#include "system_wrappers/include/clock.h"
+#include "system_wrappers/include/ntp_time.h"
+#include "test/gmock.h"
+#include "test/gtest.h"
+#include "test/near_matcher.h"
+
+namespace webrtc::video_timing_simulator {
+namespace {
+
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+
+constexpr uint32_t kSenderSsrc = 123456;
+constexpr uint32_t kReceiverSsrc = 987654;
+
+rtcp::SenderReport CreateSr(NtpTime ntp) {
+  rtcp::SenderReport sr;
+  sr.SetSenderSsrc(kSenderSsrc);
+  sr.SetNtp(ntp);
+  return sr;
+}
+
+rtcp::SenderReport CreateSrWithReportBlock(NtpTime ntp,
+                                           uint32_t last_sr,
+                                           uint32_t delay_since_last_sr) {
+  rtcp::SenderReport sr = CreateSr(ntp);
+  rtcp::ReportBlock block;
+  block.SetMediaSsrc(kSenderSsrc);
+  block.SetLastSr(last_sr);
+  block.SetDelayLastSr(delay_since_last_sr);
+  sr.AddReportBlock(block);
+  return sr;
+}
+
+rtcp::ReceiverReport CreateRrWithReportBlock(uint32_t last_sr,
+                                             uint32_t delay_since_last_sr) {
+  rtcp::ReceiverReport rr;
+  rr.SetSenderSsrc(kReceiverSsrc);
+  rtcp::ReportBlock block;
+  block.SetMediaSsrc(kSenderSsrc);
+  block.SetLastSr(last_sr);
+  block.SetDelayLastSr(delay_since_last_sr);
+  rr.AddReportBlock(block);
+  return rr;
+}
+
+rtcp::ExtendedReports CreateXrWithRrtr(NtpTime ntp) {
+  rtcp::ExtendedReports xr;
+  xr.SetSenderSsrc(kReceiverSsrc);
+  rtcp::Rrtr rrtr;
+  rrtr.SetNtp(ntp);
+  xr.SetRrtr(rrtr);
+  return xr;
+}
+
+rtcp::ExtendedReports CreateXrWithDlrr(uint32_t last_rr,
+                                       uint32_t delay_since_last_rr) {
+  rtcp::ExtendedReports xr;
+  xr.SetSenderSsrc(kSenderSsrc);
+  rtcp::ReceiveTimeInfo dlrr_block;
+  dlrr_block.ssrc = kReceiverSsrc;
+  dlrr_block.last_rr = last_rr;
+  dlrr_block.delay_since_last_rr = delay_since_last_rr;
+  xr.AddDlrrItem(dlrr_block);
+  return xr;
+}
+
+TEST(RtcpRttCalculatorTest, SenderCalculatesRttFromIncomingSr) {
+  RtcpRttCalculator calculator;
+  SimulatedClock clock(Timestamp::Millis(10000));
+
+  // Outgoing SR.
+  NtpTime ntp = clock.ConvertTimestampToNtpTime(clock.CurrentTime());
+  rtcp::SenderReport sr = CreateSr(ntp);
+  calculator.OnOutgoingSenderReport(sr, clock.CurrentTime());
+
+  // Incoming SR: 50ms delay, arrives after 150ms => RTT is 100ms.
+  clock.AdvanceTime(TimeDelta::Millis(150));
+  uint32_t last_sr = CompactNtp(ntp);
+  uint32_t delay_since_last_sr = SaturatedToCompactNtp(TimeDelta::Millis(50));
+  NtpTime ntp_incoming = clock.ConvertTimestampToNtpTime(clock.CurrentTime());
+  rtcp::SenderReport sr_incoming =
+      CreateSrWithReportBlock(ntp_incoming, last_sr, delay_since_last_sr);
+  std::vector<TimeDelta> rtts =
+      calculator.OnIncomingSenderReport(sr_incoming, clock.CurrentTime());
+
+  EXPECT_THAT(rtts, ElementsAre(Near(TimeDelta::Millis(100))));
+}
+
+TEST(RtcpRttCalculatorTest, SenderCalculatesRttFromIncomingRr) {
+  RtcpRttCalculator calculator;
+  SimulatedClock clock(Timestamp::Millis(10000));
+
+  // Outgoing SR.
+  NtpTime ntp = clock.ConvertTimestampToNtpTime(clock.CurrentTime());
+  rtcp::SenderReport sr = CreateSr(ntp);
+  calculator.OnOutgoingSenderReport(sr, clock.CurrentTime());
+
+  // Incoming RR: 50ms delay, arrives after 150ms => RTT is 100ms.
+  clock.AdvanceTime(TimeDelta::Millis(150));
+  uint32_t last_sr = CompactNtp(ntp);
+  uint32_t delay_since_last_sr = SaturatedToCompactNtp(TimeDelta::Millis(50));
+  rtcp::ReceiverReport rr =
+      CreateRrWithReportBlock(last_sr, delay_since_last_sr);
+  std::vector<TimeDelta> rtts =
+      calculator.OnIncomingReceiverReport(rr, clock.CurrentTime());
+
+  EXPECT_THAT(rtts, ElementsAre(Near(TimeDelta::Millis(100))));
+}
+
+TEST(RtcpRttCalculatorTest, ReceiverCalculatesRttFromIncomingXr) {
+  RtcpRttCalculator calculator;
+  SimulatedClock clock(Timestamp::Millis(10000));
+
+  // Outgoing XR with RRTR.
+  NtpTime ntp = clock.ConvertTimestampToNtpTime(clock.CurrentTime());
+  rtcp::ExtendedReports xr_rrtr = CreateXrWithRrtr(ntp);
+  calculator.OnOutgoingExtendedReports(xr_rrtr, clock.CurrentTime());
+
+  // Incoming XR with DLRR: 50ms delay, arrives after 150ms  => RTT is 100ms.
+  clock.AdvanceTime(TimeDelta::Millis(150));
+  uint32_t last_rr = CompactNtp(ntp);
+  uint32_t delay_since_last_rr = SaturatedToCompactNtp(TimeDelta::Millis(50));
+  rtcp::ExtendedReports xr_dlrr =
+      CreateXrWithDlrr(last_rr, delay_since_last_rr);
+  std::vector<TimeDelta> rtts =
+      calculator.OnIncomingExtendedReports(xr_dlrr, clock.CurrentTime());
+
+  EXPECT_THAT(rtts, ElementsAre(Near(TimeDelta::Millis(100))));
+}
+
+TEST(RtcpRttCalculatorTest, SenderCalculatesRttForMultipleRrsMatchingSingleSr) {
+  RtcpRttCalculator calculator;
+  SimulatedClock clock(Timestamp::Millis(10000));
+
+  // Single outgoing SR.
+  NtpTime ntp = clock.ConvertTimestampToNtpTime(clock.CurrentTime());
+  rtcp::SenderReport sr = CreateSr(ntp);
+  calculator.OnOutgoingSenderReport(sr, clock.CurrentTime());
+
+  // First incoming RR: 50ms delay, arrives after 150ms => RTT is 100ms.
+  clock.AdvanceTime(TimeDelta::Millis(150));
+  uint32_t last_sr = CompactNtp(ntp);
+  uint32_t delay_since_last_sr1 = SaturatedToCompactNtp(TimeDelta::Millis(50));
+  rtcp::ReceiverReport rr1 =
+      CreateRrWithReportBlock(last_sr, delay_since_last_sr1);
+  std::vector<TimeDelta> rtts1 =
+      calculator.OnIncomingReceiverReport(rr1, clock.CurrentTime());
+  EXPECT_THAT(rtts1, ElementsAre(Near(TimeDelta::Millis(100))));
+
+  // Second incoming RR: 150ms delay, arrives after another 100ms (total 250ms
+  // since SR)
+  // => RTT is 100ms.
+  clock.AdvanceTime(TimeDelta::Millis(100));
+  uint32_t delay_since_last_sr2 = SaturatedToCompactNtp(TimeDelta::Millis(150));
+  rtcp::ReceiverReport rr2 =
+      CreateRrWithReportBlock(last_sr, delay_since_last_sr2);
+  std::vector<TimeDelta> rtts2 =
+      calculator.OnIncomingReceiverReport(rr2, clock.CurrentTime());
+  EXPECT_THAT(rtts2, ElementsAre(Near(TimeDelta::Millis(100))));
+}
+
+TEST(RtcpRttCalculatorTest, RejectsNegativeRtt) {
+  RtcpRttCalculator calculator;
+  SimulatedClock clock(Timestamp::Millis(10000));
+
+  // Outgoing SR.
+  NtpTime ntp = clock.ConvertTimestampToNtpTime(clock.CurrentTime());
+  rtcp::SenderReport sr = CreateSr(ntp);
+  calculator.OnOutgoingSenderReport(sr, clock.CurrentTime());
+
+  // Incoming RR with delay 100ms (RTT would be 50ms - 100ms = -50ms).
+  clock.AdvanceTime(TimeDelta::Millis(50));
+  uint32_t last_sr = CompactNtp(ntp);
+  uint32_t delay_since_last_sr = SaturatedToCompactNtp(TimeDelta::Millis(100));
+  rtcp::ReceiverReport rr =
+      CreateRrWithReportBlock(last_sr, delay_since_last_sr);
+  std::vector<TimeDelta> rtts =
+      calculator.OnIncomingReceiverReport(rr, clock.CurrentTime());
+
+  EXPECT_THAT(rtts, IsEmpty());
+}
+
+TEST(RtcpRttCalculatorTest, IgnoresRrWithZeroLastSr) {
+  RtcpRttCalculator calculator;
+  SimulatedClock clock(Timestamp::Millis(10000));
+
+  // Incoming RR with last_sr = 0 (means no SR received yet).
+  rtcp::ReceiverReport rr = CreateRrWithReportBlock(/*last_sr=*/0, 0);
+  std::vector<TimeDelta> rtts =
+      calculator.OnIncomingReceiverReport(rr, clock.CurrentTime());
+
+  EXPECT_THAT(rtts, IsEmpty());
+}
+
+TEST(RtcpRttCalculatorTest, CleansOldReportsAfterOneMinute) {
+  RtcpRttCalculator calculator;
+  SimulatedClock clock(Timestamp::Millis(10000));
+
+  // Outgoing SR.
+  NtpTime ntp = clock.ConvertTimestampToNtpTime(clock.CurrentTime());
+  rtcp::SenderReport sr = CreateSr(ntp);
+  calculator.OnOutgoingSenderReport(sr, clock.CurrentTime());
+
+  // Advance time by 61 seconds (timeout is 60 seconds).
+  clock.AdvanceTime(TimeDelta::Seconds(61));
+
+  // Incoming RR: should be ignored because the SR was cleaned up.
+  uint32_t last_sr = CompactNtp(ntp);
+  rtcp::ReceiverReport rr = CreateRrWithReportBlock(last_sr, 0);
+  std::vector<TimeDelta> rtts =
+      calculator.OnIncomingReceiverReport(rr, clock.CurrentTime());
+
+  EXPECT_THAT(rtts, IsEmpty());
+}
+
+}  // namespace
+}  // namespace webrtc::video_timing_simulator