Packages

Membrane source element for SRT streams via the srt-live-transmit CLI tool.

Current section

Files

Jump to
membrane_srt_live_transmit lib membrane srtlt source.ex
Raw

lib/membrane/srtlt/source.ex

defmodule Membrane.SRTLT.Source do
@moduledoc """
Membrane source element that receives data from an SRT stream via
the `srt-live-transmit` CLI tool.
The element spawns `srt-live-transmit` as a child process, connecting to
(or listening for) an SRT peer, and forwards the raw byte stream received
on stdout as `Membrane.Buffer` structs on a push-mode output pad.
## Notifications
The element sends the following notifications to its parent:
* `{:source_state, :connected}` — first data chunk received from the process.
* `{:source_state, :disconnected}` — the process exited after data had been
received. Followed by an end-of-stream action.
## Graceful shutdown
Send a `:close` parent notification to stop the source cleanly. The element
will terminate the child process and emit end-of-stream.
## Example
child(:srt_source, %Membrane.SRTLT.Source{
host: "10.0.0.1",
port: 9000,
mode: :caller,
latency_ms: 350,
stream_id: "my-stream",
passphrase: "secret123456"
})
"""
use Membrane.Source
require Membrane.Logger
@retry_base_ms 100
@retry_max_ms 3_000
@process_cleanup_tag :srtlt_source_process_cleanup
def_output_pad(:output,
accepted_format: Membrane.RemoteStream,
flow_control: :push
)
def_options(
host: [
spec: String.t(),
default: "127.0.0.1",
description: "Hostname or IP address of the SRT peer."
],
port: [
spec: :inet.port_number(),
default: 9710,
description: "Port of the SRT peer."
],
mode: [
spec: :caller | :listener,
default: :caller,
description: "SRT connection mode."
],
latency_ms: [
spec: pos_integer(),
default: 350,
description:
"Symmetric SRT latency in milliseconds. Used as the default for `peer_latency_ms` and `rcv_latency_ms` when those are not set."
],
peer_latency_ms: [
spec: pos_integer() | nil,
default: nil,
description: "SRT peer (send) latency in ms. Falls back to `latency_ms`."
],
rcv_latency_ms: [
spec: pos_integer() | nil,
default: nil,
description: "SRT receive latency in ms. Falls back to `latency_ms`."
],
transtype: [
spec: String.t(),
default: "live",
description: "SRT transport type (`live` or `file`)."
],
stream_id: [
spec: String.t() | nil,
default: nil,
description: "SRT stream identifier for multiplexing."
],
passphrase: [
spec: String.t() | nil,
default: nil,
description: "SRT encryption passphrase (10–79 characters). `nil` = no encryption."
],
chunk_size_bytes: [
spec: pos_integer(),
default: 1316,
description:
"Max bytes read per step by srt-live-transmit. Default 1316 = 188×7 (7 MPEG-TS packets)."
],
buffering_packets: [
spec: pos_integer(),
default: 10,
description: "Application-level read batch size in srt-live-transmit."
],
telemetry_prefix: [
spec: [atom()] | nil,
default: [:membrane, :srtlt],
description:
"Telemetry event prefix for SRT stats. Events are emitted as `prefix ++ [:stats]`. Set to `nil` to disable."
]
)
# -- Callbacks --
@impl true
def handle_init(_ctx, opts) do
state = %{
host: opts.host,
port: opts.port,
mode: opts.mode,
latency_ms: opts.latency_ms,
peer_latency_ms: opts.peer_latency_ms,
rcv_latency_ms: opts.rcv_latency_ms,
transtype: opts.transtype,
stream_id: opts.stream_id,
passphrase: opts.passphrase,
chunk_size_bytes: opts.chunk_size_bytes,
buffering_packets: opts.buffering_packets,
telemetry_prefix: opts.telemetry_prefix,
command_ref: nil,
received_data?: false,
notified_connected?: false,
eos_sent?: false,
retry_timer_ref: nil,
retry_delay_ms: @retry_base_ms
}
{[], state}
end
@impl true
def handle_setup(ctx, state), do: connect(ctx, state)
@impl true
def handle_playing(_ctx, state) do
{[stream_format: {:output, %Membrane.RemoteStream{}}], state}
end
@impl true
def handle_parent_notification(:close, ctx, state) do
if state.eos_sent? do
{[], state}
else
state =
state
|> cancel_retry_timer()
|> drop_command_ref(ctx)
{[end_of_stream: :output], %{state | eos_sent?: true}}
end
end
def handle_parent_notification(_notification, _ctx, state), do: {[], state}
@impl true
def handle_terminate_request(ctx, state) do
state =
state
|> cancel_retry_timer()
|> drop_command_ref(ctx)
{[terminate: :normal], state}
end
@impl true
def handle_info(:retry_connect, _ctx, %{eos_sent?: true} = state) do
{[], %{state | retry_timer_ref: nil}}
end
def handle_info(:retry_connect, ctx, state) do
connect(ctx, %{state | retry_timer_ref: nil})
end
def handle_info({:stdout, ospid, payload}, ctx, %{command_ref: %{ospid: ospid}} = state) do
notify_actions =
if state.notified_connected? do
[]
else
[notify_parent: {:source_state, :connected}]
end
actions =
if ctx.playback == :playing do
notify_actions ++ [buffer: {:output, %Membrane.Buffer{payload: payload}}]
else
notify_actions
end
{actions,
%{state | received_data?: true, notified_connected?: true, retry_delay_ms: @retry_base_ms}}
end
def handle_info({:stderr, ospid, payload}, _ctx, %{command_ref: %{ospid: ospid}} = state) do
handle_stderr(payload, state)
{[], state}
end
def handle_info(
{:DOWN, ospid, :process, _pid, reason},
ctx,
%{command_ref: %{ospid: ospid}} = state
) do
reason = normalize_down_reason(reason)
state =
state
|> cancel_retry_timer()
|> drop_command_ref(ctx)
cond do
state.eos_sent? ->
{[], state}
state.received_data? ->
{[notify_parent: {:source_state, :disconnected}, end_of_stream: :output],
%{state | eos_sent?: true}}
true ->
maybe_log_reconnect(reason)
retry_in_ms = state.retry_delay_ms
state = schedule_retry_with_backoff(state)
Membrane.Logger.info(
"srt-live-transmit[source]: no payload yet, reconnecting in #{retry_in_ms}ms"
)
{[], state}
end
end
def handle_info(_message, _ctx, state), do: {[], state}
# -- Private --
defp connect(ctx, state) do
state = cancel_retry_timer(state)
with {:ok, executable} <- fetch_srt_live_transmit(),
{:ok, pid, ospid} <- run_command(executable, state) do
Membrane.ResourceGuard.unregister(ctx.resource_guard, @process_cleanup_tag)
Membrane.ResourceGuard.register(
ctx.resource_guard,
fn -> safe_stop(ospid) end,
tag: @process_cleanup_tag
)
{[], %{state | command_ref: %{pid: pid, ospid: ospid}, eos_sent?: false}}
else
{:error, reason} ->
Membrane.Logger.warning("srt-live-transmit start failed: #{inspect(reason)}")
{[], schedule_retry_with_backoff(state)}
end
end
defp run_command(executable, state) do
uri = build_srt_uri(state)
cmd = [
executable,
"-autoreconnect",
"no",
"-srctime",
"yes",
"-loglevel",
"warn",
"-chunk:#{state.chunk_size_bytes}",
"-buffering:#{state.buffering_packets}",
"-stats-report-frequency",
"100",
"-pf",
"json",
uri,
"file://con",
"-statsout",
"/dev/stderr"
]
:exec.run(cmd, [
{:stderr, self()},
{:stdout, self()},
:monitor,
{:kill, "kill -s TERM ${CHILD_PID}"},
{:kill_timeout, 1}
])
end
defp build_srt_uri(state) do
peer_latency = state.peer_latency_ms || state.latency_ms
rcv_latency = state.rcv_latency_ms || state.latency_ms
Membrane.SRTLT.build_uri(
host: state.host,
port: state.port,
mode: state.mode,
latency: state.latency_ms,
peerlatency: peer_latency,
rcvlatency: rcv_latency,
transtype: state.transtype,
streamid: state.stream_id,
passphrase: state.passphrase
)
end
defp fetch_srt_live_transmit do
case System.find_executable("srt-live-transmit") do
nil -> {:error, :srt_live_transmit_not_found}
executable -> {:ok, executable}
end
end
defp schedule_retry_with_backoff(
%{retry_timer_ref: nil, retry_delay_ms: retry_delay_ms} = state
) do
next_delay_ms = min(retry_delay_ms * 2, @retry_max_ms)
%{
state
| retry_timer_ref: Process.send_after(self(), :retry_connect, retry_delay_ms),
retry_delay_ms: next_delay_ms
}
end
defp schedule_retry_with_backoff(state), do: state
defp cancel_retry_timer(%{retry_timer_ref: nil} = state), do: state
defp cancel_retry_timer(%{retry_timer_ref: timer_ref} = state) do
_ = Process.cancel_timer(timer_ref)
%{state | retry_timer_ref: nil}
end
defp drop_command_ref(%{command_ref: nil} = state, ctx) do
Membrane.ResourceGuard.unregister(ctx.resource_guard, @process_cleanup_tag)
state
end
defp drop_command_ref(%{command_ref: %{ospid: ospid}} = state, ctx) do
safe_stop_and_wait(ospid)
Membrane.ResourceGuard.unregister(ctx.resource_guard, @process_cleanup_tag)
%{state | command_ref: nil}
end
defp safe_stop_and_wait(ospid) do
_ = :exec.stop_and_wait(ospid, 300)
:ok
rescue
ArgumentError -> :ok
end
defp safe_stop(ospid) do
_ = :exec.stop(ospid)
:ok
rescue
ArgumentError -> :ok
end
defp handle_stderr(payload, state) when is_binary(payload) do
if state.telemetry_prefix do
metadata = %{host: state.host, port: state.port, stream_id: state.stream_id}
for measurements <- Membrane.SRTLT.Stats.parse_all(payload) do
Membrane.SRTLT.Stats.emit(measurements, state.telemetry_prefix, metadata)
end
end
maybe_log_stderr(payload)
end
defp handle_stderr(_payload, _state), do: :ok
defp maybe_log_stderr(payload) when is_binary(payload) do
payload
|> String.split("\n", trim: true)
|> Enum.each(fn line ->
trimmed = String.trim(line)
if trimmed != "" and not String.starts_with?(trimmed, "{") do
Membrane.Logger.warning("srt-live-transmit[source]: #{inspect(trimmed)}")
end
end)
end
defp maybe_log_stderr(_payload), do: :ok
defp maybe_log_reconnect(:normal), do: :ok
defp maybe_log_reconnect(:port_closed), do: :ok
defp maybe_log_reconnect(reason) do
Membrane.Logger.warning("srt-live-transmit[source]: exited with reason #{inspect(reason)}")
end
defp normalize_down_reason(:normal), do: :normal
defp normalize_down_reason({:status, status}), do: :exec.status(status)
defp normalize_down_reason({:exit_status, code}), do: {:status, code}
defp normalize_down_reason(:port_closed), do: :port_closed
defp normalize_down_reason(other), do: other
end