Media engines: rtpengine vs siphon-rtp¶
SIPhon does not relay media itself — it drives an external media engine that
anchors and transforms RTP. You pick one of two engines with media.backend:
rtpengine (default) |
siphon-rtp |
|
|---|---|---|
| Status | Production | Experimental (pre-release) |
| Project | sipwise/rtpengine | in-house, pure-Rust |
| Control transport | NG protocol, bencode over UDP | native JSON over a persistent TCP connection |
| Datapath | userspace or in-kernel (xt_RTPENGINE module) |
userspace, optional AF_XDP acceleration |
| Packaging | distro package / container; kernel module for the fast path | single static binary, no kernel module |
| Auth on the control channel | none (bind to loopback / a trusted net) | optional shared-secret handshake |
| Async events to SIPhon | DTMF only, over a separate event log (media.events ← rtpengine's dtmf-log-ng-tcp-uri) |
DTMF and media-timeout, pushed on the same control connection |
| HA in SIPhon | weighted round-robin over instances[] |
weighted round-robin + per-call-id affinity over instances[] |
siphon-rtp is experimental — use rtpengine in production
The siphon-rtp engine is pre-release. Run it for evaluation and lab work;
keep rtpengine (the default) for production until siphon-rtp stabilises.
What is the same either way. The rtpengine scripting namespace
(offer / answer / delete, play_media, play_dtmf, silence_media,
@rtpengine.on_dtmf, …), the media profiles,
and the MediaSessionStore are the same on both. Only the
engine you run and the media: block that points at it change — a script written
for one backend runs unmodified on the other, unless it uses one of the
engine-specific profile fields below. The rest of the differences are
operational, and that is what the rest of this page covers.
What is not. A few media-profile fields exist only on the engine that can
perform them. siphon refuses to start if a media.profiles entry asks for
something its media.backend cannot honour, naming the profile and the field —
a ws_uri the engine never receives would otherwise answer the call and bridge
it nowhere, with nothing logged and silence on the line.
| Profile field | siphon-rtp |
rtpengine |
rtpproxy |
|---|---|---|---|
ws_uri, ws_vad, ws_barge_in, ws_vad_threshold, ws_vad_hangover_ms |
yes | — | — |
ws_tee, ws_tee_direction, ws_tee_channels |
yes | — | — |
noise_suppression, echo_cancellation |
yes | — | — |
echo_delay_search_ms, echo_long_tail, echo_residual_suppression |
yes | — | — |
received_from, rtcp_mux |
yes | yes | — |
address_family |
yes | yes | — [^af] |
[^af]:
address_family on rtpproxy warns at boot rather than failing the load:
rtpproxy's 6 modifier states the family of the address the command already
carries, so the call still works and only IPv4/IPv6 interworking is lost.
WebSocket audio: bridge vs tee¶
siphon-rtp can stream a call's decoded audio to a WebSocket media server in
two different shapes. They look similar in config and are not interchangeable —
picking the wrong one is the most likely way to get this wrong.
ws_uri / attach_ws_bridge — bridge |
ws_tee / attach_ws_tee — tee |
|
|---|---|---|
| The WS server is | leg A's far side | an extra listener |
| A↔B relay | not wired | stays wired |
| Audio direction | bidirectional | send-only |
| SIPREC / recording on the leg | n/a (there is no B leg) | keep running |
| Typical use | voice-AI answers the call | live transcription, agent assist, compliance |
Bridge (ws_uri). The engine dials the URI and the WS server becomes the
far end: it receives the caller's audio as L16 and what it sends back is encoded
toward the caller. There is no second SIP leg. This is the voice-AI
answer-the-call shape, usually paired with rtpengine.answer_local(...) and the
built-in voice_ai profile.
A bridge can also be attached to a call that is already relaying, rather than negotiated at offer/answer — the engine takes the live relay over, and gives it back on detach:
# Take the call over mid-call; the WS server is now this leg's far side.
await rtpengine.attach_ws_bridge(call, "wss://ai.internal/session-1")
# Move the same party to a different session. One verb, and the media path
# never returns to the relay in between.
await rtpengine.attach_ws_bridge(call, "wss://ai.internal/session-2")
# Hand the media path back to the relay.
await rtpengine.detach_ws_bridge(call)
Attaching to a call that already has a bridge is a re-point, not an error. That is the point of it: a detach followed by an attach would hand the path back to the relay for as long as the next attach took to land, and the other party hears that as a gap.
detach_ws_bridge is not idempotent, unlike detach_ws_tee. The engine
refuses a detach where there is no relay to hand the call back to — a
ws_uri-negotiated bridge is the call's whole media path, and a single-leg
answer_local takeover has no second party it could ever be relayed to — and
siphon raises rather than answering success, because the alternative is a live
call with no audio path at all. Re-point those instead, or end the call.
Watch the lifecycle with @rtpengine.on_ws_bridge_started /
on_ws_bridge_ended. detached and call_ended (the call was torn down and
took the bridge with it) are the orderly ends; every other reason
(server_closed, server_stopped, transport_error) leaves both parties up
and hearing nothing, which is why an unexpected end is logged at WARN
even when no handler is registered. A re-point arrives as an ended with reason
detached followed by a fresh started.
Tee (ws_tee). The call relays or transcodes exactly as it otherwise would,
and a copy of the decoded audio is streamed out. Additive: one decode feeds
the peer, the recorder, any SIPREC fork and the tee — there is no second jitter
buffer and no second decode. A plain in-kernel relay is promoted to the
userspace pipeline for the tee's lifetime and demoted again on detach.
A tee never affects the call. If the consumer stalls the engine drops frames
rather than backing up the media path, and reports the count on the
ws_tee_ended event.
Declare a tee on a profile:
media:
backend: siphon-rtp
profiles:
recorded_call:
offer:
ws_tee: "wss://asr.internal/stream/{call_id}"
ws_tee_direction: both # both (default) | caller | callee
ws_tee_channels: 2 # 2 = caller/callee stereo, 1 = mixed mono
answer: {}
…or attach and detach one mid-call from a script:
@b2bua.on_answer
async def on_answer(call, reply):
await rtpengine.answer(reply)
try:
await rtpengine.attach_ws_tee(call, f"wss://asr.internal/{call.call_id}")
except RuntimeError as error:
log.warn(f"transcription tee unavailable: {error}") # never fail the call
@b2bua.on_bye
async def on_bye(call):
await rtpengine.detach_ws_tee(call) # idempotent; the call teardown also does it
ws_tee_channels only means anything with ws_tee_direction: both — a
single-leg tee is always mono. Unset leaves the engine's default: 2 channels
for both legs, 1 for one.
Know when a tee dies. A tee can end without the call ending — the server closes the socket, or the transport fails. Nothing about the call changes, so this is invisible unless you watch for it:
@rtpengine.on_ws_tee_ended
def tee_down(call_id, from_tag, stream_id, reason, frames_sent, frames_dropped):
if reason not in ("detached", "call_ended"): # the orderly ends
log.warn(f"tee {stream_id} died: {reason} after {frames_sent} frames")
if frames_dropped:
log.warn(f"tee {stream_id} dropped {frames_dropped} frames — consumer too slow")
siphon also logs an unexpected tee end at WARN whether or not a handler is
registered. @rtpengine.on_ws_tee_started gives the matching start, carrying
the negotiated channels and sample_rate so a consumer decodes the binary
frames rather than guessing; stream_id correlates the control event with the
start envelope on the socket.
Both the bridge and the tee can run at a wire rate independent of the legs'
codec rates. ws_sample_rate applies to the ws_uri bridge in both
directions, so an 8 kHz G.711 call can speak 16 kHz to an inference server, and
a server rendering 24 kHz audio into that call plays at the right speed and
pitch instead of the wrong one. ws_tee_sample_rate (or
attach_ws_tee(..., sample_rate=...)) is send-only and changes only what the
tee consumer receives. Both must be a multiple of 1000 within 8000–48000; the
engine fails the offer rather than clamping, so siphon rejects a bad value at
boot.
Answering-machine detection¶
The media half of AMD: the engine watches a leg's decoded audio for the short single tone an answering machine plays before it starts recording, and reports it. Without it a transfer cannot tell a person from a voicemail box, and the caller gets bridged into the greeting.
Arm it per leg. The profile used toward the callee is what watches the party that might be a machine:
media:
backend: siphon-rtp
profiles:
screened_callee:
offer:
replace: ["origin"]
answer:
replace: ["origin"]
beep_detection: true
beep_cadence_guard_ms: 4500 # default
@rtpengine.on_beep
def machine(call_id, from_tag, to_tag, frequency_hz, duration_ms, offset_ms):
log.info(f"{call_id}: answering machine ({frequency_hz:.0f} Hz at {offset_ms} ms)")
b2bua.terminate(call_id, "Answering machine detected")
Three things to know:
- It fires once per leg per call. The engine drops the detector after the
first tone, so a handler never de-duplicates, and there is no mid-call re-arm —
a fresh
offer/answerwith the flag set re-arms it. beep_cadence_guard_msis also the detection latency. It is the window the detector waits to rule out a repeat, which is what tells a record tone from a cadenced ringback, busy or congestion tone. The event therefore arrives that long after the beep. Lowering it trades cadence robustness for latency.offset_msis the offset of the tone, not of the event. It counts decoded audio seen on the leg before the tone started, so it is the right number to reason about "how far into the call" — the event trails it by the guard.
Detection needs decoded audio, so like noise_suppression it promotes a
same-codec plaintext call onto the userspace media pipeline, and it is inert on
a codec whose native rate is neither 8 nor 16 kHz.
Media started¶
The native engine reports the first packet on each of a call's legs, within
about 20 ms of it arriving. @rtpengine.on_media_started hears it once per leg,
never again on a re-latch or a re-offer:
@rtpengine.on_media_started
def flowing(call_id, from_tag, to_tag, leg, source, signalled):
if source and signalled and source != signalled:
log.info(f"{call_id}: {leg} leg is behind a NAT ({source})")
leg is "near" (the offerer's side) or "far" (the answerer's). source is
the address the engine latched and signalled the one in the SDP, each
"ip:port" or None. The same report reaches a controller as MediaStarted,
and each party's start time lands in the media CDR as
{near,far}_media_started_at_unix_ms.
siphon names each leg's SIP Call-ID to the engine on the offer and the answer, so the HEP captures the engine exports for a leg (its RTCP and QoS reports) are filed under that leg's own dialog. That matters on a B2BUA, whose two legs are two dialogs, and on a bridged pair or a transfer, which run on an engine call-id of their own.
Prompts, tones and overlays¶
rtpengine.play_media(...) plays a prompt to the party whose tag it is given:
the engine plays toward that leg, replacing what the leg receives, and the
other party keeps hearing it live. A SIP object gives the From-tag, so
play_media(call) plays to the caller and play_media((call_id, callee_tag), ...)
to the callee. Its source can be a file, raw bytes, an engine prompt-DB id, a
synthesised tone, or a URL the engine fetches:
await rtpengine.play_media(call, tone="ringback_eu") # preset
await rtpengine.play_media(call, tone="425/1000,0/4000*inf") # cadence spec
await rtpengine.play_media(call, url="https://prompts.internal/welcome.wav")
A tone needs no provisioned audio file and renders at the leg's codec rate, so it
is never resampled. A preset name is told from a cadence spec by the /. An
HTTP source is fetched by the engine from its own network position, bounded
engine-side and off the media path — a URL that never answers ends the
playback, never the leg — so restrict the reachable hosts if you do not fully
trust whoever supplies the URL.
Overlays mix audio under a party's live egress instead of replacing it, and return a handle so you can change or stop that one playback:
bed = await rtpengine.play_overlay(call, file="/prompts/hold.wav", repeat="inf")
await rtpengine.play_media(call, file="/prompts/agent.wav")
await rtpengine.set_play_gain(call, bed, -18) # duck the bed under the prompt
await rtpengine.stop_media(call, play_id=bed) # stop just the bed
Up to four overlays run per direction, each with its own play_id and its own
completion. Starting a fifth is rejected rather than displacing one — a script
that lost a playback it believes is running has no way to notice.
Tones, HTTP sources, overlays, per-play gain and a targeted stop_media are
native siphon-rtp features. On the rtpengine and rtpproxy backends they raise
rather than silently downgrading: an overlay quietly turned into a supersede
would cut the party's live audio.
SDP topology hiding¶
On a B2BUA call siphon rewrites the o= and s= lines of the SDP it relays
between the legs, so neither party sees the other's user name, host or session
name (media.sdp_name). Every other line crosses as the far side wrote it.
Some peers read the session name as a marker of their own. Set
media.sdp_keep_session_name: true to leave s= as it arrived; o= is still
rewritten.
When an attribute carries something the other leg should not see, list it in
media.sdp_strip_attributes:
- Give the bare attribute name (
msid, nota=msidormsid:1), an RFC 8866 §9 token. Anything else fails the config load, and the error names the entry. - Names match case-insensitively. The attribute goes with or without a value
(
a=x-internal-idanda=x-internal-id:42), at session and media level. A different attribute that only shares a prefix (msid-semantic) stays. - It applies in both directions, on every path an offer or an answer crosses:
the INVITE to the callee and its 401/407 and 422 retries, 18x early media, the
2xx, a relayed failure that carries SDP, re-INVITE and UPDATE either way and
their answers, and the re-INVITEs siphon sends with the other party's SDP
(session refresh, transfer re-anchor,
Replacestakeover, controller bridge). - In a
multipart/*body (SIP-I, PIDF-LO) only theapplication/sdppart is touched; the other parts cross byte for byte. - Order with media anchoring. The engine is handed the SDP as the peer sent
it. The strip runs last, on what the engine returns and after the
o=/s=rewrite, so an attribute the engine carries through does not reach the wire either. - Left empty (the default) it strips nothing and the relayed SDP is not inspected, so the relay is byte-identical to one without the setting.
- It covers relayed SDP only. SDP siphon writes itself (a script's
call.answer(body=...), a controller's answer, an engine answer to a one-legged re-offer) goes out as written. For a per-call decision, strip in the script withsdp.parse(),remove_attr()andapply(). - Removing an attribute the far side relies on breaks whatever it was for, so list only what the other leg has no use for.
- A
media:block with nothing butsdp_nameandsdp_strip_attributesasks for no media engine. siphon boots without one, logs that calls are not media-anchored, and applies both settings. Name abackend, give an engine's connection block, or setprofilesorevents, and a missing engine is reported at boot as before.
Managing rtpengine¶
rtpengine is a separate daemon you install and operate on its own (systemd unit,
optional kernel module, rtpengine.conf). SIPhon only needs its NG control
port.
Run it so its NG listener is reachable by SIPhon and its media range is firewallable:
# /etc/rtpengine/rtpengine.conf
[rtpengine]
interface = 10.0.0.10 # media interface (public/relay IP)
listen-ng = 127.0.0.1:22222 # NG control (what SIPhon talks to)
port-min = 30000
port-max = 40000
recording-dir = /var/spool/rtpengine
Point SIPhon at it:
# siphon.yaml
media:
backend: rtpengine # optional; this is the default
rtpengine:
address: "127.0.0.1:22222" # NG control protocol (UDP)
timeout_ms: 1000
sdp_name: "SIPhon" # masks the endpoint identity in o=/s=
health_check_interval_secs: 5
Several engines load-balance with weighted round-robin:
media:
rtpengine:
instances:
- { address: "10.0.0.1:22222", weight: 2 }
- { address: "10.0.0.2:22222", weight: 1 }
Operate it as its own service. Lifecycle (start/stop/upgrade), the kernel
module for the in-kernel fast path, recording-dir and the CDR/PCAP outputs,
and its metrics/exporter are all rtpengine's own — see the upstream
documentation. SIPhon's responsibility ends at the NG control port; it probes
each instance with an NG ping (see Health).
Managing siphon-rtp¶
siphon-rtp is a single static binary with no kernel module. It listens on a JSON-over-TCP control port (what SIPhon drives) and binds media sockets on a relay IP. There is nothing else to install.
Run the daemon¶
siphon-rtp \
--control 0.0.0.0:8080 \ # JSON/TCP control — what SIPhon connects to
--relay-bind-ip 10.0.0.10 \ # bind media to the reachable relay IP (NOT loopback)
--port-min 30000 --port-max 40000 \ # bounded, firewallable media range (needed for HA takeover)
--metrics-addr 127.0.0.1:9091 \ # Prometheus /metrics + /healthz + /readyz
--media-timeout-secs 30 \ # reap a call with no media after N seconds
--shutdown-grace-secs 25 \ # drain live calls on SIGTERM before exiting
--node-id rtp-a # stable id reported to cluster load queries
Key flags (full list: siphon-rtp --help):
| Flag | Purpose |
|---|---|
--control <addr> |
JSON/TCP control listener (default 127.0.0.1:8080) — SIPhon's media.siphon_rtp.address |
--ng <addr> |
also expose an rtpengine NG/bencode UDP listener, so Kamailio/OpenSIPS (or SIPhon's rtpengine backend) can drive the same daemon |
--relay-bind-ip <ip> |
bind media sockets to the reachable IP; the production posture (default loopback is lab-only) |
--port-min / --port-max |
bounded media port range — firewallable, and required for HA takeover (a standby re-binds the same ports) |
--metrics-addr <addr> |
Prometheus /metrics, /healthz (liveness), /readyz (readiness) |
--max-control-rps <n> |
per-connection control-request flood cap (default 200; 0 disables) |
--shutdown-grace-secs <n> |
bounded drain of live calls on SIGTERM/SIGINT |
--config <path> |
rtpengine-style TOML config; CLI flags still override it |
--control and --ng can run at the same time: expose --control for
SIPhon's native backend and --ng for a legacy controller during a migration.
Point SIPhon at it¶
# siphon.yaml — single engine
media:
backend: siphon-rtp
siphon_rtp:
address: "10.0.0.1:8080" # siphon-rtp --control
control_secret: "${SIPHON_RTP_CONTROL_SECRET}" # optional; must match the engine's secret
timeout_ms: 2000
sdp_name: "SIPhon"
health_check_interval_secs: 5
Several engines for HA (weighted round-robin plus per-call-id affinity — every command for one call stays on the same control connection, because siphon-rtp keys call ownership to the connection):
media:
backend: siphon-rtp
siphon_rtp:
control_secret: "${SIPHON_RTP_CONTROL_SECRET}" # shared across all instances
timeout_ms: 2000 # default; per-instance timeout_ms overrides
instances:
- { address: "10.0.0.1:8080", weight: 2 }
- { address: "10.0.0.2:8080", weight: 1, timeout_ms: 3000 }
SIPhon opens one persistent TCP connection per instance, reconnects with backoff
if an engine restarts (it boots fine even when the engine is down — commands
issued during the connect window wait up to their timeout_ms), and runs the
auth handshake on every (re)connect when control_secret is set.
Several nodes on one engine: scope¶
A proxy does not change the Call-ID, so every node that anchors media on one
call path (an edge proxy, a breakout gateway, a media server, a B2BUA
application server) names its engine call by the same id. Without a scope a
second node using a Call-ID the engine already holds gets unknown call, so
each such node needs an engine of its own. From siphon-rtp 0.14.0 the engine
keys a call by (scope, Call-ID), and a node that sets a scope keeps its calls
apart from every other node's on a shared engine:
media:
backend: siphon-rtp
siphon_rtp:
address: "192.0.2.10:8080"
scope: "edge" # 1 to 64 of A-Z a-z 0-9 - _ ; unset by default
| Key | Type | Default | Meaning |
|---|---|---|---|
media.siphon_rtp.scope |
string | unset | This node's scope. Every command that names a call (offer, reoffer, answer, answer_local, delete, list, play_media, stop_media, set_play_gain, play_dtmf, silence, echo, block, recording, subscriptions, WebSocket tee and bridge, X3) carries it as a top-level scope key, and the node skips any event that names another scope. ping, authenticate, node_info and the conference_* verbs carry none (the engine refuses a scope on a room verb). |
- Validation. 1 to 64 bytes of ASCII letters, digits,
-and_, the set the engine accepts. Anything else, the empty string included (an unset${VAR}), stops SIPhon at start with an error namingmedia.siphon_rtp.scope. - Unset. No frame carries a scope; every frame is byte for byte what
earlier releases sent, and no
node_infois asked for. - Engine support. With a scope set, SIPhon asks each engine for
node_infoon every (re)connect, before serving a call. An engine that does not listscopein itsfeatures(older than 0.14.0) would read every scoped frame as unscoped, silently, so SIPhon refuses that connection, logs an error naming the key and the engine version, and keeps retrying with backoff. Commands meanwhile fail on theirtimeout_ms; no call is anchored unscoped. - Several instances. The scope is the node's: with
instances:every connection carries the same one. - One scope per function, not per instance. Give both instances of a redundant pair the same scope, so either can answer or release a call the other offered, and each other function its own.
- Conference rooms. A room is named by its
conference_id, not by aCall-ID, and its verbs carry no scope, so rooms on a shared engine are not separated by scope: give each node's rooms ids no other node uses (put the function in the id). A room's events, unscoped, still reach the node that seated it. - Reap.
reap_orphans_at_startuplists and deletes only the calls of this node's scope. - Scripts. Nothing changes: the scope is node configuration, scripts still
name calls by their
Call-ID, and the events they receive are this node's. - Per-call scope. A script can name a scope for one leg instead
(
rtpengine.offer(request, scope="orig"), see Two legs of one call on one node); that frame carries the script's scope in place of the node's.
Two legs of one call on one node¶
A proxy that anchors both the originating and the terminating leg of one call
(two of its own users calling each other) sees both legs under one Call-ID,
since a proxy never changes it (RFC 3261 §16.6). The node scope cannot help: it
is one per node. Name a scope per leg in the script instead:
def leg(request):
return "term" if request.ruri.is_local else "orig"
@proxy.on_request("INVITE")
async def invite(request):
await rtpengine.offer(request, profile="access", scope=leg(request))
request.relay()
@proxy.on_reply("INVITE")
async def reply(request, reply):
if reply.status_code == 200:
await rtpengine.answer(reply, scope=leg(request))
reply.relay()
@proxy.on_request("BYE")
async def bye(request):
await rtpengine.delete(request) # no scope: every leg of the Call-ID goes
request.relay()
- What the engine sees. siphon-rtp: the frame's top-level
scopeis the script's, thecall_idthe SIP Call-ID, so each leg is its own engine call keyed(scope, call_id). rtpengine and rtpproxy have no scope, so the leg's engine call-id is<call-id>;<scope>; no SIP Call-ID holds a;(RFC 3261 §25.1), so it never names another call. Each leg is a whole engine call, with its own profile, address family and ports. - Which verbs. Every media verb that addresses a call takes
scope=:offer,answer,answer_local,delete,delete_call, and the prompts, DTMF, gating, echo, recording, digit reads, subscriptions, tees and bridges. Anofferon a leg already anchored under that scope goes out as its re-offer. The leg's session (profile, tags, WebSocket URI) is kept per scope, so ananswerreuses its own leg's offer profile. - A verb without
scope=. The node's call, as before, except when the call has no session of its own and exactly one leg was offered under a scope: then that leg, the only one the verb can mean. With two scoped legs it still goes to the node's call, which is neither leg, so the engine refuses it: name the leg.
await rtpengine.play_media(request, file="/prompts/hold.wav", scope="term")
digits, reason = await rtpengine.read_digits(request, scope="orig")
- Delete.
delete(request)anddelete_call(call_id)with no scope delete the call's own session and every leg any scope anchored under that Call-ID, each under its own scope;delete(request, scope="orig")anddelete_call(call_id, scope="orig")only that leg. - Default. Without
scope=, every frame is byte for byte what it was. A per-call scope equal to the node's configured scope is the node's call. - Validation. The node scope's rules (1 to 64 of
A-Z a-z 0-9 - _); anything else raisesValueErrorbefore the engine is asked. A B2BUACall(or a B2BUA reply) is refused too: its legs have Call-IDs of their own. - Engine support. On an unscoped siphon-rtp node, the first per-call scope
on a connection asks the engine for
node_info; an engine without thescopefeature fails the verb rather than anchor the legs together. - Events. A scoped leg's media events reach the script the same way on
every backend: under the SIP Call-ID, with the scope as a
scope=keyword for a handler that takes one. A handler without the keyword is called as before, and an event of the node's own call passes no scope.
@rtpengine.on_dtmf
def digit(call_id, from_tag, digit, duration_ms, volume, scope=None):
if scope == "term":
...
On the wire rtpengine and rtpproxy report the leg's engine call-id
(<call-id>;<scope>) and siphon-rtp the frame's scope; siphon splits both
back. A read_digits on a leg takes only that leg's digits.
- Lawful intercept. An intercepted call's X3 content is attached on every
leg siphon anchored for it, each scoped leg included, so no leg's content
goes undelivered. Each leg's delivery carries the session's one Correlation
ID, and siphon records which leg it came from, so a loss or end report names
the leg and one leg ending does not end the other's delivery. The mediation
function receives one delivery per tapped leg.
- Bounded. A scope names a leg's role. siphon refuses a 257th distinct scope
name, so a script deriving one per call fails loudly instead of growing
siphon's bookkeeping with every call.
A scope separates nodes that cooperate on one engine. It is a name, not a credential: any client that may reach the control port can name any scope, so keep nodes that must not reach each other's calls on separate engines. See the cookbook: sharing one media engine between several nodes.
Security¶
The control channel is a management plane. Either bind --control to a trusted
network and firewall it, or set a control_secret on both sides (the engine
and media.siphon_rtp.control_secret) so SIPhon must authenticate before issuing
any command. Bind media with --relay-bind-ip to the intended relay IP and open
only --port-min…--port-max at the firewall.
Health and observability¶
On the SIPhon side, both backends are probed on
media.health_check_interval_secs and export the same gauges (the
rtpengine name is historical — it covers whichever engine is configured):
siphon_rtpengine_instances_total— configured instancessiphon_rtpengine_instances_up— how many answered the last probesiphon_rtpengine_instance_up{address}— 0/1 per instance
rtpengine is probed with an NG ping; siphon-rtp with a native ping command.
On the engine side, siphon-rtp additionally serves its own metrics when you
pass --metrics-addr: GET /metrics (OpenMetrics), GET /healthz (liveness),
GET /readyz (readiness) — wire these into your load balancer and Prometheus.
rtpengine exposes its own exporter separately.
Switching backends¶
Because the scripting API is identical, moving a deployment from rtpengine to siphon-rtp (or back) is a config-only change — the script does not change:
- Run the target engine (sections above).
- Flip
media.backendand fill in the matchingrtpengine:/siphon_rtp:block. - Restart SIPhon. The same media recipe runs unchanged.
The example scripts are backend-agnostic and work either way — see
examples/proxy_rtpengine.py
and examples/b2bua_rtpengine.py;
only the media: block in siphon.yaml differs.
See also¶
- Media & RTP profiles — the offer/answer/delete recipe and the profile catalogue (both backends).
- The siphon-rtp engine's own documentation for engine internals, the datapath, TURN, and recording.