Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for RTCP Sender Reports #78

Merged
merged 5 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a comment here, sth like

A packet is in order when it is from the next cycle, or current cycle with delta > 0

In ReceiverReports we have:

    cycle =
      cond do
        delta in -@breakpoint..@breakpoint -> last_cycle
        delta < -@breakpoint -> last_cycle + 1
        delta > @breakpoint -> last_cycle - 1
      end

which is really nice and easy to to read but if someone didn't see this code, it might be pretty hard to get what's going on?


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
Loading