Skip to content

Commit

Permalink
Add support for RTCP Sender Reports (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
LVala authored Feb 29, 2024
1 parent 5c0c383 commit 8328e84
Show file tree
Hide file tree
Showing 2 changed files with 219 additions and 0 deletions.
115 changes: 115 additions & 0 deletions lib/ex_webrtc/rtp_sender/report_recorder.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
defmodule ExWebRTC.RTPSender.ReportRecorder do
@moduledoc false

import Bitwise

alias ExRTCP.Packet.SenderReport

@breakpoint 0x7FFF
# NTP epoch is 1/1/1900 vs UNIX epoch is 1/1/1970
# so there's offset of 70 years (inc. 17 leap years) in seconds
@ntp_offset (70 * 365 + 17) * 86_400
@micro_in_sec 1_000_000

@type t() :: %__MODULE__{
sender_ssrc: non_neg_integer(),
clock_rate: non_neg_integer(),
last_rtp_timestamp: ExRTP.Packet.uint32(),
last_seq_no: ExRTP.Packet.uint16(),
last_timestamp: integer(),
packet_count: non_neg_integer(),
octet_count: non_neg_integer()
}

@enforce_keys [:sender_ssrc, :clock_rate]
defstruct [
last_rtp_timestamp: nil,
last_seq_no: nil,
last_timestamp: nil,
packet_count: 0,
octet_count: 0
] ++ @enforce_keys

@doc """
Records outgoing RTP Packet.
`time` parameter accepts output of `System.os_time(:native)` as a value (UNIX timestamp in :native units).
"""
@spec record_packet(t(), ExRTP.Packet.t(), integer()) :: t()
def record_packet(%{last_timestamp: nil} = recorder, packet, time) do
%__MODULE__{
recorder
| last_rtp_timestamp: packet.timestamp,
last_seq_no: packet.sequence_number,
last_timestamp: time,
packet_count: 1,
octet_count: byte_size(packet.payload)
}
end

def record_packet(recorder, packet, time) do
%__MODULE__{
last_seq_no: last_seq_no,
packet_count: packet_count,
octet_count: octet_count
} = recorder

# a packet is in order when it is from the next cycle, or from current cycle with delta > 0
delta = packet.sequence_number - last_seq_no
in_order? = delta < -@breakpoint or (delta > 0 and delta < @breakpoint)

recorder =
if in_order? do
%__MODULE__{
recorder
| last_seq_no: packet.sequence_number,
last_rtp_timestamp: packet.timestamp,
last_timestamp: time
}
else
recorder
end

%__MODULE__{
recorder
| packet_count: packet_count + 1,
octet_count: octet_count + byte_size(packet.payload)
}
end

@doc """
Creates an RTCP Sender Report.
`time` parameter accepts output of `System.os_time(:native)` as a value (UNIX timestamp in :native units).
This function can be called only if at least one packet has been recorded,
otherwise it will raise.
"""
@spec get_report(t(), integer()) :: SenderReport.t()
def get_report(%{last_timestamp: nil}, _time), do: raise("No packet has been recorded yet")

def get_report(recorder, time) do
ntp_time = to_ntp(time)
rtp_delta = delay_since(time, recorder.last_timestamp) * recorder.clock_rate

%SenderReport{
ssrc: recorder.sender_ssrc,
packet_count: recorder.packet_count,
octet_count: recorder.octet_count,
ntp_timestamp: ntp_time,
rtp_timestamp: round(recorder.last_rtp_timestamp + rtp_delta)
}
end

defp to_ntp(time) do
seconds = System.convert_time_unit(time, :native, :second)
micros = System.convert_time_unit(time, :native, :microsecond) - seconds * @micro_in_sec

frac = div(micros <<< 32, @micro_in_sec)

(seconds + @ntp_offset) <<< 32 ||| frac
end

defp delay_since(cur_ts, last_ts) do
native_in_sec = System.convert_time_unit(1, :second, :native)
(cur_ts - last_ts) / native_in_sec
end
end
104 changes: 104 additions & 0 deletions test/ex_webrtc/rtp_sender/report_recorder_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
defmodule ExWebRTC.RTPSender.ReportRecorderTest do
use ExUnit.Case, async: true

import Bitwise

alias ExWebRTC.RTPSender.ReportRecorder

@rand_ts System.os_time(:native)
@seq_no 11_534
@rtp_ts 234_444
@clock_rate 90_000
@packet ExRTP.Packet.new(<<>>, sequence_number: @seq_no, timestamp: @rtp_ts)
@recorder %ReportRecorder{sender_ssrc: 123_467, clock_rate: @clock_rate}

@ntp_offset 2_208_988_800
@max_u32 0xFFFFFFFF

describe "record_packet/3" do
test "keeps track of packet counts and sizes" do
recorder =
@recorder
|> ReportRecorder.record_packet(@packet, @rand_ts)
|> ReportRecorder.record_packet(%{@packet | payload: <<1, 2, 3>>}, @rand_ts)
|> ReportRecorder.record_packet(%{@packet | payload: <<1, 2, 3, 4, 5>>}, @rand_ts)

assert %ReportRecorder{
packet_count: 3,
octet_count: 8
} = recorder
end

test "remembers last timestamps" do
last_ts = @rand_ts - 100

recorder =
@recorder
|> ReportRecorder.record_packet(
%{@packet | timestamp: @rtp_ts - 200, sequence_number: @seq_no - 2},
@rand_ts - 200
)
|> ReportRecorder.record_packet(@packet, last_ts)
|> ReportRecorder.record_packet(
%{@packet | timestamp: @rtp_ts - 100, sequence_number: @seq_no - 1},
@rand_ts
)

assert %ReportRecorder{
last_rtp_timestamp: @rtp_ts,
last_seq_no: @seq_no,
last_timestamp: ^last_ts
} = recorder
end

test "handles wrapping sequence numbers" do
recorder =
@recorder
|> ReportRecorder.record_packet(%{@packet | sequence_number: 65_534}, @rand_ts - 300)
|> ReportRecorder.record_packet(%{@packet | sequence_number: 65_535}, @rand_ts - 200)
|> ReportRecorder.record_packet(%{@packet | sequence_number: 0}, @rand_ts - 100)
|> ReportRecorder.record_packet(%{@packet | sequence_number: 1}, @rand_ts)

assert %ReportRecorder{
last_seq_no: 1,
last_timestamp: @rand_ts
} = recorder
end
end

describe "get_report/2" do
test "properly calculates NTP timestamp" do
report =
@recorder
|> ReportRecorder.record_packet(@packet, 0)
|> ReportRecorder.get_report(0)

assert report.ntp_timestamp >>> 32 == @ntp_offset
assert (report.ntp_timestamp &&& @max_u32) == 0

native_in_sec = System.convert_time_unit(1, :second, :native)
seconds = 89_934
# 1/8, so 0.001 in binary
frac = 0.125

report =
@recorder
|> ReportRecorder.record_packet(@packet, 0)
|> ReportRecorder.get_report(trunc((seconds + frac) * native_in_sec))

assert report.ntp_timestamp >>> 32 == @ntp_offset + seconds
assert (report.ntp_timestamp &&& @max_u32) == 1 <<< 29
end

test "properly calculates delay since last packet" do
delta = System.convert_time_unit(250, :millisecond, :native)

report =
@recorder
|> ReportRecorder.record_packet(@packet, @rand_ts)
|> ReportRecorder.get_report(@rand_ts + delta)

assert report.rtp_timestamp == @rtp_ts + 0.25 * @clock_rate
end
end
end

0 comments on commit 8328e84

Please sign in to comment.