Video timing simulator: Improve frame and stream metrics

This change introduces `FrameBase` and `StreamBase`, that are base
classes for the corresponding derived frame and stream classes for
decodability and rendering simulation. Their purpose is to share value
accessors for RTP-level information, while retaining the data members in
the derived structs.

Additionally, we restructure the inter-frame metrics a bit, add
compararators and sorting, and add some more jitter buffer metrics.

Bug: b/423646186
Change-Id: Ida87c44b05e94420dbf218b4299cec97addfce64
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/443220
Reviewed-by: Åsa Persson <asapersson@webrtc.org>
Commit-Queue: Rasmus Brandt <brandtr@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#46724}
diff --git a/video/timing/simulator/BUILD.gn b/video/timing/simulator/BUILD.gn
index 113da9e..810abe4 100644
--- a/video/timing/simulator/BUILD.gn
+++ b/video/timing/simulator/BUILD.gn
@@ -17,6 +17,7 @@
       "decodability_simulator.h",
       "decodability_tracker.cc",
       "decodability_tracker.h",
+      "frame_base.h",
       "rendering_simulator.cc",
       "rendering_simulator.h",
       "rendering_tracker.cc",
@@ -25,6 +26,7 @@
       "rtc_event_log_driver.h",
       "rtp_packet_simulator.cc",
       "rtp_packet_simulator.h",
+      "stream_base.h",
     ]
     deps = [
       "../../../api:array_view",
@@ -37,6 +39,7 @@
       "../../../api:transport_api",
       "../../../api/environment",
       "../../../api/environment:environment_factory",
+      "../../../api/numerics",
       "../../../api/task_queue",
       "../../../api/task_queue:pending_task_safety_flag",
       "../../../api/units:data_size",
@@ -94,10 +97,12 @@
         "assembler_unittest.cc",
         "decodability_simulator_unittest.cc",
         "decodability_tracker_unittest.cc",
+        "frame_base_unittest.cc",
         "rendering_simulator_unittest.cc",
         "rendering_tracker_unittest.cc",
         "rtc_event_log_driver_unittest.cc",
         "rtp_packet_simulator_unittest.cc",
+        "stream_base_unittest.cc",
       ]
       deps = [
         ":simulator",
diff --git a/video/timing/simulator/decodability_simulator.cc b/video/timing/simulator/decodability_simulator.cc
index 4f7b98f..06b4dd6 100644
--- a/video/timing/simulator/decodability_simulator.cc
+++ b/video/timing/simulator/decodability_simulator.cc
@@ -15,7 +15,6 @@
 #include <optional>
 #include <utility>
 
-#include "absl/algorithm/container.h"
 #include "absl/base/nullability.h"
 #include "absl/container/flat_hash_map.h"
 #include "api/environment/environment.h"
@@ -31,6 +30,7 @@
 #include "rtc_base/thread_annotations.h"
 #include "video/timing/simulator/assembler.h"
 #include "video/timing/simulator/decodability_tracker.h"
+#include "video/timing/simulator/frame_base.h"
 #include "video/timing/simulator/rtc_event_log_driver.h"
 
 namespace webrtc::video_timing_simulator {
@@ -103,7 +103,7 @@
     for (const auto& [key, value] : frames_) {
       stream.frames.push_back(value);
     }
-    absl::c_sort(stream.frames);
+    SortByArrivalOrder(stream.frames);
     return stream;
   }
 
@@ -184,7 +184,7 @@
   rtc_event_log_simulator.Simulate();
 
   // Return.
-  absl::c_sort(results.streams);
+  SortByStreamOrder(results.streams);
   return results;
 }
 
diff --git a/video/timing/simulator/decodability_simulator.h b/video/timing/simulator/decodability_simulator.h
index adb8d90..527a750 100644
--- a/video/timing/simulator/decodability_simulator.h
+++ b/video/timing/simulator/decodability_simulator.h
@@ -12,13 +12,16 @@
 #define VIDEO_TIMING_SIMULATOR_DECODABILITY_SIMULATOR_H_
 
 #include <cstdint>
-#include <optional>
 #include <vector>
 
+#include "absl/algorithm/container.h"
+#include "api/array_view.h"
 #include "api/units/data_size.h"
 #include "api/units/time_delta.h"
 #include "api/units/timestamp.h"
 #include "logging/rtc_event_log/rtc_event_log_parser.h"
+#include "video/timing/simulator/frame_base.h"
+#include "video/timing/simulator/stream_base.h"
 
 namespace webrtc::video_timing_simulator {
 
@@ -34,7 +37,8 @@
   };
 
   // Metadata about a single decodable frame.
