This repository contains the unified, lightweight RTP monorepo designed for high-performance WebRTC video conferencing. It consolidates N2O WebSocket signaling pages, session authentication, room process supervisors, Mnesia persistence, and in-process GStreamer compositor port drivers into a single cohesive Erlang/OTP application.
├── c_src/
│ └── gst.c # GStreamer WebRTC MCU compositor — C99, 668 lines
├── include/
│ └── rtp_token.hrl # rtp_token record: token, user, room, device, expiry
├── config/
│ ├── config.exs # Elixir/OTP application environment
│ ├── sys.config # Mnesia dir, N2O parameters, port bindings
│ └── vm.args # Cluster node cookie and naming arguments
├── priv/
│ ├── gst # Compiled native C99 binary spawned by Erlang port
│ └── static/
│ ├── app/
│ │ ├── index.htm # Conference participant page (WebRTC + N2O chat)
│ │ ├── bcast.htm # HLS broadcast viewer page (passive observer)
│ │ └── login.htm # Session login page
│ ├── js/
│ │ ├── rtc.js # WebRTC signaling client: peer connection, SDP/ICE, telemetry
│ │ └── cast.js # HLS broadcast viewer: hls.js player, retro/live seek, telemetry
│ └── css/
│ ├── blank.css # Base Synrc CSS Layer
│ └── color.css # Custom Synrc CSS Layer
├── lib/
│ ├── hls.ex # HTTP Live Streaming handler — strips ETag, forces no-store
│ ├── n2o.ex # RTP.N2O <-> Bandit WebSocket proxy
│ ├── static.ex # Plug.Static asset server (port 8081) - routes to RTP.N2O
│ └── ws.ex # WebSocket server (port 8001) — routes to rtp_signaling
├── src/
│ ├── rtp_private.erl # N2O Nitro page: p2p chat history, file upload
│ ├── rtp_room.erl # N2O Nitro page: room chat history, member list, file upload
│ ├── rtp_login.erl # N2O Nitro page: session token issuance and redirect
│ ├── rtp_broker.erl # gen_server: GStreamer port lifecycle and Port IPC bridge
│ ├── rtp_store.erl # gen_server: Mnesia schema init, per-room chat tables
│ ├── rtp_signaling.erl # WebSock handler: SDP/ICE signaling, peer registration
│ ├── rtp_coordinator.erl # gen_server: room state, participant presence, media delegation
│ ├── rtp_routes.erl # N2O URL router: /app/index.htm → index, /app/login.htm → login
│ ├── rtp_app.erl # OTP Application: listeners, Syn scopes, session table, banner
│ ├── rtp_sup.erl # OTP Supervisor (one_for_one): rtp_store worker
│ ├── rtp_syn.erl # N2O MQ backend: wraps Syn v3 pub/sub as N2O pool registry
│ ├── rtp.app.src # Application descriptor and dependency list
│ └── rtp_token.erl # ETS-backed session token: issue, validate, update_device
├── mix.exs # Elixir package manager (Hex dependencies)
├── rebar.config # Rebar3 build configuration
├── GST.md # GStreamer MCU compositor specification
├── README.md # SYNRC RTP Erlang/OTP application passport
├── index.html # HTML version of RTP passport
├── gst-nuttx.pdf # GStreamer MCU port to NuttX RTOS (article)
└── rtp.pdf # RTP MCU Gateway article (LaTeX)
The system is organized into three distinct layers: the browser client (SPA), the Erlang/OTP control plane, and the C99 GStreamer media plane. All participants of a given room are bound to the same Erlang node via Ingress sticky sessions, eliminating inter-node cluster traffic and allowing the system to scale horizontally as a set of share-nothing pods.
graph TD
subgraph Clients["Browser Clients — N peers"]
JS["rtc.js — Camera and Mic"]
Cast["cast.js — HLS Viewer"]
end
subgraph Pod["Erlang/OTP Pod"]
WS["rtp_signaling — Bandit :8001"]
Coord["rtp_coordinator — gen_server"]
Broker["rtp_broker — gen_server"]
GST["GStreamer MCU — priv/gst C99"]
DB["rtp_store — Mnesia DB"]
Syn["Syn Registry"]
end
TURN["eturnal TURN — UDP/TCP :3478"]
subgraph Store["Recording Storage"]
HLS["index.m3u8 — HLS segments"]
MP4["recording.mp4 — fMP4"]
end
%% ── Signaling (thin) ─────────────────────────────────
JS -->|"TCP :8001 WSS — SDP/ICE"| WS
WS --> Coord --> Broker
Broker -->|"UNIX pipe stdin — JSON"| GST
GST -->|"UNIX pipe stdout — SDP/ICE JSON"| Broker
Broker -->|"Erlang msg via Syn"| WS
WS -->|"TCP :8001 WSS push"| JS
Coord --> DB
Coord --> Syn
%% ── 1. RTP upstream — camera to MCU (thick) ─────────
JS ==>|"1. UDP dynamic — DTLS-SRTP upstream camera"| GST
%% ── 2. RTP downstream — MCU composite (thick) ───────
GST ==>|"2. UDP dynamic — DTLS-SRTP MCU composite"| JS
GST ==>|"2. HTTP TCP :8081 — HLS segments"| Cast
%% ── 3. TURN relay — optional (dashed) ────────────────
JS -. "3. UDP/TCP :3478 — STUN/TURN optional" .-> TURN
TURN -. "UDP dynamic — relay DTLS-SRTP" .-> GST
%% ── Recording — local disk ────────────────────────────
GST --> HLS
GST --> MP4
Unlike Selective Forwarding Units (SFU), which route
The Erlang/OTP control plane strictly decouples connection management, logical room state, and media processing into three distinct layers. This separation ensures fault isolation, predictable latency, and granular crash recovery:
-
Signaling
rtp_signaling: Maps 1:1 to an active WebSocket connection. It acts as the edge protocol translator, converting JSON SDP/ICE messages from the browser into internal Erlang messages. If a client sends malformed data or drops the TCP connection ungracefully, only this isolated process terminates, leaving the rest of the room unaffected. -
Coordinator
rtp_coordinator: The pure domain-logic hub, mapping 1:1 to a conference room. It manages the participant list, presence broadcasts, and chat routing. By keeping it free from heavy I/O and OS process management, the coordinator maintains extremely low latency and high stability. It serves as the source of truth for the room. -
Broker
rtp_broker: The perilous boundary layer interfacing with the C99 GStreamer media pipeline. Managing external OS processes via Erlang Ports involves handling unpredictablestdoutchunking, buffering, and potential C-level crashes. The broker isolates this risk. If the C pipeline segfaults or the IPC pipe backs up, the broker can absorb the crash or block without freezing the room coordinator, allowing for graceful media recovery while text chat and presence remain fully operational.
rtp_app implements the application behaviour. On start it:
- Configures N2O: port
8001, protocols[nitro_n2o, n2o_heart], MQ backendrtp_syn. - Calls
kvs:join()to initialize the KVS schema layer. - Calls
rtp_token:init_table()to create the ETS session store. - Registers the
roomsandn2o_mqSyn scopes viasyn:add_node_to_scopes/1. - Spawns two Bandit listeners: WebSocket on port
8001, static assets on port8081. - Starts
rtp_sup.
The startup banner reports hardware capacity heuristics:
MaxRooms = logical_cores × 10RoomCapacity = 50participants per roomMaxParticipants = MaxRooms × RoomCapacity
one_for_one strategy with intensity 5 / period 10. Supervises a single
permanent worker: rtp_store. Room coordinators and media brokers are started
transiently on-demand by rtp_coordinator:ensure_started/1.
-record(rtp_token, {
token :: binary(), % Unique opaque session token
user :: binary(), % Username
room :: binary(), % Room name
device :: binary() | undefined, % WebRTC peer_id (populated on WebSocket connect)
expiry :: integer() % Gregorian seconds expiry (issue time + 180 s)
}).Token lifecycle:
issue(User, Room)— generates a cryptographic token vian2o_secret:sid/1, stores the record in thertp_tokensETS table with a 3-minute TTL.validate(Token)— looks up the ETS table, rejects expired entries and removes them.update_device(Token, PeerId)— associates the ephemeral WebRTCpeer_idwith the session on first WebSocket connection.
Implements the Elixir.WebSock behaviour. State record:
-record(state, {
user_id :: binary(),
room_id :: binary(),
role :: binary(),
peer_id :: binary(), % peer_<unique_integer>
room_pid :: pid()
}).init/1: Generates a unique peer_id, ensures the rtp_coordinator is started,
updates the session token device field, registers the process in the rooms Syn scope
(syn:register(rooms, PeerId, self())), and sends send_init_msg to itself.
handle_in/2: Decodes JSON text frames and dispatches:
| Client Message | Handler Action |
|---|---|
{"type":"ready"} |
Calls rtp_coordinator:originate_video/3 — triggers GStreamer peer join |
{"type":"get_room_info"} |
Retrieves started_at from broker; pushes room_info |
{"type":"get_peers"} |
Retrieves peer list from broker; pushes peer_list |
{"type":"ping"} |
No-op keep-alive |
{"sdp":{"type":"answer","sdp":...}} |
Forwards SDP answer to rtp_coordinator |
{"candidate":...} |
Forwards ICE candidate to rtp_coordinator |
handle_info/2: Routes Erlang messages to WebSocket pushes:
| Erlang Message | WebSocket Push |
|---|---|
send_init_msg |
{"type":"init","peer_id":"..."} |
{send_room_info, StartedAt} |
{"type":"room_info","started_at":...,"hls_format":...} |
{sdp_offer, Sdp} |
{"sdp":{"type":"offer","sdp":"..."}} |
{ice_candidate, Candidate} |
{"candidate":{...}} |
{peer_joined, PeerId} |
{"type":"peer_joined","peer_id":"..."} |
{peer_left, PeerId} |
{"type":"peer_left","peer_id":"..."} |
terminate/2: Calls rtp_coordinator:peer_left/2 and unregisters from Syn.
A per-room gen_server started on-demand by ensure_started/1. It is registered
in the rooms Syn scope under the binary room ID. State record:
-record(state, {
room_id :: binary(),
participants = [] :: list(), % Active member maps: #{id, pid}
publishers = [] :: list(), % Active media publishers
media_broker = undefined :: pid() | undefined
}).Handles:
| Call / Cast | Behaviour |
|---|---|
{join, Participant} |
Adds participant; publishes {presence, join, Participant} via Syn |
{leave, ParticipantId} |
Removes participant; publishes {presence, leave, ParticipantId} |
{chat, Sender, Message} |
Writes to Mnesia via rtp_store; publishes via n2o:send/2 |
{originate_video, PeerId, ClientPid} |
Lazily starts rtp_broker; calls peer_joined/4 |
{sdp_answer, PeerId, Sdp} |
Delegates to rtp_broker:sdp_answer/4 |
{ice_candidate, PeerId, Candidate} |
Delegates to rtp_broker:ice_candidate/4 |
{peer_left, PeerId} |
Delegates to rtp_broker:peer_left/3 |
terminate_room |
Calls rtp_broker:terminate_room/2; stops broker |
get_started_at |
Queries broker for recording start timestamp |
get_peers |
Queries broker for active peer list |
On terminate/2, unregisters from Syn and stops the media broker if active.
A per-room-group gen_server managing the GStreamer port process lifecycle. State:
-record(state, {
ports = #{}, % RoomId → Port
room_peers = #{}, % RoomId → [PeerId]
peer_rooms = #{}, % PeerId → RoomId
room_started_at = #{}, % RoomId → millisecond timestamp
monitors = #{} % MonitorRef → {RoomId, PeerId}
}).Port spawning (peer_joined call): On the first peer join for a room, spawns
the priv/gst binary as an Erlang port with:
open_port({spawn_executable, Binary}, [
binary, stream, {args, [OutDir, FormatStr]},
use_stdio, stderr_to_stdout, exit_status,
{line, 16384},
{env, [{"GST_GL_WINDOW", "none"},
{"GST_PLUGIN_FEATURE_FILTER", "opengl:0,applemedia:0"}]}
])Port IPC (send_to_port/2): Encodes Erlang maps as JSON via jsone:encode/1
and writes to the port with a trailing newline.
Stdout parsing (handle_info({Port, {data, {eol, Line}}}, ...)): Decodes JSON
lines received from the GStreamer process and dispatches:
| GStreamer Output | Erlang Action |
|---|---|
{"type":"sdp_offer","peer_id":"...","sdp":"..."} |
Sends {sdp_offer, Sdp} to the peer's signaling process via syn:lookup |
{"type":"ice_candidate","peer_id":"...","candidate":{...}} |
Sends {ice_candidate, Candidate} to the peer's signaling process |
{"type":"recording_started"} |
Records real start time; begins polling for index.m3u8 manifest |
Manifest polling (poll_manifest): After recording_started, polls
priv/static/rooms/<id>/index.m3u8 every 100 ms (up to 1000 attempts / 100 seconds).
When the manifest appears on disk, broadcasts room_info to all room peers via Syn.
Process monitoring: On peer_joined, monitors the client WebSocket process PID.
If the process dies (browser tab closed, network drop), the 'DOWN' message triggers
handle_peer_departure, cleanly notifying GStreamer and updating room state.
Last-peer cleanup: When handle_peer_departure reduces the room peer list to
empty, the port is closed (catch port_close(Port)), automatically terminating
the GStreamer process and freeing media resources.
A named gen_server (singleton) initializing Mnesia on startup. Schema:
| Table | Type | Key | Fields |
|---|---|---|---|
chat_message |
ordered_set, disc_copies |
{room_id, timestamp} |
room_id, sender, text |
room_state |
set, disc_copies |
room_id |
state_data |
chat_room_<RoomId> |
ordered_set, disc_copies |
{room_id, timestamp} |
per-room chat partition |
Per-room tables (create_room_table/1) are created lazily when a room coordinator
starts, enabling namespace-level partitioning of chat history across rooms.
Implements the N2O MQ interface backed by Syn v3, replacing Redis:
send(Pool, Message) ->
syn:publish(n2o_mq, term_to_binary(Pool), Message).
reg(Pool, _Value) ->
syn:join(n2o_mq, term_to_binary(Pool), self()).This provides in-memory, distributed-optional pub/sub for N2O page events (chat messages, member presence) without any external message broker dependency.
N2O router implementing init/2 and finish/2. Maps HTTP path prefixes to
Erlang page modules:
| Path prefix | Module |
|---|---|
/ or `` |
rtp_login |
/ws/index... or /app/index... |
rtp_room |
/ws/login... or /app/login... |
rtp_login |
N2O Nitro page. The login event:
- Reads
userandpass(room name) form fields vianitro:q/1. - Issues a session token via
rtp_token:issue/2. - Stores user and token in the N2O session.
- Writes
localStorage.setItem('rtp_joined', 'true')vianitro:wire/1. - Redirects to
/app/index.htm?room=<room>&user=<user>&token=<token>.
N2O Nitro page serving the conference room interface. The init event:
- Validates the session token from URL params or session store.
- Registers the process on the room topic (
n2o:reg({topic, Room})). - Renders the logout button, room heading, chat send button, upload widget, and terminate button.
- Calls
rtp_coordinator:ensure_started/1andrtp_coordinator:join/2. - Loads chat history from Mnesia and renders message elements.
- Renders the active participants list via injected JavaScript.
- Broadcasts
{member_joined, User}to the room topic.
Manages the full WebRTC participant lifecycle within index.htm:
- Session persistence: Room, user, and token are read from URL params with
localStoragefallback, enabling F5 resume without re-authentication. - Signaling: Connects to
ws://<host>:8001/ws/signaling?room=...&user=...&token=.... Handlesinit,room_info,sdp(offer), andcandidatemessages. startConference(): AcquiresgetUserMedia(video 640×360/30fps + audio, with audio-only fallback), createsRTCPeerConnection, adds tracks, and sends{"type":"ready"}to trigger GStreamer peer join.leaveConference(): Closes WebSocket, stops tracks, resets PeerConnection, clearslocalStorage.rtp_joined.- Telemetry: Polls
RTCPeerConnection.getStats()every 2 seconds and displays RTT (ms) and packet loss (%) fromremote-inbound-rtpandcandidate-pairreports. - Autoplay handling: On
NotAllowedErrorfromvideo.play(), injects a click-to-play overlay button.
Manages HLS passive viewer playback within bcast.htm:
- HLS player: Uses
hls.jswithliveSyncDurationCount=2,backBufferLength=30. Loadsplaylist-location=/rooms/<room>/index.m3u8. - Live/Retro mode: Detects scrubbing via
retroSliderand toggles between LIVE (pulsing red dot) and RETRO (grey dot) states. Automatic catch-up logic jumps toliveSyncPositionwhen drift exceeds 10 seconds. - Error recovery: Network errors reload the playlist source after 1 second;
media errors call
recoverMediaError(); fatal errors destroy and reinitialize the player after 2 seconds. - Stream end (VOD mode): On
video.ended, disables catch-up, changes badge to "ЗАВЕРШЕНО", and treats the stream as a playable archive. - Telemetry sidebar: WebSocket connection to the signaling server for receiving
peer_joined/peer_leftevents and displaying active peer count and list. - Stall detection: 5-second
waitingevent timeout triggers full player reinitialization.
The GStreamer pipeline (c_src/gst.c) maintains a permanent backbone activated at
startup: a black videotestsrc feeds compositor.sink_0 and a silent audiotestsrc
feeds audiomixer.sink_0. This prevents scheduler stalls when no peers are connected.
Three output formats are supported, selected by the hls_format application environment:
| Format | Video Encoder | Audio Encoder | Sink |
|---|---|---|---|
ts (default) |
x264enc → h264parse → rtph264pay / hlssink2 | opusenc (WebRTC) + avenc_aac (HLS) | hlssink2 (2s segments, playlist-length=10) |
fmp4 / mp4 |
x264enc → h264parse → rtph264pay / mp4mux | opusenc (WebRTC) + avenc_aac (mux) | mp4mux fragment-duration=1000 streamable=true |
hevc / h265 |
x264enc (WebRTC) + x265enc (HLS) | opusenc (WebRTC) + avenc_aac (HLS) | hlssink2 with H.265 video |
Standard static file servers use ETag / Last-Modified headers for cache
revalidation. In an HLS context where index.m3u8 is updated every 2 seconds
but may retain identical byte sizes across successive updates, servers return
304 Not Modified, starving hls.js of new segment announcements and causing
hard playback stalls.
Rtp.LiveStream (Elixir) intercepts all .m3u8 requests before Plug.Static
evaluation, stripping ETag generation and injecting:
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
This guarantees deterministic delivery of the live edge state on every poll.
Earlier iterations routed H.264 through rtph264pay → rtph264depay before feeding
hlssink2. The RTP payload/depayload cycle introduces microscopic PTS/DTS
discontinuities that mpegtsmux cannot tolerate, causing MSE decoders to freeze.
The corrected architecture tees the stream after h264parse (and before
rtph264pay), sending a pristine bitstream directly to hlssink2 while a separate
branch handles RTP payloading for WebRTC participants. Audio is teed from the raw
audiomixer output (raw_atee) before the Opus encoder, preserving timestamps for
both paths independently.
HLS segment writes (approx. 1 MB per 2-second .ts segment) introduce I/O stalls
that, under standard 1.2-second queue limits, cause upstream frame drops. Storage
branches use queue max-size-time=30000000000 leaky=2 (30 s). Frames accumulate
in RAM during filesystem stalls, mathematically guaranteeing HLS continuity
independent of disk scheduler latency.
| Port | Protocol | Purpose |
|---|---|---|
8001 |
WebSocket (Bandit) | N2O signaling + WebRTC SDP/ICE signaling |
8081 |
HTTP (Bandit) | Static file server (Plug.Static) for priv/static/ |
3478 |
UDP/TCP (eturnal) | STUN/TURN relay for NAT traversal |
5349 |
UDP/TCP (eturnal) | TURNS (STUN/TURN over TLS) |
Mnesia directory defaults to /var/lib/rtp/mnesia (Kubernetes PVC) with
fallback to ./mnesia_data for local development.
brew install gstreamer libnice libnice-gstreamer json-glib erlangsudo apt-get update
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev libjson-glib-dev pkg-configcc -O3 c_src/gst.c -o priv/gst \
$(pkg-config --cflags --libs \
gstreamer-1.0 gstreamer-webrtc-1.0 gstreamer-sdp-1.0 json-glib-1.0)iex -S mixExpected banner:
╔════════════════════════════════════════════════════════╗
║ ERP/1: RTP Server / Signaling & Telemetry ║
║ WS : ws://localhost:8001/ws/app/<page>.htm ║
║ HTTP: http://localhost:8081/app/login.htm ║
╚════════════════════════════════════════════════════════╝
Hardware : 10 Cores, 16 GB RAM
Max Rooms : 100 (heuristic based on cores)
Capacity : 5000 max participants (50 per room)
RTP Codecs : Opus (Audio), VP8, VP9, H.264 (Video)
Navigate to http://localhost:8081/app/login.htm. Enter a username and room name.
The login page issues a 3-minute session token and redirects to the conference page.
| Standard | Description | System Mapping |
|---|---|---|
| H.264 | Active video codec (AVC) | x264enc in gst.c (WebRTC + HLS) |
| H.265 | High-efficiency video codec (HEVC) | x265enc in gst.c (HEVC HLS pipeline) |
| H.323 | Packet multimedia systems | rtp_coordinator architecture mirrors H.323 MCU |
| H.235.8 | SRTP key exchange via secure signaling | DTLS-SRTP negotiated by webrtcbin |
| H.239 | Role management (participant/presenter) | role field in rtp_signaling state |
| H.245 | Control protocol for multimedia | SDP in WebRTC (RFC 8866 / RFC 3264) |
| G.711 | PCM 64 kbit/s | WebRTC baseline audio |
| G.722 | 7 kHz wideband | WebRTC HD voice |
| T.124 | Generic conference control | rtp_coordinator gen_server |
| X.601 | Multi-peer communications framework | N2O WebSocket + Syn room scope |
| X.603 | Relayed multicast protocol | Erlang Port IPC (stdin/stdout relay) |
- M. Sokhatsky. RTP: A Minimal MCU Gateway of ANSI C99 with GStreamer for High-Density Video Conferencing on Erlang/OTP Control Plane in Alpine Linux under Kubernetes (Part of Zen Crypted X.422.2 Buddha Protocol). Axiosis. 2026. (rtp.pdf)
- M. Sokhatsky. NuStream: A Lightweight, Predictable Deterministic Real-Time Media Pipeline with GStreamer-like API for NuttX RTOS. Axiosis. 2026. (gst-nuttx.pdf)
- Ericsson Research — First GStreamer WebRTC implementation (OpenWebRTC)
- 5HT — Author of SYNRC RTP