Skip to content

Media & RTP profiles

SIPhon anchors and transforms media through RTPEngine using its NG control protocol. A profile is a named bundle of RTPEngine flags — SRTP↔RTP interworking, WebRTC, ICE handling, transcoding direction — that you select per call with one argument.

Config

# siphon.yaml
media:
  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    # exported as siphon_rtpengine_instances_up

Multiple 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 }

The offer / answer / delete lifecycle

Anchor the offer when the INVITE arrives, the answer when the 2xx comes back, and release on teardown. RTPEngine rewrites the SDP so media flows through it.

On a proxy:

from siphon import proxy, registrar, rtpengine

@proxy.on_request
async def route(request):
    if request.in_dialog:
        if request.method == "BYE":
            await rtpengine.delete(request)
        elif request.method == "INVITE" and request.body:
            await rtpengine.offer(request, profile="srtp_to_rtp")  # re-INVITE
        request.loose_route() and request.relay()
        return

    contacts = registrar.lookup(request.ruri)
    if request.method == "INVITE" and request.body:
        await rtpengine.offer(request, profile="srtp_to_rtp")
    request.record_route()
    request.fork([c.uri for c in contacts])

@proxy.on_reply
async def reply_route(request, reply):
    if 200 <= reply.status_code < 300 and reply.has_body("application/sdp"):
        await rtpengine.answer(reply, profile="srtp_to_rtp")
    reply.relay()

@proxy.on_cancel
async def cancel_route(request):
    await rtpengine.delete(request)   # release media for an abandoned call

On a B2BUA it's the same three calls in @b2bua.on_invite / on_answer / on_bye (+ on_failure / on_cancel); pass call= to answer() so it reuses the A-leg Call-ID that matched the offer (see the SBC recipe).

Always release

offer without a matching delete leaks an RTPEngine session until its inactivity timeout. Handle every teardown path — on_bye, on_failure, on_cancel (proxy: @proxy.on_cancel) — or media lingers.

Built-in profiles

Profile Interworking
rtp_passthrough Plain RTP both sides — anchoring only (the default)
srtp_to_rtp SRTP UE ↔ RTP core (VoLTE/secure access ↔ trunk)
ws_to_rtp WebSocket UE (RTP/AVPF + ICE) ↔ RTP core
wss_to_rtp Secure WebSocket (DTLS-SRTP/AVPF + ICE) ↔ RTP core

ws_to_rtp / wss_to_rtp are what make a WebRTC gateway work — terminate the browser's DTLS-SRTP + ICE on one side, plain RTP toward your core on the other.

Custom profiles

Define your own under media.profiles — any RTPEngine flag, per direction:

media:
  profiles:
    srtp_to_srtp:
      offer:
        transport_protocol: "RTP/SAVP"
        ice: "remove"
        replace: ["origin"]
        direction: ["external", "internal"]
      answer:
        transport_protocol: "RTP/SAVP"
        ice: "remove"
        replace: ["origin"]
        direction: ["internal", "external"]
await rtpengine.offer(request, profile="srtp_to_srtp")

Shape the SDP yourself

For codec filtering, hold, or attribute tweaks without RTPEngine, use the sdp namespace:

from siphon import sdp

s = sdp.parse(request)
for m in s.media:
    if m.media_type == "audio":
        s.filter_codecs(["PCMU", "PCMA"])   # keep only G.711
        # m.port = 0                          # ... or put audio on hold
s.apply(request)

More media control

The rtpengine namespace also drives announcements and tones (play_media, play_dtmf), gating (silence_media / block_media), DTMF events (@rtpengine.on_dtmf), and conference/MPTY subscriptions — useful for IVR, MMTel announcements, and recording.

See also