-  struct Frame {
+  struct Frame : public FrameBase<Frame> {
+    // -- Values --
     // Frame information.
     int num_packets = -1;
     DataSize size = DataSize::Zero();
@@ -46,54 +50,25 @@
     Timestamp assembled_timestamp = Timestamp::PlusInfinity();
     Timestamp decodable_timestamp = Timestamp::PlusInfinity();
 
-    bool operator<(const Frame& other) const {
-      return decodable_timestamp < other.decodable_timestamp;
-    }
+    // -- Populated values --
+    // One-way delay relative some baseline.
+    TimeDelta frame_delay_variation = TimeDelta::PlusInfinity();
 
-    std::optional<int> InterPacketCount(const Frame& prev) const {
-      if (num_packets <= 0 || prev.num_packets <= 0) {
-        return std::nullopt;
-      }
-      return num_packets - prev.num_packets;
-    }
-    std::optional<int64_t> InterFrameSizeBytes(const Frame& prev) const {
-      if (size.IsZero() || prev.size.IsZero()) {
-        return std::nullopt;
-      }
-      return size.bytes() - prev.size.bytes();
-    }
-    TimeDelta InterDepartureTime(const Frame& prev) const {
-      if (unwrapped_rtp_timestamp < 0 || prev.unwrapped_rtp_timestamp < 0) {
-        return TimeDelta::PlusInfinity();
-      }
-      constexpr int64_t kRtpTicksPerMs = 90;
-      int64_t inter_departure_time_ms =
-          (unwrapped_rtp_timestamp - prev.unwrapped_rtp_timestamp) /
-          kRtpTicksPerMs;
-      return TimeDelta::Millis(inter_departure_time_ms);
-    }
-    TimeDelta InterAssemblyTime(const Frame& prev) const {
-      return assembled_timestamp - prev.assembled_timestamp;
-    }
-    TimeDelta InterArrivalTime(const Frame& prev) const {
-      return decodable_timestamp - prev.decodable_timestamp;
+    // -- Value accessors --
+    Timestamp ArrivalTimestampInternal() const { return decodable_timestamp; }
+
+    // -- Per-frame metrics --
+    // Time spent waiting for reference frames to arrive.
+    TimeDelta UndecodableDuration() const {
+      return decodable_timestamp - assembled_timestamp;
     }
   };
 
   // All frames in one stream.
-  struct Stream {
+  struct Stream : public StreamBase<Stream> {
     Timestamp creation_timestamp = Timestamp::PlusInfinity();
     uint32_t ssrc = 0;
     std::vector<Frame> frames;
-
-    bool IsEmpty() const { return frames.empty(); }
-
-    bool operator<(const Stream& other) const {
-      if (creation_timestamp != other.creation_timestamp) {
-        return creation_timestamp < other.creation_timestamp;
-      }
-      return ssrc < other.ssrc;
-    }
   };
 
   // All streams.
@@ -113,6 +88,23 @@
   const Config config_;
 };
 
+// -- Comparators and sorting --
+inline bool DecodableOrder(const DecodabilitySimulator::Frame& a,
+                           const DecodabilitySimulator::Frame& b) {
+  return a.decodable_timestamp < b.decodable_timestamp;
+}
+inline void SortByDecodableOrder(
+    ArrayView<DecodabilitySimulator::Frame> frames) {
+  absl::c_stable_sort(frames, DecodableOrder);
+}
+
+// -- Inter-frame metrics --
+// Difference in decodable time between two frames.
+inline TimeDelta InterDecodableTime(const DecodabilitySimulator::Frame& cur,
+                                    const DecodabilitySimulator::Frame& prev) {
+  return cur.decodable_timestamp - prev.decodable_timestamp;
+}
+
 }  // namespace webrtc::video_timing_simulator
 
 #endif  // VIDEO_TIMING_SIMULATOR_DECODABILITY_SIMULATOR_H_
diff --git a/video/timing/simulator/frame_base.h b/video/timing/simulator/frame_base.h
new file mode 100644
index 0000000..f1610df
--- /dev/null
+++ b/video/timing/simulator/frame_base.h
@@ -0,0 +1,148 @@
+/*
+ *  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_FRAME_BASE_H_
+#define VIDEO_TIMING_SIMULATOR_FRAME_BASE_H_
+
+#include <cstdint>
+#include <optional>
+#include <vector>
+
+#include "absl/algorithm/container.h"
+#include "api/units/time_delta.h"
+#include "api/units/timestamp.h"
+
+namespace webrtc::video_timing_simulator {
+
+// CRTP base struct for code reuse of departure and arrival timestamp functions.
+template <typename FrameT>
+struct FrameBase {
+  // Data members are defined in derived struct.
+
+  // -- CRTP accessor --
+  const FrameT& self() const { return static_cast<const FrameT&>(*this); }
+
+  // -- Value accessors --
+
+  // Departure time (possibly offset), as determined by RTP timestamp from
+  // the derived class.
+  Timestamp DepartureTimestamp(Timestamp offset = Timestamp::Zero()) const {
+    int64_t unwrapped_rtp_timestamp = self().unwrapped_rtp_timestamp;
+    if (unwrapped_rtp_timestamp < 0) {
+      return Timestamp::PlusInfinity();
+    }
+    constexpr int64_t kMicrosPerMillis = 1'000;
+    constexpr int64_t kRtpVideoTicksPerMillis = 90;
+    // Convert from RTP ticks to microseconds using integer division with
+    // truncation. Note that this introduces an error of up to 1us. That is fine
+    // for our purposes however: the arrival timestamp is logged in ms and the
+    // expected frame delay variation caused by the network is also on the order
+    // of ms.
+    int64_t departure_timestamp_us =
+        (unwrapped_rtp_timestamp * kMicrosPerMillis) / kRtpVideoTicksPerMillis;
+    return Timestamp::Micros(departure_timestamp_us - offset.us());
+  }
+
+  // Arrival time (possibly offset), as determined by
+  // `ArrivalTimestampInternal()` from the derived class. This allows derived
+  // classes to define themselves the meaning of "arrival": typically decodable
+  // or rendered, but could be assembled or decoded as well.
+  Timestamp ArrivalTimestamp(Timestamp offset = Timestamp::Zero()) const {
+    Timestamp arrival_timestamp = self().ArrivalTimestampInternal();
+    if (!arrival_timestamp.IsFinite()) {
+      return arrival_timestamp;
+    }
+    return Timestamp::Micros(arrival_timestamp.us() - offset.us());
+  }
+
+  // -- Per-frame metrics --
+  // One way delay with required timestamp offset normalization.
+  TimeDelta OneWayDelay(Timestamp arrival_offset,
+                        Timestamp departure_offset) const {
+    return ArrivalTimestamp(arrival_offset) -
+           DepartureTimestamp(departure_offset);
+  }
+};
+
+// -- Comparators and sorting --
+template <typename FrameT>
+bool DepartureOrder(const FrameT& a, const FrameT& b) {
+  return a.DepartureTimestamp() < b.DepartureTimestamp();
+}
+template <typename FrameT>
+void SortByDepartureOrder(std::vector<FrameT>& frames) {
+  absl::c_stable_sort(frames, DepartureOrder<FrameT>);
+}
+
+template <typename FrameT>
+bool ArrivalOrder(const FrameT& a, const FrameT& b) {
+  return a.ArrivalTimestamp() < b.ArrivalTimestamp();
+}
+template <typename FrameT>
+void SortByArrivalOrder(std::vector<FrameT>& frames) {
+  absl::c_stable_sort(frames, ArrivalOrder<FrameT>);
+}
+
+template <typename FrameT>
+inline bool AssembledOrder(const FrameT& a, const FrameT& b) {
+  return a.assembled_timestamp < b.assembled_timestamp;
+}
+template <typename FrameT>
+inline void SortByAssembledOrder(std::vector<FrameT> frames) {
+  absl::c_stable_sort(frames, AssembledOrder<FrameT>);
+}
+
+// --- Inter-frame metrics ---
+// Difference in packet counts between two frames.
+template <typename FrameT>
+std::optional<int> InterPacketCount(const FrameT& cur, const FrameT& prev) {
+  if (cur.num_packets <= 0 || prev.num_packets <= 0) {
+    return std::nullopt;
+  }
+  return cur.num_packets - prev.num_packets;
+}
+
+// Difference in frame size (bytes) between two frames.
+template <typename FrameT>
+std::optional<int64_t> InterFrameSizeBytes(const FrameT& cur,
+                                           const FrameT& prev) {
+  if (cur.size.IsZero() || prev.size.IsZero()) {
+    return std::nullopt;
+  }
+  return cur.size.bytes() - prev.size.bytes();
+}
+
+// Difference in departure timestamp between two frames.
+template <typename FrameT>
+TimeDelta InterDepartureTime(const FrameT& cur, const FrameT& prev) {
+  return cur.DepartureTimestamp() - prev.DepartureTimestamp();
+}
+
+// Difference in arrival timestamp between two frames.
+template <typename FrameT>
+TimeDelta InterArrivalTime(const FrameT& cur, const FrameT& prev) {
+  return cur.ArrivalTimestamp() - prev.ArrivalTimestamp();
+}
+
+// https://datatracker.ietf.org/doc/html/rfc5481#section-1
+template <typename FrameT>
+TimeDelta InterFrameDelayVariation(const FrameT& cur, const FrameT& prev) {
+  return InterArrivalTime(cur, prev) - InterDepartureTime(cur, prev);
+}
+
+// Difference in assembled timestamp between two frames.
+template <typename FrameT>
+TimeDelta InterAssembledTime(const FrameT& cur, const FrameT& prev) {
+  return cur.assembled_timestamp - prev.assembled_timestamp;
+}
+
+}  // namespace webrtc::video_timing_simulator
+
+#endif  // VIDEO_TIMING_SIMULATOR_FRAME_BASE_H_
diff --git a/video/timing/simulator/frame_base_unittest.cc b/video/timing/simulator/frame_base_unittest.cc
new file mode 100644
index 0000000..63829f8
--- /dev/null
+++ b/video/timing/simulator/frame_base_unittest.cc
@@ -0,0 +1,117 @@
+/*
+ *  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/frame_base.h"
+
+#include <cstdint>
+#include <vector>
+
+#include "api/units/time_delta.h"
+#include "api/units/timestamp.h"
+#include "test/gtest.h"
+
+namespace webrtc::video_timing_simulator {
+namespace {
+
+constexpr int64_t kMicrosPerMillis = 1000;
+constexpr int64_t kRtpVideoTicksPerMillis = 90;
+
+struct TestFrame : public FrameBase<TestFrame> {
+  int64_t unwrapped_rtp_timestamp = -1;
+  Timestamp assembled_timestamp = Timestamp::PlusInfinity();
+  Timestamp ArrivalTimestampInternal() const { return assembled_timestamp; }
+};
+
+TEST(FrameBaseTest, DepartureTimestampIsInvalidForUnsetRtpTimestamp) {
+  TestFrame frame;
+  EXPECT_FALSE(frame.DepartureTimestamp().IsFinite());
+}
+
+TEST(FrameBaseTest, DepartureTimestamp) {
+  TestFrame frame{.unwrapped_rtp_timestamp = 3000};
+  EXPECT_EQ(frame.DepartureTimestamp(), Timestamp::Micros(33333));
+}
+
+TEST(FrameBaseTest, DepartureTimestampWithOffset) {
+  int64_t rtp_timestamp_offset = 123456789;
+  Timestamp departure_timestamp_offset = Timestamp::Micros(
+      (rtp_timestamp_offset * kMicrosPerMillis) / kRtpVideoTicksPerMillis);
+  TestFrame frame{.unwrapped_rtp_timestamp = rtp_timestamp_offset + 3000};
+  EXPECT_EQ(frame.DepartureTimestamp(departure_timestamp_offset),
+            Timestamp::Micros(33333));
+}
+
+TEST(FrameBaseTest, ArrivalTimestampIsInvalidForUnsetRtpTimestamp) {
+  TestFrame frame;
+  EXPECT_FALSE(frame.ArrivalTimestamp().IsFinite());
+}
+
+TEST(FrameBaseTest, ArrivalTimestamp) {
+  TestFrame frame{.assembled_timestamp = Timestamp::Micros(33333)};
+  EXPECT_EQ(frame.ArrivalTimestamp(), Timestamp::Micros(33333));
+}
+
+TEST(FrameBaseTest, ArrivalTimestampWithOffset) {
+  Timestamp arrival_timestamp_offset = Timestamp::Seconds(123456789);
+  TestFrame frame{.assembled_timestamp =
+                      Timestamp::Micros(arrival_timestamp_offset.us() + 33333)};
+  EXPECT_EQ(frame.ArrivalTimestamp(arrival_timestamp_offset),
+            Timestamp::Micros(33333));
+}
+
+TEST(FrameBaseTest, OneWayDelayWithZeroOffsets) {
+  TestFrame frame1{.unwrapped_rtp_timestamp = 3000,
+                   .assembled_timestamp = Timestamp::Micros(33333)};
+  EXPECT_EQ(frame1.OneWayDelay(
+                /*arrival_offset=*/Timestamp::Zero(),
+                /*departure_offset=*/Timestamp::Zero()),
+            TimeDelta::Zero());
+
+  // Delayed 1000us relative to its nominal arrival time.
+  TestFrame frame2{.unwrapped_rtp_timestamp = 6000,
+                   .assembled_timestamp = Timestamp::Micros(67666)};
+  EXPECT_EQ(frame2.OneWayDelay(
+                /*arrival_offset=*/Timestamp::Zero(),
+                /*departure_offset=*/Timestamp::Zero()),
+            TimeDelta::Micros(1000));
+}
+
+TEST(FrameBaseTest, OneWayDelayWithOffsets) {
+  int64_t rtp_timestamp_offset = 123456789;
+  Timestamp departure_timestamp_offset = Timestamp::Micros(
+      (rtp_timestamp_offset * kMicrosPerMillis) / kRtpVideoTicksPerMillis);
+  Timestamp arrival_timestamp_offset = Timestamp::Seconds(123456789);
+
+  TestFrame frame1{.unwrapped_rtp_timestamp = rtp_timestamp_offset + 3000,
+                   .assembled_timestamp = Timestamp::Micros(
+                       arrival_timestamp_offset.us() + 33333)};
+  EXPECT_EQ(
+      frame1.OneWayDelay(arrival_timestamp_offset, departure_timestamp_offset),
+      TimeDelta::Zero());
+
+  // Delayed 1000us relative to its nominal arrival time.
+  TestFrame frame2{.unwrapped_rtp_timestamp = rtp_timestamp_offset + 6000,
+                   .assembled_timestamp = Timestamp::Micros(
+                       arrival_timestamp_offset.us() + 67666)};
+  EXPECT_EQ(
+      frame2.OneWayDelay(arrival_timestamp_offset, departure_timestamp_offset),
+      TimeDelta::Micros(1000));
+}
+
+TEST(FrameBaseTest, SortingTemplatesCompile) {
+  std::vector<TestFrame> test_frames = {
+      TestFrame{.unwrapped_rtp_timestamp = 3000}};
+  SortByDepartureOrder(test_frames);
+  SortByArrivalOrder(test_frames);
+  SortByAssembledOrder(test_frames);
+}
+
+}  // namespace
+}  // namespace webrtc::video_timing_simulator
diff --git a/video/timing/simulator/rendering_simulator.cc b/video/timing/simulator/rendering_simulator.cc
index 71ba065..1293c8d 100644
--- a/video/timing/simulator/rendering_simulator.cc
+++ b/video/timing/simulator/rendering_simulator.cc
@@ -16,7 +16,6 @@
 #include <optional>
 #include <utility>
 
