diff --git a/worker/include/RTC/RTP/Packet.hpp b/worker/include/RTC/RTP/Packet.hpp index 573c006151..9e6a5cab4d 100644 --- a/worker/include/RTC/RTP/Packet.hpp +++ b/worker/include/RTC/RTP/Packet.hpp @@ -838,6 +838,22 @@ namespace RTC return this->payloadDescriptorHandler->GetTemporalLayer(); } + /** + * Capture time (in ms) of the packet. + */ + std::optional GetCaptureMs() const + { + return this->captureMs; + } + + /** + * Set the capture time (in ms) of the packet. + */ + void SetCaptureMs(uint64_t captureMs) + { + this->captureMs = captureMs; + } + private: /** * @remarks @@ -960,6 +976,8 @@ namespace RTC RTP::HeaderExtensionIds headerExtensionIds{}; // Codec related. std::shared_ptr payloadDescriptorHandler; + // Capture time of the packet. + std::optional captureMs; }; } // namespace RTP } // namespace RTC diff --git a/worker/include/RTC/RTP/RtpStreamSend.hpp b/worker/include/RTC/RTP/RtpStreamSend.hpp index f768b11ad5..42c4cb78c4 100644 --- a/worker/include/RTC/RTP/RtpStreamSend.hpp +++ b/worker/include/RTC/RTP/RtpStreamSend.hpp @@ -14,9 +14,9 @@ namespace RTC { public: // Maximum retransmission buffer size for video (ms). - static const uint32_t MaxRetransmissionDelayForVideoMs; + static constexpr uint32_t MaxRetransmissionDelayForVideoMs{ 2000 }; // Maximum retransmission buffer size for audio (ms). - static const uint32_t MaxRetransmissionDelayForAudioMs; + static constexpr uint32_t MaxRetransmissionDelayForAudioMs{ 1000 }; public: enum class ReceivePacketResult : uint8_t diff --git a/worker/include/RTC/RemoteClockOffsetEstimator.hpp b/worker/include/RTC/RemoteClockOffsetEstimator.hpp new file mode 100644 index 0000000000..3f4c989c2c --- /dev/null +++ b/worker/include/RTC/RemoteClockOffsetEstimator.hpp @@ -0,0 +1,87 @@ +#ifndef MS_RTC_REMOTE_CLOCK_OFFSET_ESTIMATOR_HPP +#define MS_RTC_REMOTE_CLOCK_OFFSET_ESTIMATOR_HPP + +#include "common.hpp" +#include + +namespace RTC +{ + /** + * Estimates the offset between the wall clock of a remote sender, as reported in + * the NTP field of the RTCP Sender Reports it sends, and mediasoup's own monotonic + * clock. Both are expressed in milliseconds, so the estimated offset satisfies: + * + * localMs = remoteMs + offsetMs + * + * Each sample is the difference between the arrival time of a Sender Report and + * the NTP value it carries, so it holds the clock offset plus the one way delay + * of that Sender Report. That delay is removed with half of the RTT when known, + * and the median of a sliding window is taken so that transient delay spikes are + * rejected. + * + * A single instance is meant to be shared by all the RTP streams of a given + * CNAME. Those streams come from the same machine and hence from the same wall + * clock, and using a different offset for each of them would reintroduce the + * very inter stream skew this is meant to remove. + * + * @remarks + * - Based on the RemoteNtpTimeEstimator class of libwebrtc. + */ + class RemoteClockOffsetEstimator + { + public: + /** + * Number of most recent samples the median is computed over. + */ + static constexpr size_t WindowSize{ 7 }; + /** + * Number of samples required before an offset is reported. + */ + static constexpr size_t MinSampleCount{ 3 }; + + public: + RemoteClockOffsetEstimator(); + + public: + /** + * Feed a received RTCP Sender Report. + * + * @param remoteNtpMs - NTP field of the Sender Report, in milliseconds. + * @param localArrivalMs - Our local time at which the Sender Report arrived. + * @param rttMs - RTT towards the sender, or 0 if not known yet. + */ + void AddSenderReport(uint64_t remoteNtpMs, uint64_t localArrivalMs, uint32_t rttMs); + + /** + * The estimated offset, or no value while less than `MinSampleCount` samples + * have been gathered. + */ + std::optional GetOffsetMs() const + { + return this->offsetMs; + } + + /** + * Translate a time expressed in the remote sender's wall clock into our own + * monotonic clock. Returns no value if there is no offset yet or if the given + * time does not map into our clock. + */ + std::optional RemoteMsToLocalMs(uint64_t remoteMs) const; + + void Reset(); + + private: + void UpdateOffsetMs(); + + private: + // Most recent samples, oldest first. + std::vector samples; + // Arrival time of the last accepted Sender Report, so that all the Sender + // Reports of a same compound packet produce a single sample. + uint64_t lastLocalArrivalMs{ 0 }; + // Median of the samples in the window. + std::optional offsetMs; + }; +} // namespace RTC + +#endif diff --git a/worker/meson.build b/worker/meson.build index 6697b8069b..ee27b036ee 100644 --- a/worker/meson.build +++ b/worker/meson.build @@ -124,6 +124,7 @@ common_sources = [ 'src/RTC/PortManager.cpp', 'src/RTC/Producer.cpp', 'src/RTC/RateCalculator.cpp', + 'src/RTC/RemoteClockOffsetEstimator.cpp', 'src/RTC/Router.cpp', 'src/RTC/RtcLogger.cpp', 'src/RTC/RtpListener.cpp', @@ -429,6 +430,7 @@ test_sources = [ 'test/src/RTC/TestNackGenerator.cpp', 'test/src/RTC/TestPortManager.cpp', 'test/src/RTC/TestRateCalculator.cpp', + 'test/src/RTC/TestRemoteClockOffsetEstimator.cpp', 'test/src/RTC/TestRtpEncodingParameters.cpp', 'test/src/RTC/TestSeqManager.cpp', 'test/src/RTC/TestSubchannelsCodec.cpp', diff --git a/worker/src/RTC/RTP/Packet.cpp b/worker/src/RTC/RTP/Packet.cpp index ed2d817653..fedfe8debb 100644 --- a/worker/src/RTC/RTP/Packet.cpp +++ b/worker/src/RTC/RTP/Packet.cpp @@ -391,6 +391,11 @@ namespace RTC MS_DUMP_CLEAN(indentation, " padding length: %" PRIu8, GetPaddingLength()); MS_DUMP_CLEAN(indentation, " padded to 4 bytes: %s", IsPaddedTo4Bytes() ? "yes" : "no"); + if (GetCaptureMs()) + { + MS_DUMP_CLEAN(indentation, " capture time (ms):%" PRIu64, GetCaptureMs().value()); + } + if (this->payloadDescriptorHandler) { MS_DUMP_CLEAN(indentation + 1, ""); @@ -422,6 +427,9 @@ namespace RTC // Clone extension ids. clonedPacket->headerExtensionIds = this->headerExtensionIds; + // Clone capture time. + clonedPacket->captureMs = this->captureMs; + // Assign the payload descriptor handler. clonedPacket->payloadDescriptorHandler = this->payloadDescriptorHandler; diff --git a/worker/src/RTC/RTP/RtpStreamSend.cpp b/worker/src/RTC/RTP/RtpStreamSend.cpp index 47ed1547f8..ee2d184788 100644 --- a/worker/src/RTC/RTP/RtpStreamSend.cpp +++ b/worker/src/RTC/RTP/RtpStreamSend.cpp @@ -21,11 +21,6 @@ namespace RTC MaxRequestedPackets + 1); static constexpr uint32_t DefaultRtt{ 100u }; - /* Class Static. */ - - const uint32_t RtpStreamSend::MaxRetransmissionDelayForVideoMs{ 2000u }; - const uint32_t RtpStreamSend::MaxRetransmissionDelayForAudioMs{ 1000u }; - /* Instance methods. */ RtpStreamSend::RtpStreamSend( diff --git a/worker/src/RTC/RemoteClockOffsetEstimator.cpp b/worker/src/RTC/RemoteClockOffsetEstimator.cpp new file mode 100644 index 0000000000..b26c8beee0 --- /dev/null +++ b/worker/src/RTC/RemoteClockOffsetEstimator.cpp @@ -0,0 +1,106 @@ +#define MS_CLASS "RTC::RemoteClockOffsetEstimator" +// #define MS_LOG_DEV_LEVEL 3 + +#include "RTC/RemoteClockOffsetEstimator.hpp" +#include "Logger.hpp" + +namespace RTC +{ + /* Instance methods. */ + + RemoteClockOffsetEstimator::RemoteClockOffsetEstimator() + { + MS_TRACE(); + + this->samples.reserve(RemoteClockOffsetEstimator::WindowSize); + } + + void RemoteClockOffsetEstimator::AddSenderReport( + uint64_t remoteNtpMs, uint64_t localArrivalMs, uint32_t rttMs) + { + MS_TRACE(); + + // Ignore Sender Reports with no NTP timestamp. + if (remoteNtpMs == 0) + { + MS_DEBUG_DEV("ignoring Sender Report with no NTP timestamp"); + + return; + } + + // Ignore a Sender Report belonging to a compound packet already accounted + // for. Otherwise a single delayed compound packet would contribute as many + // samples as streams it reports about, and hence bias the median. + if (localArrivalMs == this->lastLocalArrivalMs) + { + return; + } + + this->lastLocalArrivalMs = localArrivalMs; + + // The sample holds the clock offset plus the one way delay of this Sender + // Report. Assume a symmetric path and remove half of the RTT. + const int64_t sample = static_cast(localArrivalMs) - + static_cast(remoteNtpMs) - (static_cast(rttMs) / 2); + + if (this->samples.size() == RemoteClockOffsetEstimator::WindowSize) + { + this->samples.erase(this->samples.begin()); + } + + this->samples.push_back(sample); + + UpdateOffsetMs(); + } + + std::optional RemoteClockOffsetEstimator::RemoteMsToLocalMs(uint64_t remoteMs) const + { + MS_TRACE(); + + if (!this->offsetMs.has_value()) + { + return std::nullopt; + } + + const int64_t localMs = static_cast(remoteMs) + this->offsetMs.value(); + + // The given time does not map into our clock, so the input is bogus. + if (localMs < 0) + { + MS_WARN_2TAGS( + rtp, rtcp, "remote time does not map into our clock [remoteMs:%" PRIu64 "]", remoteMs); + + return std::nullopt; + } + + return static_cast(localMs); + } + + void RemoteClockOffsetEstimator::Reset() + { + MS_TRACE(); + + this->samples.clear(); + this->lastLocalArrivalMs = 0; + this->offsetMs.reset(); + } + + void RemoteClockOffsetEstimator::UpdateOffsetMs() + { + MS_TRACE(); + + if (this->samples.size() < RemoteClockOffsetEstimator::MinSampleCount) + { + return; + } + + // Take the median of the window. While the window is not full its size may + // be even, in which case the upper of the two middle samples is taken. + std::vector sortedSamples(this->samples); + const auto middle = sortedSamples.begin() + (sortedSamples.size() / 2); + + std::nth_element(sortedSamples.begin(), middle, sortedSamples.end()); + + this->offsetMs = *middle; + } +} // namespace RTC diff --git a/worker/test/src/RTC/RTP/TestPacket.cpp b/worker/test/src/RTC/RTP/TestPacket.cpp index b18c504924..b487f3ff66 100644 --- a/worker/test/src/RTC/RTP/TestPacket.cpp +++ b/worker/test/src/RTC/RTP/TestPacket.cpp @@ -1059,12 +1059,16 @@ SCENARIO("RTP Packet", "[serializable][rtp][packet]") /*paddingLength*/ 0); REQUIRE(packet->IsPaddedTo4Bytes() == true); + REQUIRE(packet->GetCaptureMs() == std::nullopt); packet->SetPayloadType(100); packet->SetMarker(true); packet->SetSequenceNumber(12345); packet->SetTimestamp(987654321); packet->SetSsrc(1234567890); + packet->SetCaptureMs(99998888); + + REQUIRE(packet->GetCaptureMs() == 99998888); std::vector extensions; @@ -1204,6 +1208,7 @@ SCENARIO("RTP Packet", "[serializable][rtp][packet]") REQUIRE(extensionLen == 3); REQUIRE(packet->IsPaddedTo4Bytes() == true); + REQUIRE(packet->GetCaptureMs() == 99998888); /* Clone it. */ @@ -1252,6 +1257,7 @@ SCENARIO("RTP Packet", "[serializable][rtp][packet]") REQUIRE(extensionLen == 3); REQUIRE(packet->IsPaddedTo4Bytes() == true); + REQUIRE(packet->GetCaptureMs() == 99998888); /* Set payload. */ diff --git a/worker/test/src/RTC/TestPortManager.cpp b/worker/test/src/RTC/TestPortManager.cpp index c402779a2e..414ad0ce4b 100644 --- a/worker/test/src/RTC/TestPortManager.cpp +++ b/worker/test/src/RTC/TestPortManager.cpp @@ -10,7 +10,7 @@ // merged unrelated bindings. This scenario locks down the post-fix // behavior: distinct tuples produce distinct keys, equal tuples produce equal // keys. -SCENARIO("PortManager", "[rtc][portmanager]") +SCENARIO("PortManager", "[portmanager]") { // Helper: build an IPv4 `sockaddr_storage` from a dotted-quad string + port=0. auto makeV4 = [](const char* dottedQuad) diff --git a/worker/test/src/RTC/TestRemoteClockOffsetEstimator.cpp b/worker/test/src/RTC/TestRemoteClockOffsetEstimator.cpp new file mode 100644 index 0000000000..b0e2f4ebb9 --- /dev/null +++ b/worker/test/src/RTC/TestRemoteClockOffsetEstimator.cpp @@ -0,0 +1,241 @@ +#include "common.hpp" +#include "RTC/RemoteClockOffsetEstimator.hpp" +#include + +SCENARIO("RemoteClockOffsetEstimator", "[rtp][rtcp][remoteclockoffsetestimator]") +{ + // Remote sender wall clock, as reported in the NTP field of its Sender Reports + // (milliseconds since 1900). + constexpr uint64_t RemoteBaseMs{ 3976000000000 }; + // Our own monotonic clock. + constexpr uint64_t LocalBaseMs{ 1000000 }; + // Offset expected when Sender Reports reach us with no delay at all. + constexpr int64_t BaseOffsetMs{ static_cast(LocalBaseMs) - + static_cast(RemoteBaseMs) }; + + const auto minSampleCount = RTC::RemoteClockOffsetEstimator::MinSampleCount; + const auto windowSize = RTC::RemoteClockOffsetEstimator::WindowSize; + + // Feeds `count` Sender Reports one second apart, each one reaching us `owdMs` + // after having been generated by the sender. + auto feed = []( + RTC::RemoteClockOffsetEstimator& estimator, + uint64_t remoteNtpMs, + uint64_t localArrivalMs, + size_t count, + uint64_t owdMs, + uint32_t rttMs) -> void + { + for (size_t idx{ 0 }; idx < count; ++idx) + { + const uint64_t elapsedMs = static_cast(idx) * 1000; + + estimator.AddSenderReport(remoteNtpMs + elapsedMs, localArrivalMs + elapsedMs + owdMs, rttMs); + } + }; + + RTC::RemoteClockOffsetEstimator estimator; + + SECTION("no offset until MinSampleCount Sender Reports have been received") + { + feed(estimator, RemoteBaseMs, LocalBaseMs, minSampleCount - 1, 0, 0); + + REQUIRE_FALSE(estimator.GetOffsetMs().has_value()); + + // One more Sender Report reaches the minimum. + const uint64_t elapsedMs = static_cast(minSampleCount - 1) * 1000; + + feed(estimator, RemoteBaseMs + elapsedMs, LocalBaseMs + elapsedMs, 1, 0, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs); + } + + SECTION("the offset carries the Sender Report one way delay when the RTT is unknown") + { + feed(estimator, RemoteBaseMs, LocalBaseMs, minSampleCount, 40, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs + 40); + } + + SECTION("half of the RTT is removed from the offset") + { + feed(estimator, RemoteBaseMs, LocalBaseMs, minSampleCount, 40, 80); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs); + } + + SECTION("a single delayed Sender Report does not move the offset") + { + feed(estimator, RemoteBaseMs, LocalBaseMs, windowSize, 20, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs + 20); + + // A Sender Report that took much longer to reach us must be rejected by the + // median. + const uint64_t elapsedMs = static_cast(windowSize) * 1000; + + feed(estimator, RemoteBaseMs + elapsedMs, LocalBaseMs + elapsedMs, 1, 500, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs + 20); + } + + SECTION("the offset follows a sustained change in delay") + { + feed(estimator, RemoteBaseMs, LocalBaseMs, windowSize, 0, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs); + + // Once the whole window has been replaced the new delay is fully reflected. + const uint64_t elapsedMs = static_cast(windowSize) * 1000; + + feed(estimator, RemoteBaseMs + elapsedMs, LocalBaseMs + elapsedMs, windowSize, 100, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs + 100); + } + + SECTION("delayed Sender Reports move the offset once they are the majority") + { + feed(estimator, RemoteBaseMs, LocalBaseMs, windowSize, 0, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs); + + // Less than half of the window delayed, so the median still falls on a + // Sender Report that reached us with no delay. + uint64_t elapsedMs = static_cast(windowSize) * 1000; + + feed(estimator, RemoteBaseMs + elapsedMs, LocalBaseMs + elapsedMs, windowSize / 2, 300, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs); + + // One more delayed Sender Report makes them the majority. + elapsedMs += static_cast(windowSize / 2) * 1000; + + feed(estimator, RemoteBaseMs + elapsedMs, LocalBaseMs + elapsedMs, 1, 300, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs + 300); + } + + SECTION("the offset tracks a drifting remote clock") + { + // The remote wall clock runs one millisecond per second slower than ours, so + // each Sender Report yields an offset one millisecond higher than the + // previous one. + for (size_t idx{ 0 }; idx < windowSize; ++idx) + { + estimator.AddSenderReport( + RemoteBaseMs + (static_cast(idx) * 1000), + LocalBaseMs + (static_cast(idx) * 1001), + 0); + } + + REQUIRE(estimator.GetOffsetMs().has_value()); + // The samples in the window grow by one millisecond each, so the median is + // the middle of that ramp. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs + static_cast(windowSize / 2)); + + // The drift keeps going, so the offset keeps up with it rather than sticking + // to the value it already had. + for (size_t idx{ windowSize }; idx < windowSize * 2; ++idx) + { + estimator.AddSenderReport( + RemoteBaseMs + (static_cast(idx) * 1000), + LocalBaseMs + (static_cast(idx) * 1001), + 0); + } + + REQUIRE(estimator.GetOffsetMs().has_value()); + REQUIRE( + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + estimator.GetOffsetMs().value() == + BaseOffsetMs + static_cast(windowSize + (windowSize / 2))); + } + + SECTION("all the Sender Reports of a compound packet produce a single sample") + { + // Three Sender Reports of a same compound packet, hence a single arrival + // time. + estimator.AddSenderReport(RemoteBaseMs, LocalBaseMs, 0); + estimator.AddSenderReport(RemoteBaseMs + 1, LocalBaseMs, 0); + estimator.AddSenderReport(RemoteBaseMs + 2, LocalBaseMs, 0); + + REQUIRE_FALSE(estimator.GetOffsetMs().has_value()); + + // Two more compound packets reach the minimum. + estimator.AddSenderReport(RemoteBaseMs + 1000, LocalBaseMs + 1000, 0); + estimator.AddSenderReport(RemoteBaseMs + 1001, LocalBaseMs + 1000, 0); + estimator.AddSenderReport(RemoteBaseMs + 2000, LocalBaseMs + 2000, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs); + } + + SECTION("Sender Reports with no NTP timestamp are ignored") + { + // NOTE: Not using feed() here since it would advance the NTP timestamp. + estimator.AddSenderReport(0, LocalBaseMs, 0); + estimator.AddSenderReport(0, LocalBaseMs + 1000, 0); + estimator.AddSenderReport(0, LocalBaseMs + 2000, 0); + + REQUIRE_FALSE(estimator.GetOffsetMs().has_value()); + } + + SECTION("remote times are translated into our clock") + { + const RTC::RemoteClockOffsetEstimator untrainedEstimator; + + REQUIRE_FALSE(untrainedEstimator.RemoteMsToLocalMs(RemoteBaseMs).has_value()); + + feed(estimator, RemoteBaseMs, LocalBaseMs, minSampleCount, 0, 0); + + REQUIRE(estimator.RemoteMsToLocalMs(RemoteBaseMs).has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.RemoteMsToLocalMs(RemoteBaseMs).value() == LocalBaseMs); + REQUIRE(estimator.RemoteMsToLocalMs(RemoteBaseMs + 500).has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.RemoteMsToLocalMs(RemoteBaseMs + 500).value() == LocalBaseMs + 500); + + // A remote time older than the origin of our clock cannot be translated. + REQUIRE_FALSE(estimator.RemoteMsToLocalMs(0).has_value()); + } + + SECTION("Reset() clears the estimation") + { + feed(estimator, RemoteBaseMs, LocalBaseMs, minSampleCount, 0, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + + estimator.Reset(); + + REQUIRE_FALSE(estimator.GetOffsetMs().has_value()); + + // The last arrival time is forgotten as well, so Sender Reports arriving at + // the very same times as before the reset are not taken as duplicates. + feed(estimator, RemoteBaseMs, LocalBaseMs, minSampleCount, 0, 0); + + REQUIRE(estimator.GetOffsetMs().has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + REQUIRE(estimator.GetOffsetMs().value() == BaseOffsetMs); + } +} diff --git a/worker/test/src/RTC/TestTransportTuple.cpp b/worker/test/src/RTC/TestTransportTuple.cpp index b12464d72b..0c4f437088 100644 --- a/worker/test/src/RTC/TestTransportTuple.cpp +++ b/worker/test/src/RTC/TestTransportTuple.cpp @@ -6,7 +6,7 @@ #include #include -SCENARIO("TransportTuple", "[transport-tuple]") +SCENARIO("TransportTuple", "[transporttuple]") { class UdpSocketListener : public RTC::UdpSocket::Listener {