Current section

Files

Jump to
membrane_rtc_engine_sip lib sip_endpoint.ex
Raw

lib/sip_endpoint.ex

defmodule Membrane.RTC.Engine.Endpoint.SIP do
@moduledoc """
An Endpoint responsible for:
* registering at a SIP provider,
* dialing a phone number,
* receiving audio from the callee and forwarding it to other Endpoints,
* mixing audio tracks from other Endpoints and sending them to the callee.
## Limitations
* Incoming calls are unsupported,
* only the G.711 A-law audio codec is supported (SDP negotiation will fail if the SIP provider doesn't support it).
## Setup
All SIP Endpoints share a single UDP socket for SIP signaling messages.
By default, it is opened on `0.0.0.0:5060`; this can be changed by adding the following line to your `config.exs`:
```
config :membrane_rtc_engine_sip, sip_address: "1.2.3.4", sip_port: 5061
```
The range of UDP ports (available to all SIP Endpoints) used for RTP media stream exchange
can be modified by adding the following line to your `config.exs`:
```
config :membrane_rtc_engine_sip, port_range: {from, to} # (both ends inclusive)
```
"""
use Membrane.Bin
require Membrane.G711
require Membrane.Logger
alias Membrane.{Logger, RawAudio, Time}
alias Membrane.RTC.Engine
alias Membrane.RTC.Engine.Endpoint.SIP.{
Call,
OutgoingCall,
PortAllocator,
RegisterCall,
RegistrarCredentials
}
alias Membrane.RTC.Engine.Endpoint.WebRTC.{TrackReceiver, TrackSender}
alias Membrane.RTC.Engine.Notifications.TrackNotification
alias Membrane.RTC.Engine.Subscriber
alias Membrane.RTC.Engine.Track
alias Membrane.RTP.SessionBin
@default_sip_port 5060
@register_interval_ms 45_000
@audio_mixer_delay Time.milliseconds(200)
@opus_sample_rate 48_000
def_output_pad :output,
accepted_format: Membrane.RTP,
availability: :on_request
def_input_pad :input,
accepted_format: Membrane.RTP,
availability: :on_request
def_options rtc_engine: [
spec: pid(),
description: "PID of parent Engine"
],
registrar_credentials: [
spec: RegistrarCredentials.t(),
description: "Credentials needed to connect with the SIP registrar server"
],
external_ip: [
spec: String.t(),
description:
"External IPv4 address of the machine running the Endpoint, required for SDP negotiation"
],
register_interval_ms: [
spec: non_neg_integer(),
description: """
Interval (in ms) in which keep-alive (keep-registered) REGISTER messages
will be sent to the SIP registrar server
""",
default: @register_interval_ms
],
disconnect_if_alone: [
spec: boolean(),
description: """
Whether the Endpoint should disconnect from the call when all incoming tracks are removed,
i.e. when all other Endpoints publishing audio are removed from the Engine
""",
default: true
]
@doc """
Starts calling a specified number
"""
@spec dial(rtc_engine :: pid(), endpoint_id :: String.t(), phone_number :: String.t()) :: :ok
def dial(rtc_engine, endpoint_id, phone_number) do
Engine.message_endpoint(rtc_engine, endpoint_id, {:dial, phone_number})
end
@doc """
Ends ongoing call or cancels call attempt
"""
@spec end_call(rtc_engine :: pid(), endpoint_id :: String.t()) :: :ok
def end_call(rtc_engine, endpoint_id) do
Engine.message_endpoint(rtc_engine, endpoint_id, :end_call)
end
defmodule State do
@moduledoc false
use Bunch.Access
@typep endpoint_state ::
:unregistered
| :unregistered_call_pending
| :registered
| :calling
| :in_call
| :ending_call
| :terminating
@type t :: %__MODULE__{
rtc_engine: pid(),
registrar_credentials: RegistrarCredentials.t(),
external_ip: String.t(),
register_interval_ms: non_neg_integer(),
disconnect_if_alone: boolean(),
endpoint_state: endpoint_state(),
rtp_port: 1..65_535,
sip_port: 1..65_535,
outgoing_track: Track.t(),
subscriber: Subscriber.t(),
outgoing_ssrc: Membrane.RTP.ssrc_t(),
first_ssrc: Membrane.RTP.ssrc_t() | nil,
register_call_id: Call.id(),
call_id: Call.id() | nil,
phone_number: String.t() | nil,
payload_type: ExSDP.Attribute.RTPMapping.payload_type_t()
}
@enforce_keys [
:rtc_engine,
:registrar_credentials,
:external_ip,
:register_interval_ms,
:disconnect_if_alone,
:endpoint_state,
:rtp_port,
:sip_port,
:outgoing_track,
:subscriber,
:outgoing_ssrc,
:first_ssrc,
:register_call_id,
:call_id,
:phone_number,
:payload_type
]
defstruct @enforce_keys
end
@impl true
def handle_init(ctx, opts) do
Logger.debug("SIP Endpoint: Init")
{:endpoint, endpoint_id} = ctx.name
subscriber = %Subscriber{
subscribe_mode: :auto,
endpoint_id: endpoint_id,
rtc_engine: opts.rtc_engine
}
track =
Track.new(
:audio,
Track.stream_id(),
endpoint_id,
:OPUS,
@opus_sample_rate,
%ExSDP.Attribute.FMTP{pt: Membrane.RTP.PayloadFormat.get(:OPUS)}
)
{register_call_id, _pid} = spawn_call(opts, RegisterCall)
self_pid = self()
Membrane.ResourceGuard.register(
ctx.resource_guard,
fn -> PortAllocator.free_ports(self_pid) end
)
with {:ok, rtp_port} <- PortAllocator.get_port() do
state =
opts
|> Map.from_struct()
|> Map.merge(%{
endpoint_state: :unregistered,
rtp_port: rtp_port,
sip_port: Application.get_env(:membrane_rtc_engine_sip, :sip_port, @default_sip_port),
outgoing_track: track,
subscriber: subscriber,
outgoing_ssrc: SessionBin.generate_receiver_ssrc([], []),
first_ssrc: nil,
register_call_id: register_call_id,
call_id: nil,
phone_number: nil,
payload_type: nil
})
|> then(&struct!(State, &1))
{[], state}
else
{:error, :no_available_port} ->
raise """
No available ports! Consider increasing the port range used by PortAllocator.
You can do that by adding the following line to your `config.exs` file:
```
config :membrane_rtc_engine_sip, port_range: {from, to}
```
"""
end
end
@impl true
def handle_playing(ctx, state) do
state =
if state.phone_number != nil,
do: try_calling(state, ctx.playback, state.phone_number),
else: state
{[], state}
end
@impl true
def handle_pad_added(Pad.ref(:input, track_id) = pad, _ctx, state) do
track = Subscriber.get_track(state.subscriber, track_id)
spec = [
bin_input(pad)
|> child({:track_receiver, track.id}, %TrackReceiver{
track: track,
initial_target_variant: :high
})
|> child({:depayloader, track.id}, Track.get_depayloader(track))
|> child({:opus_decoder, track.id}, Membrane.Opus.Decoder)
|> via_in(Pad.ref(:input, track.id))
|> get_child(:audio_mixer)
]
{[spec: spec], state}
end
@impl true
def handle_pad_added(Pad.ref(:output, {track_id, :high}) = pad, _ctx, state)
when track_id == state.outgoing_track.id do
Logger.debug("Pad added for track #{inspect(track_id)}, variant :high")
spec = [
get_rtp_stream_pipeline(state.first_ssrc)
|> child(:funnel, %Membrane.Funnel{})
|> child({:payloader, track_id}, %Membrane.RTP.PayloaderBin{
payloader: Membrane.RTP.Opus.Payloader,
ssrc: state.first_ssrc,
payload_type: Membrane.RTP.PayloadFormat.get(:OPUS),
clock_rate: @opus_sample_rate
})
|> via_in(Pad.ref(:input, {track_id, :high}))
|> child(
{:track_sender, track_id},
%TrackSender{
track: state.outgoing_track,
variant_bitrates: %{}
}
)
|> via_out(pad)
|> bin_output(pad)
]
{[spec: spec], state}
end
@impl true
def handle_pad_removed(Pad.ref(:input, track_id), _ctx, state) do
subscriber = Subscriber.remove_track(state.subscriber, track_id)
state = %{state | subscriber: subscriber}
children_to_remove =
[:track_receiver, :depayloader, :opus_decoder] |> Enum.map(&{&1, track_id})
actions = [remove_children: children_to_remove]
tracks = Subscriber.get_tracks(state.subscriber)
if state.disconnect_if_alone and map_size(tracks) == 0 do
{actions ++ [notify_parent: :finished], state}
else
{actions, state}
end
end
@impl true
def handle_pad_removed(Pad.ref(:output, {_track_id, _variant}), _ctx, state) do
{[], state}
end
@impl true
def handle_parent_notification({:dial, phone_number}, ctx, state) do
# Strip whitespace and separator characters
phone_number = String.replace(phone_number, ~r/[-.() \t\r\n]+/, "")
unless Regex.match?(~r/^\+?\d+$/, phone_number) do
raise "Invalid phone number: #{inspect(phone_number)}. Only digits and `+` are allowed in number"
end
state = try_calling(state, ctx.playback, phone_number)
{[], state}
end
@impl true
def handle_parent_notification(:end_call, _ctx, state) do
new_endpoint_state =
case state.endpoint_state do
:unregistered_call_pending ->
Logger.info("SIP Endpoint: Cancelling call attempt")
send(self(), {:call_info, {:end, :cancelled}})
:ending_call
:calling ->
Logger.info("SIP Endpoint: Cancelling call attempt")
OutgoingCall.cancel(state.call_id)
:ending_call
:in_call ->
Logger.info("SIP Endpoint: Ending call")
OutgoingCall.bye(state.call_id)
:ending_call
other_state ->
Logger.warning(
"SIP Endpoint: No ongoing call or call attempt to end, or endpoint is already terminating"
)
other_state
end
{[], %{state | phone_number: nil, endpoint_state: new_endpoint_state}}
end
@impl true
def handle_parent_notification({:new_tracks, tracks}, _ctx, state) do
tracks = Enum.filter(tracks, fn track -> track.type == :audio end)
subscriber = Subscriber.handle_new_tracks(tracks, state.subscriber)
{[], %{state | subscriber: subscriber}}
end
@impl true
def handle_parent_notification({topic, _data}, _ctx, state)
when topic in [:remove_tracks, :ready, :endpoint_added, :endpoint_removed] do
{[], state}
end
@impl true
def handle_parent_notification(%TrackNotification{}, _ctx, state) do
{[], state}
end
@impl true
def handle_parent_notification(msg, _ctx, state) do
Logger.warning("SIP Endpoint: Unexpected message from parent: #{inspect(msg)}. Ignoring.")
{[], state}
end
@impl true
def handle_child_notification(
{:new_rtp_stream, ssrc, fmt, _extensions} = msg,
:rtp,
_ctx,
state
)
when is_nil(state.first_ssrc) or ssrc == state.first_ssrc do
Logger.debug("SIP Endpoint: New RTP stream connected: #{inspect(msg)}")
state = %{state | first_ssrc: ssrc}
if fmt != state.payload_type do
raise """
Payload type mismatch between RTP mapping and received stream
(expected #{inspect(state.payload_type)}, got #{inspect(fmt)})
"""
end
{[
notify_child: {:audio_mixer, {:start_mixing, @audio_mixer_delay}},
notify_parent:
{:track_ready, state.outgoing_track.id, :high, state.outgoing_track.encoding},
notify_parent: {:forward_to_parent, :received_rtp_stream}
], state}
end
@impl true
def handle_child_notification(
{:new_rtp_stream, ssrc, fmt, _extensions},
:rtp,
_ctx,
state
) do
Logger.debug("Received another RTP stream with ssrc: #{ssrc}")
if fmt != state.payload_type do
raise """
Payload type mismatch between RTP mapping and received stream
(expected #{inspect(state.payload_type)}, got #{inspect(fmt)})
"""
end
spec =
ssrc
|> get_rtp_stream_pipeline()
|> get_child(:funnel)
{[spec: spec], state}
end
@impl true
def handle_child_notification({:connection_info, _address, _port}, :udp_endpoint, _ctx, state) do
{[], state}
end
@impl true
def handle_child_notification({:estimation, _data}, {:track_sender, _tid}, _ctx, state) do
{[], state}
end
@impl true
def handle_child_notification(
{:variant_switched, _new, _old},
{:track_receiver, _tid},
_ctx,
state
) do
{[], state}
end
@impl true
def handle_child_notification(
{:voice_activity_changed, _new},
{:track_receiver, _tid},
_ctx,
state
) do
{[], state}
end
@impl true
def handle_child_notification(notification, element, _ctx, state) do
Logger.warning(
"SIP Endpoint: Unexpected notification from `#{inspect(element)}`: #{inspect(notification)}. Ignoring."
)
{[], state}
end
@impl true
def handle_info(:registered, _ctx, state) do
state =
case state.endpoint_state do
:unregistered ->
%{state | endpoint_state: :registered}
:unregistered_call_pending ->
Logger.info("SIP Endpoint: Calling #{inspect(state.phone_number)}...")
{call_id, _pid} = spawn_call(state)
%{state | call_id: call_id, endpoint_state: :calling}
_other_state ->
state
end
{[], state}
end
@impl true
def handle_info({:call_info, call_info}, _ctx, %{endpoint_state: :terminating} = state) do
Logger.debug("SIP Endpoint: Received call info #{inspect(call_info)} in state :terminating")
{[], state}
end
@impl true
def handle_info({:call_info, :trying}, _ctx, state) do
Logger.debug("SIP Endpoint: Trying...")
{[], state}
end
@impl true
def handle_info({:call_info, :ringing}, _ctx, state) do
Logger.debug("SIP Endpoint: Ringing...")
{[notify_parent: {:forward_to_parent, :ringing}], state}
end
@impl true
def handle_info({:call_info, {:call_ready, options}}, _ctx, %{endpoint_state: :calling} = state) do
Logger.debug("SIP Endpoint: Connected. Received source options: #{inspect(options)}")
Logger.info("SIP Endpoint: Call answered")
{payload_type, rtpmap} = options.rtp_payload_fmt
receive_spec = [
child(:udp_endpoint, %Membrane.UDP.Endpoint{
local_port_no: state.rtp_port,
destination_port_no: options.port,
destination_address: options.connection_data.address
})
|> via_in(Pad.ref(:rtp_input, make_ref()))
|> child(:rtp, %SessionBin{
fmt_mapping: %{payload_type => {rtpmap.encoding_name, rtpmap.clock_rate}}
})
]
send_spec = [
child(:audio_mixer, %Membrane.LiveAudioMixer{
latency: nil,
stream_format: %Membrane.RawAudio{
channels: 1,
sample_rate: @opus_sample_rate,
sample_format: :s16le
}
})
|> child(:converter_out, %Membrane.FFmpeg.SWResample.Converter{
input_stream_format: %RawAudio{
channels: 1,
sample_format: :s16le,
sample_rate: @opus_sample_rate
},
output_stream_format: %RawAudio{
channels: 1,
sample_format: :s16le,
sample_rate: Membrane.G711.sample_rate()
}
})
|> child(:audio_codec_encoder, Membrane.G711.Encoder)
|> child(:audio_codec_parser, %Membrane.G711.Parser{overwrite_pts?: true})
|> via_in(Pad.ref(:input, state.outgoing_ssrc),
options: [payloader: Membrane.RTP.G711.Payloader]
)
|> get_child(:rtp)
|> via_out(Pad.ref(:rtp_output, state.outgoing_ssrc), options: [encoding: :PCMA])
|> get_child(:udp_endpoint)
]
actions = [
spec: receive_spec ++ send_spec,
notify_parent: :ready,
notify_parent: {:publish, {:new_tracks, [state.outgoing_track]}},
notify_parent: {:forward_to_parent, :call_ready}
]
state = %{state | payload_type: payload_type, endpoint_state: :in_call}
{actions, state}
end
@impl true
def handle_info({:call_info, {:call_ready, _opts}}, _ctx, %{endpoint_state: :in_call} = state) do
Logger.debug(
"SIP Endpoint: Received `:call_ready` info, but the pipelines are already spawned. Ignoring"
)
{[], state}
end
@impl true
def handle_info({:call_info, {:end, reason} = msg}, _ctx, state) do
case reason do
:cancelled ->
Logger.info("SIP Endpoint: Call attempt cancelled by user")
:user_hangup ->
Logger.info("SIP Endpoint: Call ended by user")
:busy ->
Logger.info("SIP Endpoint: Call declined, other side is busy")
:declined ->
Logger.info("SIP Endpoint: Call declined by other side")
:normal_clearing ->
Logger.info("SIP Endpoint: Call ended by other side (hangup)")
reason ->
Logger.warning("SIP Endpoint: Call ended with reason: #{inspect(reason)}")
end
actions = [
notify_parent: {:forward_to_parent, msg},
notify_parent: :finished
]
actions =
if state.endpoint_state != :in_call do
[notify_parent: :ready] ++ actions
else
actions
end
{actions, %{state | endpoint_state: :terminating}}
end
@impl true
def handle_info(info, _ctx, state) do
Logger.warning("SIP Endpoint: Unexpected info: #{inspect(info)}. Ignoring.")
{[], state}
end
@impl true
def handle_terminate_request(_ctx, state) do
Logger.debug("SIP Endpoint: Received terminate request")
case state.endpoint_state do
:calling ->
OutgoingCall.cancel(state.call_id)
:in_call ->
OutgoingCall.bye(state.call_id)
_other ->
nil
end
Call.stop(state.register_call_id)
{[terminate: :normal], %{state | endpoint_state: :terminating}}
end
defp try_calling(state, playback_state, phone_number) do
case state.endpoint_state do
_any when playback_state != :playing ->
Logger.info("SIP Endpoint: Postponing call until in state playing")
%{state | phone_number: phone_number}
:unregistered ->
Logger.info("SIP Endpoint: Postponing call until registered")
%{state | phone_number: phone_number, endpoint_state: :unregistered_call_pending}
:registered ->
Logger.info("SIP Endpoint: Calling #{inspect(phone_number)}...")
state = %{state | phone_number: phone_number}
{call_id, _pid} = spawn_call(state)
%{state | call_id: call_id, endpoint_state: :calling}
_other ->
Logger.warning("SIP Endpoint: Already calling, or endpoint is terminating")
state
end
end
defp spawn_call(state, module \\ OutgoingCall) do
state
|> Call.Settings.new()
|> module.start_link()
end
defp get_rtp_stream_pipeline(ssrc) do
get_child(:rtp)
|> via_out(Pad.ref(:output, ssrc),
options: [depayloader: Membrane.RTP.G711.Depayloader]
)
|> child({:audio_codec_decoder, ssrc}, Membrane.G711.Decoder)
|> child({:converter, ssrc}, %Membrane.FFmpeg.SWResample.Converter{
input_stream_format: %RawAudio{
channels: 1,
sample_format: :s16le,
sample_rate: Membrane.G711.sample_rate()
},
output_stream_format: %RawAudio{
channels: 1,
sample_format: :s16le,
sample_rate: @opus_sample_rate
}
})
|> child({:raw_audio_parser, ssrc}, %Membrane.RawAudioParser{
stream_format: %RawAudio{channels: 1, sample_format: :s16le, sample_rate: @opus_sample_rate},
overwrite_pts?: true
})
|> child({:opus_encoder, ssrc}, %Membrane.Opus.Encoder{
input_stream_format: %Membrane.RawAudio{
channels: 1,
sample_rate: @opus_sample_rate,
sample_format: :s16le
}
})
|> child({:opus_parser, ssrc}, Membrane.Opus.Parser)
end
end