Skip to main content

lib/air_play/rtp.ex

defmodule AirPlay.Rtp do
  @moduledoc """
  RAOP packet builders: audio (PT 0x60), control `sync` (0xd4), and timing
  response (0xd3). All multi-byte fields are big-endian.
  """

  import Bitwise, only: [&&&: 2, <<<: 2]

  @doc """
  An audio RTP packet: `80 [60|e0] seq ts ssrc <payload>`. `marker?` sets the
  marker bit (0xe0) on the first packet of a stream.
  """
  @spec audio(non_neg_integer(), non_neg_integer(), non_neg_integer(), binary(), boolean()) ::
          binary()
  def audio(seq, timestamp, ssrc, payload, marker? \\ false) do
    pt = if marker?, do: 0xE0, else: 0x60
    <<0x80, pt, seq::16, timestamp::32, ssrc::32, payload::binary>>
  end

  @doc """
  A control `sync` packet (PT 0x54). `rtp_now` is the current RTP timestamp,
  `latency` the buffer in samples (RAOP default 88200 = 2 s), `ntp` the NTP time.
  `first?` sets the 0x90 lead byte on the first sync of a stream (else 0x80).
  """
  @spec sync(non_neg_integer(), non_neg_integer(), non_neg_integer(), boolean()) :: binary()
  def sync(rtp_now, latency, ntp, first? \\ false) do
    lead = if first?, do: 0x90, else: 0x80
    rtp_minus = rtp_now - latency
    <<lead, 0xD4, 0x00, 0x07, rtp_minus::32, ntp::64, rtp_now::32>>
  end

  @doc """
  An AirPlay 2 PTP-mode sync packet (PT 87).

  Unlike classic RAOP sync packets, this carries receiver PTP time rather than
  NTP time and includes the master clock identity learned through gPTP.
  """
  @spec ptp_sync(
          non_neg_integer(),
          non_neg_integer(),
          non_neg_integer(),
          non_neg_integer(),
          binary() | non_neg_integer(),
          boolean()
        ) :: binary()
  def ptp_sync(seq, current_rtp, ptp_clock_ns, next_rtp, clock_identity, first? \\ false) do
    lead = if first?, do: 0x90, else: 0x80
    seconds = div(ptp_clock_ns, 1_000_000_000)
    nanoseconds = rem(ptp_clock_ns, 1_000_000_000)
    fraction = div(nanoseconds <<< 32, 1_000_000_000)
    clock_identity = clock_identity_binary(clock_identity)

    <<lead, 0xD7, seq &&& 0xFFFF::16, current_rtp &&& 0xFFFFFFFF::32, seconds &&& 0xFFFFFFFF::32,
      fraction::32, next_rtp &&& 0xFFFFFFFF::32, clock_identity::binary-size(8)>>
  end

  @doc """
  A timing response (PT 0x53) to a received timing request. `origin` is the
  request's transmit timestamp; `recv`/`xmit` are our NTP receive/send times.
  """
  @spec timing_response(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: binary()
  def timing_response(origin, recv, xmit) do
    <<0x80, 0xD3, 0x00, 0x07, 0::32, origin::64, recv::64, xmit::64>>
  end

  @doc "Parse the transmit (origin) NTP timestamp out of a timing request (PT 0x52)."
  @spec timing_request_origin(binary()) :: {:ok, non_neg_integer()} | :error
  def timing_request_origin(<<0x80, 0xD2, _seq::16, 0::32, _origin::64, _recv::64, xmit::64>>),
    do: {:ok, xmit}

  def timing_request_origin(_), do: :error

  defp clock_identity_binary(<<_::64>> = identity), do: identity

  defp clock_identity_binary(identity) when is_integer(identity),
    do: <<identity &&& 0xFFFFFFFFFFFFFFFF::64>>
end