-#include "absl/algorithm/container.h"
 #include "absl/base/nullability.h"
 #include "absl/container/flat_hash_map.h"
 #include "api/environment/environment.h"
@@ -33,6 +32,7 @@
 #include "rtc_base/numerics/sequence_number_unwrapper.h"
 #include "rtc_base/thread_annotations.h"
 #include "video/timing/simulator/assembler.h"
+#include "video/timing/simulator/frame_base.h"
 #include "video/timing/simulator/rendering_tracker.h"
 #include "video/timing/simulator/rtc_event_log_driver.h"
 
@@ -151,7 +151,7 @@
       RTC_DCHECK_EQ(key, value.frame_id);
       stream.frames.push_back(value);
     }
-    absl::c_sort(stream.frames);
+    SortByArrivalOrder(stream.frames);
     return stream;
   }
 
@@ -240,7 +240,7 @@
   rtc_event_log_simulator.Simulate();
 
   // Return.
-  absl::c_sort(results.streams);
+  SortByStreamOrder(results.streams);
   return results;
 }
 
diff --git a/video/timing/simulator/rendering_simulator.h b/video/timing/simulator/rendering_simulator.h
index 439509f..8e74cbf 100644
--- a/video/timing/simulator/rendering_simulator.h
+++ b/video/timing/simulator/rendering_simulator.h
@@ -18,12 +18,16 @@
 #include <string>
 #include <vector>
 
+#include "absl/algorithm/container.h"
+#include "api/array_view.h"
 #include "api/environment/environment.h"
 #include "api/units/data_size.h"
 #include "api/units/time_delta.h"
 #include "api/units/timestamp.h"
 #include "logging/rtc_event_log/rtc_event_log_parser.h"
 #include "modules/video_coding/timing/timing.h"
+#include "video/timing/simulator/frame_base.h"
+#include "video/timing/simulator/stream_base.h"
 
 namespace webrtc::video_timing_simulator {
 
@@ -48,7 +52,8 @@
   };
 
   // Metadata about a single rendered frame.
-  struct Frame {
+  struct Frame : public FrameBase<Frame> {
+    // -- Values --
     // Frame information.
     int num_packets = -1;
     DataSize size = DataSize::Zero();
@@ -77,59 +82,83 @@
     // Jitter buffer state at the time of this frame.
     int frames_dropped = -1;
     // TODO: b/423646186 - Add `current_delay_ms`.
+    // The `jitter_buffer_*` metrics below are recorded by the production code,
+    // and should be compatible with the `webrtc-stats` definitions. One major
+    // difference is that they are _not_ cumulative.
+    // https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats-jitterbufferminimumdelay
     TimeDelta jitter_buffer_minimum_delay = TimeDelta::MinusInfinity();
+    // https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats-jitterbuffertargetdelay
     TimeDelta jitter_buffer_target_delay = TimeDelta::MinusInfinity();
+    // https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats-jitterbufferdelay
     TimeDelta jitter_buffer_delay = TimeDelta::MinusInfinity();
 
-    bool operator<(const Frame& other) const {
-      return rendered_timestamp < other.rendered_timestamp;
-    }
+    // -- Populated values --
+    // One-way delay relative some baseline.
+    TimeDelta frame_delay_variation = TimeDelta::PlusInfinity();
 
-    std::optional<int64_t> InterFrameSizeBytes(const Frame& prev) const {
-      if (size.IsZero() || prev.size.IsZero()) {
-        return std::nullopt;
-      }
-      return size.bytes() - prev.size.bytes();
+    // -- Value accessors --
+    Timestamp ArrivalTimestampInternal() const { return rendered_timestamp; }
+
+    // -- Per-frame metrics --
+    // Time spent being assembled (waiting for all packets to arrive).
+    TimeDelta PacketBufferDuration() const {
+      return assembled_timestamp - first_packet_arrival_timestamp;
     }
-    TimeDelta InterDepartureTime(const Frame& prev) const {
-      if (unwrapped_rtp_timestamp < 0 || prev.unwrapped_rtp_timestamp < 0) {
-        return TimeDelta::PlusInfinity();
-      }
-      constexpr int64_t kRtpTicksPerMs = 90;
-      int64_t inter_departure_time_ms =
-          (unwrapped_rtp_timestamp - prev.unwrapped_rtp_timestamp) /
-          kRtpTicksPerMs;
-      return TimeDelta::Millis(inter_departure_time_ms);
-    }
-    TimeDelta InterArrivalTime(const Frame& prev) const {
-      return rendered_timestamp - prev.rendered_timestamp;
-    }
-    TimeDelta AssemblyDuration() const {
-      return last_packet_arrival_timestamp - first_packet_arrival_timestamp;
-    }
-    TimeDelta PreDecodeBufferDuration() const {
+    // Time spent waiting to be decoded, after assembly. (This includes a,
+    // currently, zero decode duration.) Note that this is similar to
+    // `jitter_buffer_delay`, except that the latter is 1) recorded by the
+    // production code; and, 2) is anchored on `first_packet_arrival_timestamp`
+    // rather than `assembled_timestamp`.
+    TimeDelta FrameBufferDuration() const {
       return decoded_timestamp - assembled_timestamp;
     }
-    TimeDelta PostDecodeMargin() const {
-      return render_timestamp - rendered_timestamp -
-             RenderingSimulator::kRenderDelay;
+    // Time spent waiting to be rendered, after decode.
+    TimeDelta RenderBufferDuration() const {
+      return rendered_timestamp - decoded_timestamp;
+    }
+    // Margin between render timestamp (target) and
+    // assembled timestamp (actual):
+    //   * A frame that is assembled early w.r.t. the target (<=> arriving
+    //     on time from the network) has a positive margin.
+    //   * A frame that is assembled on time w.r.t. the target (<=> arriving
+    //     slightly late from the network) has zero margin.
+    //   * A frame that is assembled late w.r.t. the target (<=> arriving
+    //     very late from the network) has negative margin.
+    // Positive margins mean no video freezes, at the cost of receiver delay.
+    // A jitter buffer needs to strike a balance between video freezes and
+    // delay. In terms of margin, that means low positive margin values, a
+    // couple of frames with zero margin, and very few frames with
+    // negative margin.
+    TimeDelta RenderMargin() const {
+      return render_timestamp - assembled_timestamp;
+    }
+    // Split the margin along zero: "excess margin" for positive margins and
+    // "deficit margin" for negative margins.
+    // A jitter buffer would generally want to minimize the number of frames
+    // with a deficit margin (delayed frames/buffer underruns => video freezes),
+    // while also minimizing the stream-level min/p10 of the excess margin
+    // (early frames spending a long time in the buffer => high latency).
+    std::optional<TimeDelta> RenderedExcessMargin() const {
+      TimeDelta margin = RenderMargin();
+      if (margin < TimeDelta::Zero()) {
+        return std::nullopt;
+      }
+      return margin;
+    }
+    std::optional<TimeDelta> RenderedDeficitMargin() const {
+      TimeDelta margin = RenderMargin();
+      if (margin > TimeDelta::Zero()) {
+        return std::nullopt;
+      }
+      return margin;
     }
   };
 
   // All frames in one stream.
-  struct Stream {
+  struct Stream : public StreamBase<Stream> {
     Timestamp creation_timestamp = Timestamp::PlusInfinity();
     uint32_t ssrc = 0;
     std::vector<Frame> frames;
-
-    bool IsEmpty() const { return frames.empty(); }
-
-    bool operator<(const Stream& other) const {
-      if (creation_timestamp != other.creation_timestamp) {
-        return creation_timestamp < other.creation_timestamp;
-      }
-      return ssrc < other.ssrc;
-    }
   };
 
   // All streams.
@@ -153,6 +182,50 @@
   const Config config_;
 };
 
+// -- Comparators and sorting --
+inline bool RenderOrder(const RenderingSimulator::Frame& a,
+                        const RenderingSimulator::Frame& b) {
+  return a.render_timestamp < b.render_timestamp;
+}
+inline void SortByRenderOrder(ArrayView<RenderingSimulator::Frame> frames) {
+  absl::c_stable_sort(frames, RenderOrder);
+}
+
+inline bool DecodedOrder(const RenderingSimulator::Frame& a,
+                         const RenderingSimulator::Frame& b) {
+  return a.decoded_timestamp < b.decoded_timestamp;
+}
+inline void SortByDecodedOrder(ArrayView<RenderingSimulator::Frame> frames) {
+  absl::c_stable_sort(frames, DecodedOrder);
+}
+
+inline bool RenderedOrder(const RenderingSimulator::Frame& a,
+                          const RenderingSimulator::Frame& b) {
+  return a.rendered_timestamp < b.rendered_timestamp;
+}
+inline void SortByRenderedOrder(ArrayView<RenderingSimulator::Frame> frames) {
+  absl::c_stable_sort(frames, RenderedOrder);
+}
+
+// -- Inter-frame metrics --
+// Difference in render time (target) between two frames.
+inline TimeDelta InterRenderTime(const RenderingSimulator::Frame& cur,
+                                 const RenderingSimulator::Frame& prev) {
+  return cur.render_timestamp - prev.render_timestamp;
+}
+
+// Difference in decoded time (actual) between two frames.
+inline TimeDelta InterDecodedTime(const RenderingSimulator::Frame& cur,
+                                  const RenderingSimulator::Frame& prev) {
+  return cur.decoded_timestamp - prev.decoded_timestamp;
+}
+
+// Difference in rendered time (actual) between two frames.
+inline TimeDelta InterRenderedTime(const RenderingSimulator::Frame& cur,
+                                   const RenderingSimulator::Frame& prev) {
+  return cur.rendered_timestamp - prev.rendered_timestamp;
+}
+
 }  // namespace webrtc::video_timing_simulator
 
 #endif  // VIDEO_TIMING_SIMULATOR_RENDERING_SIMULATOR_H_
diff --git a/video/timing/simulator/stream_base.h b/video/timing/simulator/stream_base.h
new file mode 100644
index 0000000..a097188
--- /dev/null
+++ b/video/timing/simulator/stream_base.h
@@ -0,0 +1,95 @@
+/*
+ *  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 <cstddef>
+#include <vector>
+
+#include "absl/algorithm/container.h"
+#include "api/units/time_delta.h"
+#include "api/units/timestamp.h"
+#include "rtc_base/logging.h"
+#include "rtc_base/numerics/moving_percentile_filter.h"
+
+#ifndef VIDEO_TIMING_SIMULATOR_STREAM_BASE_H_
+#define VIDEO_TIMING_SIMULATOR_STREAM_BASE_H_
+
+namespace webrtc::video_timing_simulator {
+
+// CRTP base struct for code reuse.
+template <typename StreamT>
+struct StreamBase {
+  // Data members are defined in derived struct.
+
+  // -- CRTP accessors --
+  const StreamT& self() const { return static_cast<const StreamT&>(*this); }
+  StreamT& self() { return static_cast<StreamT&>(*this); }
+
+  // -- Helpers --
+  bool IsEmpty() const { return self().frames.empty(); }
+
+  // -- Metric population --
+  void PopulateFrameDelayVariations(float baseline_percentile = 0.0,
+                                    size_t baseline_window_size = 300) {
+    auto& frames = self().frames;
+    if (frames.empty()) {
+      return;
+    }
+
+    // The baseline filter measures the minimum (by default) one-way delay
+    // seen over a window. The corresponding value is then used to anchor all
+    // other one-way delay measurements, creating the frame delay variation.
+    MovingPercentileFilter<TimeDelta> baseline_filter(baseline_percentile,
+                                                      baseline_window_size);
+
+    // One-way delay measurement offsets.
+    Timestamp arrival_offset = Timestamp::PlusInfinity();
+    Timestamp departure_offset = Timestamp::PlusInfinity();
+    for (const auto& frame : frames) {
+      Timestamp arrival = frame.ArrivalTimestamp();
+      Timestamp departure = frame.DepartureTimestamp();
+      if (arrival.IsFinite() && departure.IsFinite()) {
+        arrival_offset = arrival;
+        departure_offset = departure;
+        break;
+      }
+    }
+    if (!arrival_offset.IsFinite() || !departure_offset.IsFinite()) {
+      RTC_LOG(LS_WARNING)
+          << "Did not find valid arrival and/or departure offsets";
+      return;
+    }
+
+    // Calculate frame delay variations relative the moving baseline.
+    for (auto& frame : frames) {
+      TimeDelta one_way_delay =
+          frame.OneWayDelay(arrival_offset, departure_offset);
+      baseline_filter.Insert(one_way_delay);
+      frame.frame_delay_variation =
+          one_way_delay - baseline_filter.GetFilteredValue();
+    }
+  }
+};
+
+// -- Comparators and sorting --
+template <typename StreamT>
+bool StreamOrder(const StreamT& a, const StreamT& b) {
+  if (a.creation_timestamp != b.creation_timestamp) {
+    return a.creation_timestamp < b.creation_timestamp;
+  }
+  return a.ssrc < b.ssrc;
+}
+template <typename StreamT>
+void SortByStreamOrder(std::vector<StreamT>& streams) {
+  absl::c_stable_sort(streams, StreamOrder<StreamT>);
+}
+
+}  // namespace webrtc::video_timing_simulator
+
+#endif  // VIDEO_TIMING_SIMULATOR_STREAM_BASE_H_
diff --git a/video/timing/simulator/stream_base_unittest.cc b/video/timing/simulator/stream_base_unittest.cc
new file mode 100644
index 0000000..ff1ec4d
--- /dev/null
+++ b/video/timing/simulator/stream_base_unittest.cc
@@ -0,0 +1,81 @@
+/*
+ *  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/stream_base.h"
+
+#include <cstdint>
+#include <vector>
+
+#include "api/units/time_delta.h"
+#include "api/units/timestamp.h"
+#include "test/gmock.h"
+#include "test/gtest.h"
+#include "video/timing/simulator/frame_base.h"
+
+namespace webrtc::video_timing_simulator {
+namespace {
+
+using ::testing::ElementsAreArray;
+using ::testing::Eq;
+using ::testing::Field;
+
+struct TestFrame : public FrameBase<TestFrame> {
+  int64_t unwrapped_rtp_timestamp = -1;
+  Timestamp assembled_timestamp = Timestamp::PlusInfinity();
+  Timestamp ArrivalTimestampInternal() const { return assembled_timestamp; }
+  TimeDelta frame_delay_variation = TimeDelta::PlusInfinity();
+};
+
+struct TestStream : public StreamBase<TestStream> {
+  Timestamp creation_timestamp = Timestamp::PlusInfinity();
+  uint32_t ssrc = 0;
+  std::vector<TestFrame> frames;
+};
+
+TEST(StreamBaseTest, IsEmpty) {
+  TestStream stream;
+  EXPECT_TRUE(stream.IsEmpty());
+}
+
+TEST(StreamBaseTest, PopulateFrameDelayVariations) {
+  // Four frames at 30fps => 3000 RTP ticks between sent frames.
+  // Nominal inter-arrival-time is 33333us.
+
+  // First frame becomes the initial baseline.
+  TestFrame frame1{.unwrapped_rtp_timestamp = 3000,
+                   .assembled_timestamp = Timestamp::Micros(33333)};
+  // Second frame is delayed 1000us.
+  TestFrame frame2{.unwrapped_rtp_timestamp = 6000,
+                   .assembled_timestamp = Timestamp::Micros(66666 + 1000)};
+  // Third frame is severely delayed, arriving back-to-back with the 4th frame.
+  TestFrame frame3{.unwrapped_rtp_timestamp = 9000,
+                   .assembled_timestamp = Timestamp::Micros(99999 + 33333)};
+  // The 4th frame arrives on time.
+  TestFrame frame4{.unwrapped_rtp_timestamp = 12000,
+                   .assembled_timestamp = Timestamp::Micros(133332)};
+
+  TestStream stream{.frames = {frame1, frame2, frame3, frame4}};
+  stream.PopulateFrameDelayVariations();
+
+  EXPECT_THAT(
+      stream.frames,
+      ElementsAreArray({
+          Field(&TestFrame::frame_delay_variation, Eq(TimeDelta::Zero())),
+          Field(&TestFrame::frame_delay_variation, Eq(TimeDelta::Micros(1000))),
+          // Due to the non-integer 1000/90 factor in the timestamp
+          // translation, we get a 33332us here instead of 33333us.
+          Field(&TestFrame::frame_delay_variation,
+                Eq(TimeDelta::Micros(33332))),
+          Field(&TestFrame::frame_delay_variation, Eq(TimeDelta::Zero())),
+      }));
+}
+
+}  // namespace
+}  // namespace webrtc::video_timing_simulator