Skip to content

Media

The rtpengine namespace controls media anchoring and injection (announcements, DTMF, gating, subscriptions) via the RTPEngine / siphon-rtp NG control protocol. The qos namespace turns an SDP offer/answer pair into the media_components structure that diameter.rx_aar and sbi.create_session consume.

from siphon import rtpengine

@b2bua.on_invite
async def anchor(call):
    await rtpengine.play_media(call, file="/prompts/welcome.wav")

rtpengine namespace

Mock RTPEngine namespace — records media operations for assertions.

Example::

from siphon import rtpengine
# After running handler:
assert rtpengine.operations == [("offer", "srtp_to_rtp")]

Media-injection operations (play_media, stop_media, play_dtmf, silence_media, unsilence_media, block_media, unblock_media, echo) are also captured in operations as (name, detail) tuples so downstream apps can unit-test MMTEL announcement flows without a live rtpengine. Full parameter dicts are available on media_calls.

Every media verb's target accepts three forms (like the runtime's resolve_call_from_tag): a SIP object (Request/Reply/Call), a (call_id, from_tag) pair, or a bare call_id string — so an @rtpengine.on_dtmf handler can drive media from the call_id / from_tag it was handed. The resolved call_id / from_tag are recorded on each media_calls entry.

Valid profiles: "srtp_to_rtp", "ws_to_rtp", "wss_to_rtp", "rtp_passthrough".

operations instance-attribute

operations: list[tuple[str, Optional[str]]] = []

List of (operation, profile_or_detail) tuples recorded.

media_calls instance-attribute

media_calls: list[dict[str, Any]] = []

Full parameter dicts for each media-injection call.

ws_uris instance-attribute

ws_uris: list[tuple[str, Optional[str]]] = []

(operation, resolved_ws_uri) per offer/answer/answer_local.

The URI is post-templating, so a test asserts the value the media engine would actually be handed. None means no WebSocket bridge was requested for that call.

conferences instance-attribute

conferences: dict[str, dict[str, dict[str, Any]]] = {}

Live rooms: {conference_id: {from_tag: seat}}.

Each seat is {"call_id", "role", "target", "whisper_target", "profile"}. A room appears on its first :meth:conference_join and goes with its last :meth:conference_leave, as on the engine.

conference_bridges instance-attribute

conference_bridges: list[tuple[str, str, str]] = []

(conference_a, conference_b, direction) per live bridge.

conference_plays instance-attribute

conference_plays: dict[int, dict[str, Any]] = {}

Running room playbacks by play_id.

conference_offers instance-attribute

conference_offers: dict[tuple[str, str], dict[str, Any]] = (
    {}
)

Seats offered by :meth:conference_offer and not yet answered, by (conference_id, seat_tag).

media_scopes instance-attribute

media_scopes: list[tuple[str, Optional[str]]] = []

(operation, scope) per media verb (offer, answer, delete, delete_call, and every verb that addresses a call's media): the per-call scope the verb addressed, None for the node's own. For a verb that names no scope it is the scope the runtime resolves (the call's one scoped leg).

media_overrides instance-attribute

media_overrides: list[tuple[str, dict[str, Any]]] = []

(operation, overrides) per offer/answer/answer_local.

The per-call media knobs (beep_detection, ws_sample_rate, ws_vad_engine, ...) a script passed for that call, already validated. None values mean "leave the profile's value alone".

active_sessions property

active_sessions: int

Number of active media sessions (mock: count of offer - delete).

instance_count property

instance_count: int

Number of configured RTPEngine instances (mock: always 1).

offer async

offer(
    request: Any,
    profile: Optional[str] = None,
    ws_uri: Optional[str] = None,
    beep_detection: Optional[bool] = None,
    beep_cadence_guard_ms: Optional[int] = None,
    ws_sample_rate: Optional[int] = None,
    ws_tee_sample_rate: Optional[int] = None,
    ws_vad_engine: Optional[str] = None,
    ws_vad_min_speech_ms: Optional[int] = None,
    sdp: Union[str, bytes, None] = None,
    scope: Optional[str] = None,
) -> Union[bool, str]

Send offer command to RTPEngine.

Two ways to hand it the offer:

  • A SIP message (the usual case): the SDP comes off the body of request, the body is replaced with the rewritten SDP, and the call resolves to True.
  • Raw SDP with sdp=: for an offering party's SDP that reached the script some other way, such as one party's new SDP on an access transfer, which arrives in a dialog of its own. request then only names the offerer. No message body is read or written; the call resolves to the rewritten SDP as str for the script to send itself.

The profile's two halves describe the two parties of the call, not the two commands: the offer half is what the callee is sent and the answer half what the caller is sent. On the call's first INVITE the rewritten offer goes to the callee, so it is shaped by the offer half, and received_from (the request's source address) is applied when that half asks for it, since the SDP is the caller's.

On a call already anchored the command is a re-offer (a re-INVITE or an UPDATE) and follows whoever sent it. From the caller it is as above. From the callee the rewritten offer goes to the caller, so it is shaped by the answer half, and the callee is pinned to the request's source when the answer half asks for it: the half the callee was set up under. Pass the same profile= as on the first offer and each party keeps the transport, direction and received_from policy it started with, whoever re-offers. The mock records the profile name and rewrites nothing.

A raw re-offer names its party by tag: the tag of the party that made the first offer, or the tag its answer recorded for the other party. It is shaped by the same rule and goes to the engine as reoffer on the call-id the engine knows the call by. The other party's answer can be handed back raw too, with answer((call_id, offerer_tag), sdp=..., to_tag=answerer_tag). On a call siphon has not anchored, a raw offer is the call's first offer and records the session.

Parameters:

Name Type Description Default
request Any

Request or Call object with SDP body. With sdp= it only names the offerer: a Call, Request or Reply, or a (call_id, from_tag) tuple. A bare call_id string names no party and raises TypeError.

required
profile Optional[str]

RTP profile name. Defaults to "rtp_passthrough".

None
ws_uri Optional[str]

Bridge this leg's audio to an external WebSocket media server (siphon-rtp backend only), overriding the profile's own ws_uri for this call. Supports {call_id}, {from_tag}, {from_user} and {to_user} placeholders. The resolved URI is recorded on the media session, so a later answer reuses it automatically.

None
sdp Union[str, bytes, None]

The offering party's SDP (str or bytes). Switches to raw mode. Blank raises ValueError. No source address is carried, so a profile's received_from does not gate the party's media.

None
scope Optional[str]

Anchor this leg as an engine call of its own, for a proxy that anchors both legs of one call under one Call-ID (the originating and the terminating leg of a call between two of its users), e.g. "orig" and "term". 1-64 ASCII letters, digits, - and _. Pass the same scope to that leg's answer; a delete without one ends every leg. Omitted: the node's own scope, exactly as before. ValueError on a B2BUA Call.

None

Returns:

Type Description
Union[bool, str]

True, or the rewritten SDP as str when sdp= was passed.

Union[bool, str]

The mock rewrites nothing and returns sdp as given.

Example::

# A proxy anchoring both legs of a call between two of its users.
@proxy.on_request("INVITE")
async def invite(request):
    leg = "term" if request.ruri.is_local else "orig"
    await rtpengine.offer(request, profile="access", scope=leg)
    request.relay()

@b2bua.on_invite
async def on_invite(call):
    sdp = await rtpengine.answer_local(
        call,
        profile="voice_ai",
        ws_uri=f"wss://ai.example.com/stream/{call.call_id}",
    )

# Re-point the far party of an anchored call at an SDP that
# arrived in another dialog, then hand its answer back raw.
sdp = await rtpengine.offer((call_id, far_tag), sdp=new_sdp)

answer async

answer(
    reply: Any,
    profile: Optional[str] = None,
    call: Any = None,
    ws_uri: Optional[str] = None,
    beep_detection: Optional[bool] = None,
    beep_cadence_guard_ms: Optional[int] = None,
    ws_sample_rate: Optional[int] = None,
    ws_tee_sample_rate: Optional[int] = None,
    ws_vad_engine: Optional[str] = None,
    ws_vad_min_speech_ms: Optional[int] = None,
    sdp: Union[str, bytes, None] = None,
    to_tag: Optional[str] = None,
    scope: Optional[str] = None,
) -> Union[bool, str]

Send answer command to RTPEngine.

Two ways to hand it the answer:

  • A SIP reply (the usual proxy / B2BUA case): the SDP and To-tag come off reply, its body is replaced with the rewritten SDP, and the call resolves to True.
  • Raw SDP with sdp=: for a far side that is not a SIP agent and hands over its answer some other way (a media server behind its own API). No message body is read or written; the call resolves to the rewritten SDP as str for the script to send itself.

Profile precedence (matches the real implementation):

  1. Explicit profile= argument (script override).
  2. Profile recorded by the matching offer (looked up by A-leg Call-ID). Lets @b2bua.on_answer / @b2bua.on_early_media call rtpengine.answer(reply) with no profile= and still get the directional flags from the offer-side profile.
  3. "rtp_passthrough" when no offer was ever recorded.

Delayed offer (RFC 3264 §4): when the INVITE carried no SDP, the reply's SDP is the offer, not an answer. It goes to the engine as an offer from the replying party's side, and siphon completes it with the caller's answer from the ACK itself, so a script calls answer the same way for both. A raw sdp= is always an answer.

Whose SDP, and for whom. The SDP in a reply is the replying party's, and the rewritten SDP goes to the other party:

  • The callee replies (the 2xx or an 18x to the INVITE, or a delayed offer). The result goes to the caller, shaped by the profile's answer half.
  • The caller replies to a re-INVITE or an UPDATE from the callee. The result goes to the callee, shaped by the offer half, which is what the callee has been sent since the call was set up. The tags recorded for the two parties are not changed by it.

received_from is stamped with the address the reply arrived from, never the address of the call= object, which is the other party's. Whether it is stamped is the replying party's own policy: the answer half's for the callee, the offer half's for the caller.

Either command is addressed by the call-id the engine knows the call by, which differs from the SIP Call-ID after a siphon-terminated transfer re-anchored the call.

Parameters:

Name Type Description Default
reply Any

Reply or Call object with SDP body. With sdp= it only names the offer being answered: a Call, Request or Reply, or a (call_id, from_tag) tuple. A bare call_id string names no from-tag and raises TypeError.

required
profile Optional[str]

Optional explicit RTP profile name. When omitted, the profile recorded by the matching offer is used.

None
call Any

Optional Call object — when provided, Call-ID and From-tag are taken from this object (matching the earlier offer), while To-tag and SDP body still come from reply.

None
ws_uri Optional[str]

WebSocket bridge URI override for this call (siphon-rtp backend only). When omitted, the URI recorded by the matching offer is reused — the same precedence as profile.

None
sdp Union[str, bytes, None]

The far side's answer SDP (str or bytes). Switches to raw mode. Blank raises ValueError. No source address is carried for the far side: siphon never heard from it, so a profile's received_from does not gate its media.

None
to_tag Optional[str]

Tag naming the answering party to the engine. Raw mode only (ValueError without sdp=). When omitted: the To-tag on reply if it carries one, else the tag an earlier answer on this call recorded (so a re-answer reaches the same party), else a new one. The mock records what was passed, None when omitted.

None
scope Optional[str]

The per-call scope the leg's offer named; its recorded profile and tags are that leg's own. Omitted: the node's own scope. ValueError on a B2BUA reply or Call.

None

Returns:

Type Description
Union[bool, str]

True, or the rewritten SDP as str when sdp= was passed.

Union[bool, str]

The mock rewrites nothing and returns sdp as given.

Example::

@b2bua.on_invite
async def on_invite(call):
    await rtpengine.offer(call, profile="rtp_passthrough")
    far_sdp = await my_media_server.connect(call.body)  # not SIP
    sdp = await rtpengine.answer(call, sdp=far_sdp)
    call.answer(200, "OK", body=sdp, content_type="application/sdp")

answer_local async

answer_local(
    call: Any,
    profile: Optional[str] = None,
    auto_reject: bool = True,
    ws_uri: Optional[str] = None,
    beep_detection: Optional[bool] = None,
    beep_cadence_guard_ms: Optional[int] = None,
    ws_sample_rate: Optional[int] = None,
    ws_tee_sample_rate: Optional[int] = None,
    ws_vad_engine: Optional[str] = None,
    ws_vad_min_speech_ms: Optional[int] = None,
    scope: Optional[str] = None,
) -> Optional[str]

Single-leg UAS answer — synthesise an RFC 3264 answer for the caller's own offer, with the media engine as the far side (IVR / echo / announcement server).

Unlike :meth:answer, this takes the offer (INVITE), not a peer's reply: there is no far leg, so the engine picks one encodable codec from the offer and returns the answer SDP for the script to put in its own 2xx.

Profile precedence matches :meth:answer (explicit profile= → profile recorded by a matching offer → "rtp_passthrough").

When the offer has no encodable codec (primed in tests via :meth:set_answer_local_no_codec), the engine cannot answer:

  • with auto_reject=True (default) and a Call target, a deferred 488 Not Acceptable Here is recorded on the call (call.reject(488, "Not Acceptable Here")) and the coroutine resolves to None;
  • with auto_reject=False (or a non-Call target) it raises ValueError instead.

Native siphon-rtp backend only.

Parameters:

Name Type Description Default
call Any

A Call (B2BUA) — or Request — carrying the INVITE offer whose Call-ID / From-tag drive the single-leg answer.

required
profile Optional[str]

Optional explicit RTP profile name. When omitted, the profile recorded by a matching offer is used.

None
auto_reject bool

When True (default) and call is a Call, a no-encodable-codec result records a deferred 488 and returns None. When False it raises ValueError.

True
ws_uri Optional[str]

Bridge this leg's audio to an external WebSocket media server instead of a far SIP leg — the shape a voice-AI answer takes, since the WS server is the far side. Overrides the profile's own ws_uri; supports the same placeholders as :meth:offer.

None
scope Optional[str]

Answer this leg as an engine call of its own, as :meth:offer anchors one; pass it to the leg's later verbs. ValueError on a B2BUA Call.

None

Returns:

Type Description
Optional[str]

The answer SDP as str on success, or None when the offer had

Optional[str]

no encodable codec and it was auto-rejected with a 488.

Example::

@b2bua.on_invite
async def on_invite(call):
    sdp = await rtpengine.answer_local(call, profile="ivr")
    if sdp is not None:
        call.answer(200, "OK", body=sdp, content_type="application/sdp")
        await rtpengine.play_media(call, file="/prompts/welcome.wav")

delete async

delete(request: Any, scope: Optional[str] = None) -> bool

Send delete command to tear down media session.

On a call seated in a conference room (:meth:conference_join) this leaves the room. Without scope it also ends every leg an offer(..., scope=...) anchored under the Call-ID, so one BYE ends both legs a proxy anchored; with scope only that leg goes.

Parameters:

Name Type Description Default
request Any

Request or Call object (uses Call-ID + From-tag).

required
scope Optional[str]

Delete only the leg anchored under this per-call scope. ValueError on a B2BUA Call.

None

Returns:

Type Description
bool

True on success.

Example::

@proxy.on_request("BYE")
async def bye(request):
    await rtpengine.delete(request)  # both scoped legs go
    request.relay()

delete_call async

delete_call(
    call_id: str,
    from_tag: Optional[str] = None,
    scope: Optional[str] = None,
) -> bool

Tear down a call's media by Call-ID, without a SIP message in hand.

For a call that ends outside any SIP transaction: an Rx Abort-Session (@diameter.on_request), an N5 termination (@sbi.on_terminate), a periodic sweep. :meth:delete needs a request, reply or call to read the tags from; this needs only the Call-ID::

@sbi.on_terminate
async def bearer_gone(termination):
    call_id = await cache.fetch("n5", termination["resUri"])
    if call_id:
        await rtpengine.delete_call(call_id)

A call this process anchored is deleted with its own engine call-id and From-tag. from_tag overrides the tracked tag. With neither, the whole call is deleted by id (rtpengine; siphon-rtp limits it to calls this node created). rtpproxy cannot address a call without a tag, so there an untracked call needs from_tag.

Parameters:

Name Type Description Default
call_id str

The SIP Call-ID the media was anchored under.

required
from_tag Optional[str]

The From-tag to delete with, if not the tracked one.

None
scope Optional[str]

Release only the leg an offer(..., scope=...) anchored under this scope. Omitted: the call's own media and every leg anchored under the Call-ID, each under its own scope.

None

Returns:

Type Description
bool

True when the engine released the call, False when it no

bool

longer held it (already timed out, or never anchored). The mock

bool

answers False for a Call-ID it already released.

Raises:

Type Description
ValueError

call_id is empty.

RuntimeError

the engine could not be reached or refused the delete.

ping async

ping() -> bool

Health check: ping RTPEngine instance(s).

Returns:

Type Description
bool

True if healthy.

play_media async

play_media(
    target: Any,
    file: Optional[str] = None,
    blob: Optional[bytes] = None,
    db_id: Optional[int] = None,
    tone: Optional[str] = None,
    url: Optional[str] = None,
    repeat: Union[int, str, None] = None,
    start_ms: Optional[int] = None,
    duration_ms: Optional[int] = None,
    gain_decibels: Optional[int] = None,
    to_tag: Optional[str] = None,
    wait: bool = True,
    scope: Optional[str] = None,
) -> Optional[int]

Inject an audio prompt toward a party, replacing what it receives.

Exactly one of file/blob/db_id/tone/url must be supplied. The party whose tag target gives hears the prompt: the engine plays toward that leg (siphon-rtp play_media, "Play audio toward a leg"), and the other party keeps hearing it live. A SIP object gives the From-tag, so play_media(call) plays to the caller; (call_id, callee_tag) plays to the callee. Pass to_tag to scope to a specific peer in MPTY scenarios.

Requires rtpengine built with --with-transcoding and launched with --audio-player=on-demand. AMR-NB/WB prompts need licensed codec plugins; G.711 and Opus prompts work without them.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object.

required
file Optional[str]

Absolute path to an audio file on the rtpengine host.

None
blob Optional[bytes]

Raw audio bytes to play (e.g. TTS output).

None
db_id Optional[int]

Reference to a prompt stored in rtpengine's prompt DB.

None
tone Optional[str]

A synthesised call-progress tone, with no audio file to provision. Either a preset name ("ringback_eu", "busy_na", "dial_uk", ...) or an explicit cadence spec in the engine's tone grammar ("425/1000,0/4000*inf" is 425 Hz one second on, four seconds off, forever). The two are told apart by the /. Rendered at the leg's codec rate, so never resampled. Native siphon-rtp backend only.

None
url Optional[str]

An http:// / https:// WAV the engine fetches from its own network position. The fetch is bounded engine-side (connect, first-byte, deadline, size cap, redirect cap) and runs off the media path, so a URL that never answers ends the playback, never the leg. The accept carries no duration, since the length is unknown until the body arrives. Native siphon-rtp backend only.

None
repeat Union[int, str, None]

Total number of times to play the prompt (default once), or "inf" to play until stopped -- music on hold. "inf" needs wait=False (an endless play never finishes) and the native siphon-rtp backend. Anything else raises ValueError.

None
start_ms Optional[int]

Offset into the file at which to start (ms).

None
duration_ms Optional[int]

Cap on playback length (ms).

None
gain_decibels Optional[int]

Playout gain in whole decibels relative to the source's own level, clamped engine-side to -60..=+12. Native siphon-rtp backend only.

None
to_tag Optional[str]

Optional peer tag for MPTY scoping.

None
wait bool

When True (default, native siphon-rtp backend), the real runtime blocks until the prompt finishes playing so a script can sequence a following action (e.g. echo()) after it. The coroutine parks while it waits. wait=False returns as soon as the engine accepts the prompt (fire-and-forget). In this mock the call always returns immediately (the completion event is a runtime behavior); wait is recorded for assertions.

True
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Type Description
Optional[int]

Prompt duration in ms if rtpengine reports one (mock returns

Optional[int]

the value set via :meth:set_play_media_duration, else None).

Example::

@b2bua.on_invite
async def on_invite(call):
    await rtpengine.offer(call, profile="ivr")
    call.answer(200, "OK", body=call.body, content_type="application/sdp")
    await rtpengine.play_media(call, file="/prompts/welcome.wav")  # wait=True
    await rtpengine.echo(call)                                     # after prompt

play_overlay async

play_overlay(
    target: Any,
    file: Optional[str] = None,
    blob: Optional[bytes] = None,
    db_id: Optional[int] = None,
    tone: Optional[str] = None,
    url: Optional[str] = None,
    repeat: Union[int, str, None] = None,
    start_ms: Optional[int] = None,
    duration_ms: Optional[int] = None,
    gain_decibels: Optional[int] = None,
    to_tag: Optional[str] = None,
    scope: Optional[str] = None,
) -> Optional[int]

Start an overlay playback and return its play_id handle.

The additive twin of :meth:play_media: audio is mixed under the party's live egress instead of replacing it. Where play_media answers "how long did it play", this answers "which playback is it", because that is what an overlay is for -- a music bed you will duck with :meth:set_play_gain and stop individually with stop_media(target, play_id=...).

Up to four overlays run concurrently per direction, each with its own play_id and its own completion. Starting a fifth is rejected rather than displacing one, since a script that lost a playback it believes is running has no way to notice. An overlay never supersedes anything, including another overlay.

Returns on the engine's accept: an overlay is background audio, so there is no wait. Native siphon-rtp backend only.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object.

required
file Optional[str]

Absolute path to an audio file on the engine host.

None
blob Optional[bytes]

Raw audio bytes to play.

None
db_id Optional[int]

Reference to a prompt in the engine's prompt DB.

None
tone Optional[str]

A preset name or cadence spec, as for :meth:play_media.

None
url Optional[str]

An http:// / https:// WAV the engine fetches.

None
repeat Union[int, str, None]

Total number of times to play, or "inf" to play until stopped. Anything else raises ValueError.

None
start_ms Optional[int]

Offset into the source at which to start (ms).

None
duration_ms Optional[int]

Hard playout cap -- the only bound, short of a stop, on an endless (*inf) tone.

None
gain_decibels Optional[int]

Playout gain relative to the source's own level.

None
to_tag Optional[str]

Optional peer tag for MPTY scoping.

None
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Name Type Description
Optional[int]

The play_id of the started overlay (mock returns the value set

via Optional[int]

meth:set_play_overlay_id, default 1).

Example::

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)
await rtpengine.stop_media(call, play_id=bed)

start_recording async

start_recording(
    target: Any,
    path: Optional[str] = None,
    direction: str = "ingress",
    channels: str = "mono",
    max_duration_ms: Optional[int] = None,
    silence_ms: Optional[int] = None,
    scope: Optional[str] = None,
    format: str = "wav",
    bitrate_bps: Optional[int] = None,
) -> str

Start recording the call's decoded audio to a file.

Returns on the engine's accept with the recording_id; the recording runs until :meth:stop_recording, max_duration_ms, silence_ms or the end of the call. format is "wav" (16-bit PCM, the default) or "ogg_opus" (Opus in Ogg, about a seventh of the size); bitrate_bps sets the Opus target, 6000 to 510000, and is refused with "wav". Works on a call the engine answered itself (answer_local), which is what a voicemail box is, and on a relayed call, where channels="stereo" puts each party on its own channel. direction is "ingress" (default), "egress" or "both"; channels "mono" or "stereo". Native siphon-rtp backend only, and ogg_opus needs siphon-rtp 0.15.0 or later.

In the mock the id is "rec-<n>" and the recording is pending until the test calls :meth:finish_recording.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

finish_recording

finish_recording(
    recording_id: str,
    *,
    path: Optional[str] = None,
    duration_ms: int = 0,
    reason: str = "stopped",
    scope: Optional[str] = None
) -> int

Test helper: the engine reports recording_id ended and closed.

Resolves a :meth:wait_recording on it and fires the matching :meth:on_recording_finished handlers with the call and tag the recording was started on. Returns how many handlers fired.

wait_recording async

wait_recording(
    target: Any,
    recording_id: str,
    timeout_ms: int = 3600000,
    scope: Optional[str] = None,
) -> Optional[dict]

Wait until a recording ended and its file is closed.

Returns {"recording_id", "path", "duration_ms", "reason"} (reason "stopped", "max_duration", "silence", "call_ended" or "error"), or None when no end was reported in timeout_ms. In the mock: what :meth:finish_recording set, else None.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

record async

record(
    target: Any,
    path: Optional[str] = None,
    direction: str = "ingress",
    channels: str = "mono",
    max_duration_ms: Optional[int] = None,
    silence_ms: Optional[int] = None,
    timeout_ms: Optional[int] = None,
    scope: Optional[str] = None,
    format: str = "wav",
    bitrate_bps: Optional[int] = None,
) -> Optional[dict]

Record and wait for the closed file: :meth:start_recording then :meth:wait_recording.

Example::

message = await rtpengine.record(call, path="/spool/m1.wav",
                                 max_duration_ms=120000,
                                 silence_ms=5000)

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

stop_recording async

stop_recording(
    target: Any,
    recording_id: Optional[str] = None,
    scope: Optional[str] = None,
) -> bool

Stop one recording, or every recording on the call.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

key_digits

key_digits(call_id: str, digits: str) -> None

Test helper: queue digits the caller keys on call_id, read by the next :meth:read_digits calls.

read_digits async

read_digits(
    target: Any,
    terminators: str = "#",
    max_digits: Optional[int] = None,
    timeout_ms: int = 5000,
    inter_digit_timeout_ms: int = 3000,
    scope: Optional[str] = None,
) -> tuple

Collect the digits the caller keys (RFC 4733) until a terminator (not included), max_digits, timeout_ms with nothing keyed or inter_digit_timeout_ms after the last digit.

Returns (digits, reason), reason "terminator", "max_digits", "timeout", "inter_digit_timeout" or "interrupted". Digits a read takes are not also @rtpengine.on_dtmf events. In the mock the digits come from :meth:key_digits.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

stop_media async

stop_media(
    target: Any,
    play_id: Optional[int] = None,
    scope: Optional[str] = None,
) -> bool

Stop prompt playback on the selected monologue.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object.

required
play_id Optional[int]

Stop one specific playback (an individual overlay slot, from :meth:play_overlay). Omitting it stops everything playing on the leg. Native siphon-rtp backend only: the other backends have no handle on an individual playback and raise rather than widening this into "stop everything", which would kill playbacks the script meant to keep running.

None
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Type Description
bool

True on success.

set_play_gain async

set_play_gain(
    target: Any,
    play_id: int,
    gain_decibels: int,
    to_tag: Optional[str] = None,
    scope: Optional[str] = None,
) -> bool

Retune the playout gain of a playback that is already running.

How a script ducks a music bed under a prompt and lifts it again. A separate verb rather than a field on :meth:play_media because play_media is a start: reusing it would mean "start another playback", not "change this one". play_id is already the contract's handle on a running playback, so gain is addressed the same way.

The engine answers an error when no playback on the call holds that play_id, so a stale handle raises rather than silently doing nothing. Native siphon-rtp backend only.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object.

required
play_id int

The running playback to retune, from :meth:play_overlay.

required
gain_decibels int

New gain in whole decibels, clamped engine-side to -60..=+12.

required
to_tag Optional[str]

Optional peer tag for MPTY scoping.

None
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Type Description
bool

True on success.

conference_seat

conference_seat(call_id: str) -> Optional[tuple[str, str]]

(conference_id, from_tag) of the seat call_id holds, or None (test helper).

conference_join async

conference_join(
    conference_id: str,
    call: Any,
    role: str = "talker",
    target: Optional[str] = None,
    whisper_target: Optional[str] = None,
    profile: Optional[str] = None,
    dtmf: Optional[str] = None,
) -> str

Seat a call in a conference room and resolve to the answer SDP for the script's own 2xx.

The media engine mixes: every seat hears the room minus itself. The first join of a conference_id creates the room and the last :meth:conference_leave ends it, so the script owns the id (a conference factory URI, RFC 4579 section 5.2, mints one per room). The engine answers the participant's offer with its own endpoint and the participant's codec, as :meth:answer_local does for a call it answers alone.

A seat is the participant's From-tag. That is the name :meth:on_active_speaker and a room's :meth:on_dtmf hand back (a room's DTMF arrives with the room id as call_id), so a roster keyed by From-tag reads them directly.

Roles (role=), who hears whom:

  • "talker" hears the room minus itself and is heard (default);
  • "listener" and "muted" hear the room and are heard by no one;
  • "whisper" is heard only by target (a seat's From-tag);
  • "monitor" hears target alone, unknown to it, and may whisper to whisper_target.

Limits of the engine (siphon-rtp 0.14.0): 64 seats a room; the offer's first audio codec is the seat's codec, and the engine must be able to decode it and encode the mix back into it (AMR needs an amr build of the engine, G.729 a g729 one, and EVS is not implemented). The engine does not apply a profile's codec policy, WebSocket bridge or tee, recording or DSP knobs to a seat, so a profile that sets one raises ValueError rather than seating the call without it. A call holds one seat: a second join raises ValueError; leave first.

rtpengine.delete(call) on a seated call leaves its room.

Native siphon-rtp backend only; the other backends raise RuntimeError ("not supported by the ... media backend").

Parameters:

Name Type Description Default
conference_id str

The room.

required
call Any

The Call / Request carrying the participant's offer (INVITE).

required
role str

See above.

'talker'
target Optional[str]

The seat a "whisper" / "monitor" role applies to.

None
whisper_target Optional[str]

The seat a "monitor" also whispers to.

None
profile Optional[str]

RTP profile name, resolved as for :meth:answer_local.

None
dtmf Optional[str]

"events" (default, also None): the seat's RFC 4733 digits only raise @rtpengine.on_dtmf. "forward": the room also regenerates them toward every other seat, each in that seat's own payload type and clock. An engine that does not list conference_dtmf refuses "forward" (RuntimeError; prime it with :meth:set_conference_dtmf_supported). Anything else raises ValueError. The seat's setting is on the roster.

None

Returns:

Type Description
str

The answer SDP as str.

Raises:

Type Description
ValueError

the engine cannot encode the room mix in the participant's codec (primed in tests with :meth:set_conference_join_no_codec), the call is already seated, or role / targets do not fit.

Example::

@b2bua.on_invite
async def on_invite(call):
    sdp = await rtpengine.conference_join("room-1", call)
    call.answer(200, "OK", body=sdp, content_type="application/sdp")

conference_leave async

conference_leave(conference_id: str, call: Any) -> bool

Free a call's seat; the room ends with its last seat.

call is the Call / Request that joined, or a (call_id, from_tag) pair. A seat the room does not hold raises RuntimeError, as the engine refuses it.

Returns:

Type Description
bool

True on success.

conference_route async

conference_route(
    conference_id: str,
    call: Any,
    role: str,
    target: Optional[str] = None,
    whisper_target: Optional[str] = None,
    dtmf: Optional[str] = None,
) -> bool

Change who a seat hears and who hears it, live (no re-INVITE): role, target and whisper_target as for :meth:conference_join. Muting a participant is conference_route(room, call, "muted").

dtmf switches the seat's digit forwarding ("events" or "forward"); None keeps it. An engine that does not list conference_dtmf refuses an explicit dtmf. A seat the room does not hold raises RuntimeError, as the engine answers an error for it.

Returns:

Type Description
bool

True on success.

conference_bridge async

conference_bridge(
    conference_a: str,
    conference_b: str,
    direction: str = "both",
) -> bool

Link two live rooms, so one hears the other or each hears the other.

direction is "both" (default), "a_to_b" (room A's participants are heard in room B) or "b_to_a". This is what merging two conferences is. Both rooms run at 16 kHz while bridged, and :meth:conference_unbridge unlinks them; a bridge also ends with either room.

Returns:

Type Description
bool

True on success.

conference_unbridge async

conference_unbridge(
    conference_a: str, conference_b: str
) -> bool

Unlink two rooms :meth:conference_bridge linked, in both directions, whichever way the link was made. Splitting two merged conferences again.

Returns:

Type Description
bool

True on success.

Raises:

Type Description
ValueError

both ids name one room, or one is empty.

RuntimeError

the rooms are not linked.

conference_offer async

conference_offer(
    conference_id: str,
    seat_tag: str,
    role: str = "talker",
    target: Optional[str] = None,
    whisper_target: Optional[str] = None,
    profile: Optional[str] = None,
    dtmf: Optional[str] = None,
) -> str

Offer a seat for a participant the focus calls out, and resolve to the offer SDP for the INVITE (RFC 4579 section 5.5, a REFER to the focus naming a user; TS 24.605 CONF).

The engine allocates the seat and offers every codec the room can mix (plain RTP/AVP; a profile's codec flags edit the list). The seat joins the room on :meth:conference_answer; one never answered is freed by :meth:conference_leave with (call_id, seat_tag) or by the engine's setup timeout.

Parameters:

Name Type Description Default
conference_id str

The room; created on its first seat.

required
seat_tag str

The seat's name, a SIP token unique in the room. The engine names the seat by it where it names a joined seat by its From-tag (:meth:on_active_speaker, a room's DTMF).

required
role str

The seat's role, as for :meth:conference_join.

'talker'
target Optional[str]

As for :meth:conference_join.

None
whisper_target Optional[str]

As for :meth:conference_join.

None
profile Optional[str]

The RTP profile whose offer half shapes the seat.

None
dtmf Optional[str]

As for :meth:conference_join.

None

Returns:

Type Description
str

The offer SDP as str.

Example::

sdp = await rtpengine.conference_offer(room, tag)
call_id = b2bua.originate(target, sdp=sdp)
# in @b2bua.on_answer(call, reply):
await rtpengine.conference_answer(room, (call.call_id, tag), reply.body)

conference_answer async

conference_answer(
    conference_id: str,
    call: Any,
    answer_sdp: Union[str, bytes],
    received_from: Optional[str] = None,
) -> bool

Seat a participant the focus called out, on its answer to the offer :meth:conference_offer made.

call names the seat: the (call_id, seat_tag) pair of the call that carried the offer (its teardown, rtpengine.delete(call), then leaves the room). answer_sdp is the participant's answer, e.g. reply.body in @b2bua.on_answer. received_from is the IP the answer came from (reply.source_ip), for a participant behind NAT whose c= is private: the seat takes its media from there and sends the mix there. Recorded on the seat as received_from.

Returns:

Type Description
bool

True once seated.

Raises:

Type Description
ValueError

no offer is pending for the seat, the answer is empty, received_from is not an IP address, or the engine refused it (a codec it did not offer, a rejected stream); the offered seat is freed.

conference_play async

conference_play(
    conference_id: str,
    file: Optional[str] = None,
    blob: Optional[bytes] = None,
    tone: Optional[str] = None,
    repeat: Any = None,
    start_ms: Optional[int] = None,
    duration_ms: Optional[int] = None,
    gain_decibels: Optional[int] = None,
) -> int

Play audio into a room and resolve to its play_id.

Every seat hears it and nobody is mixed-minus-self against it, which is what an entry tone, "this conference is being recorded" or music for a lone participant need. Exactly one of file (a WAV on the engine host), blob (WAV bytes) or tone (a preset or a cadence, as for :meth:play_media). siphon-rtp plays no URL into a room. Up to four run at once.

Accept-on-start: the end arrives as :meth:on_play_finished with the room id as call_id and an empty from_tag (fire it in tests with :meth:fire_play_finished). Retune with :meth:conference_set_play_gain, end with :meth:conference_stop_play.

Parameters:

Name Type Description Default
conference_id str

The room, which must exist.

required
file Optional[str]

A WAV on the engine host (exactly one of the three sources).

None
blob Optional[bytes]

WAV bytes.

None
tone Optional[str]

A tone preset name or cadence spec.

None
repeat Any

Total play count, or "inf" until stopped.

None
start_ms Optional[int]

Offset into the source.

None
duration_ms Optional[int]

Hard playout cap.

None
gain_decibels Optional[int]

Gain relative to the source, -60..=+12.

None

Returns:

Type Description
int

The play_id.

conference_stop_play async

conference_stop_play(
    conference_id: str, play_id: Optional[int] = None
) -> bool

Stop one room playback by play_id, or every one in the room.

A play_id that is not running raises RuntimeError, as the engine refuses it.

Returns:

Type Description
bool

True on success.

conference_set_play_gain async

conference_set_play_gain(
    conference_id: str, play_id: int, gain_decibels: int
) -> bool

Retune a running room playback's gain, -60..=+12 dB: how an announcement ducks under the conversation rather than burying it.

Returns:

Type Description
bool

True on success.

conference_start_recording async

conference_start_recording(
    conference_id: str,
    path: Optional[str] = None,
    max_duration_ms: Optional[int] = None,
    silence_ms: Optional[int] = None,
) -> str

Record a room's listener mix (its seats, any bridged room, any room playback) to a 16 kHz 16-bit PCM WAV. Returns on the engine's accept with the recording_id; it runs until :meth:conference_stop_recording, max_duration_ms, silence_ms or the end of the room. path's directory must exist.

In the mock the id is "rec-<n>" and the recording is pending until the test calls :meth:finish_recording.

conference_stop_recording async

conference_stop_recording(
    conference_id: str, recording_id: Optional[str] = None
) -> bool

Stop one room recording, or every recording of the room.

conference_wait_recording async

conference_wait_recording(
    conference_id: str,
    recording_id: str,
    timeout_ms: Optional[int] = None,
) -> Optional[dict]

Wait until a room recording has ended and its file is closed: the dict :meth:wait_recording returns, or None after timeout_ms (default an hour). In the mock: what :meth:finish_recording set.

set_conference_join_no_codec

set_conference_join_no_codec(no_codec: bool = True) -> None

Prime :meth:conference_join to model an offer whose codec the room cannot encode (test helper): the next join raises ValueError.

set_conference_seat_supported

set_conference_seat_supported(
    supported: bool = True,
) -> None

Model an engine that does (or, with False, does not) list conference_seat in its features, or a backend other than siphon-rtp: :meth:MockB2bua.adopt_replaced then resolves 501 (test helper).

set_conference_dtmf_supported

set_conference_dtmf_supported(
    supported: bool = True,
) -> None

Model an engine that does (or, with False, does not) list conference_dtmf: dtmf="forward" on :meth:conference_join, :meth:conference_offer and b2bua.adopt_replaced, and an explicit dtmf on :meth:conference_route, are then refused with nothing done (RuntimeError, 501 for the merge) (test helper).

refuse_conference_seat

refuse_conference_seat(
    reason: Optional[str] = "room full",
) -> None

Prime the engine to refuse conference_seat (a full room, a codec it cannot mix, a draining node): :meth:MockB2bua.adopt_replaced then resolves 488. None clears it (test helper).

set_conference_join_sdp

set_conference_join_sdp(sdp: str) -> None

Set the answer SDP :meth:conference_join resolves to (test helper).

play_dtmf async

play_dtmf(
    target: Any,
    code: str,
    duration_ms: Optional[int] = None,
    volume_dbm0: Optional[int] = None,
    pause_ms: Optional[int] = None,
    to_tag: Optional[str] = None,
    scope: Optional[str] = None,
) -> bool

Inject DTMF tone(s) into the call.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object.

required
code str

A single digit ("0"–"9", "*", "#", "A"–"D") or a string sequence of digits.

required
duration_ms Optional[int]

Tone duration per digit.

None
volume_dbm0 Optional[int]

Tone volume in dBm0 (typically -8).

None
pause_ms Optional[int]

Inter-tone gap when code is a sequence.

None
to_tag Optional[str]

Optional peer tag for MPTY scoping.

None
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Example::

await rtpengine.play_dtmf(call, "123#", duration_ms=100)

silence_media async

silence_media(
    target: Any, scope: Optional[str] = None
) -> bool

Replace outgoing audio on the selected monologue with silence.

Pair with :meth:unsilence_media to restore the original stream.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

unsilence_media async

unsilence_media(
    target: Any, scope: Optional[str] = None
) -> bool

Stop replacing outgoing audio with silence (undo :meth:silence_media).

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

block_media async

block_media(
    target: Any, scope: Optional[str] = None
) -> bool

Drop outgoing packets on the selected monologue entirely.

Pair with :meth:unblock_media to resume.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

unblock_media async

unblock_media(
    target: Any, scope: Optional[str] = None
) -> bool

Resume forwarding the selected monologue's packets.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

echo async

echo(
    target: Any,
    enabled: bool = True,
    scope: Optional[str] = None,
) -> bool

Toggle echo-test mode on a call — reflect the caller's ingress audio back to itself (single-leg IVR echo).

enabled=False stops echoing. Native siphon-rtp backend only; DTMF and media-timeout events still fire while echoing.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

subscribe_request async

subscribe_request(
    call_id: str,
    from_tag: str,
    to_tag: str,
    sdp: Optional[bytes] = None,
    profile: Optional[str] = None,
    scope: Optional[str] = None,
) -> bytes

Create a new subscription to an existing call's media (MPTY / MRF conference focus).

Parameters:

Name Type Description Default
call_id str

rtpengine call-id of the source session.

required
from_tag str

source monologue tag whose outgoing audio is subscribed.

required
to_tag str

subscriber tag to create.

required
sdp Optional[bytes]

Optional inbound SDP for the subscriber.

None
profile Optional[str]

RTP profile name (defaults to "rtp_passthrough").

None
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Type Description
bytes

The subscriber SDP as bytes.

subscribe_answer async

subscribe_answer(
    call_id: str,
    from_tag: str,
    to_tag: str,
    sdp: bytes,
    profile: Optional[str] = None,
    scope: Optional[str] = None,
) -> bytes

Complete the SDP negotiation for a subscription created via :meth:subscribe_request.

Returns:

Type Description
bytes

The rewritten SDP as bytes (may be empty).

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

unsubscribe async

unsubscribe(
    call_id: str,
    from_tag: str,
    to_tag: str,
    scope: Optional[str] = None,
) -> bool

Tear down a subscription created via :meth:subscribe_request.

scope: the per-call scope the leg was offered under (offer(..., scope=)); omitted, the call's one scoped leg when only one is anchored, else the node's call. ValueError when invalid, or on a B2BUA Call.

attach_ws_tee async

attach_ws_tee(
    target: Any,
    ws_uri: str,
    direction: str = "both",
    channels: Optional[int] = None,
    sample_rate: Optional[int] = None,
    scope: Optional[str] = None,
) -> bool

Attach a WebSocket tee to a live call — stream a copy of its decoded audio to a WebSocket media server while the call keeps relaying.

The distinction from the ws_uri media-profile flag matters:

  • ws_uri is a takeover — the WebSocket server becomes leg A's far side and the A↔B relay is not wired. That is the voice-AI answer-the-call shape.
  • A tee is send-only and additive — the call relays (or transcodes) normally and streams a copy of its audio out. Any SIPREC subscription and recording on the same leg keep running untouched.

Use a tee for live transcription, agent-assist, sentiment or compliance monitoring on a call that is otherwise a normal two-party call.

A tee never affects the call: the engine drops frames rather than stalling the media path if the consumer cannot keep up, and a failure raises rather than tearing anything down — catch it and carry on.

Requires media.backend: siphon-rtp; the rtpengine and rtpproxy backends raise rather than silently doing nothing.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object, a (call_id, from_tag) pair, or a bare call_id.

required
ws_uri str

ws:// or wss:// URI the engine dials as a client. {call_id} (the SIP Call-ID), {from_tag}, {from_user} and {to_user} expand as they do on a profile's ws_uri.

required
direction str

Which leg(s) to stream — "both" (default), "caller" (the offerer) or "callee" (the answerer).

'both'
channels Optional[int]

Wire channel count — 2 interleaves caller/callee as stereo, 1 mixes them to mono. Only meaningful with direction="both"; a single-leg tee is always mono. None (default) leaves the engine's choice: 2 for both legs, 1 for one.

None
sample_rate Optional[int]

L16 wire sample rate in Hz, independent of the legs' codec rates -- the engine resamples the teed copy into it. Must be a multiple of 1000 within 8000-48000; the engine fails the attach on anything else rather than clamping, so it is checked here first. None (default) leaves the engine's choice.

None
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Type Description
bool

True on success.

Example::

@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}")

detach_ws_tee async

detach_ws_tee(
    target: Any, scope: Optional[str] = None
) -> bool

Detach a call's WebSocket tee, closing its stream.

Idempotent — detaching a call with no tee is not an error. A tee is also torn down automatically when the call ends, so an explicit detach is only needed to stop streaming mid-call.

Requires media.backend: siphon-rtp.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object.

required
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Type Description
bool

True on success.

Example::

await rtpengine.detach_ws_tee(call)

attach_ws_bridge async

attach_ws_bridge(
    target: Any,
    ws_uri: str,
    profile: Optional[str] = None,
    scope: Optional[str] = None,
) -> bool

Attach a WebSocket takeover bridge to a live call, or re-point an existing one at a different server.

The opposite of :meth:attach_ws_tee in what it does to the call. A tee is additive — the call keeps relaying and a copy is streamed out. A bridge is a takeover: the WebSocket server becomes this leg's far side and A<->B is unwired for as long as the bridge lives.

Calling it on a call that already has a bridge re-points it rather than failing, and the media path never drops in between — which is what lets one party be moved from one media server to another without the other party hearing a gap.

Requires media.backend: siphon-rtp.

Pass profile= to give the bridge a media profile's bridge settings (ws_sample_rate, noise_suppression, echo_cancellation, ws_vad, ws_barge_in), read as an answer with ws_uri reads them. Without it the engine runs the bridge at the leg's own rate with uplink processing off (a re-point keeps what the bridge had), so a bot attached after a greeting gets no barge-in and 8 kHz on a G.711 leg.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object, a (call_id, from_tag) pair, or a bare call_id.

required
ws_uri str

ws:// or wss:// URI the engine dials as a client, templated as for :meth:attach_ws_tee.

required
profile Optional[str]

Name of a media profile (built-in such as "voice_ai", or one from media.profiles). siphon raises ValueError for an unknown name before anything reaches the engine.

None
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Type Description
bool

True on success.

Example::

# Greet first, then hand the caller to the assistant with the
# settings it would have had at the answer:
await rtpengine.attach_ws_bridge(call, "wss://ai.internal/session-1",
                                 profile="voice_ai")
# ... later, hand the same caller to a different model session:
await rtpengine.attach_ws_bridge(call, "wss://ai.internal/session-2")

detach_ws_bridge async

detach_ws_bridge(
    target: Any, scope: Optional[str] = None
) -> bool

Detach a call's WebSocket takeover bridge, putting its media path back the way it was.

Not idempotent, unlike :meth:detach_ws_tee. The engine refuses a detach when there is no relay to return the call to — a bridge negotiated through ws_uri on the media profile is the call's media path, and a single-leg (answer_local) takeover has no second party that could ever be relayed to. Both raise rather than answering success, because the alternative is a live call with no audio path at all. Re-point those with :meth:attach_ws_bridge, or end the call.

Requires media.backend: siphon-rtp.

Parameters:

Name Type Description Default
target Any

Request, Reply, or Call object.

required
scope Optional[str]

The per-call scope the leg was offered under (offer(..., scope=)), for a proxy anchoring two legs of one Call-ID. Omitted: the node's call, or the call's one scoped leg when only one is anchored and the call has no session of its own. ValueError when invalid, or on a B2BUA Call.

None

Returns:

Type Description
bool

True on success.

Example::

await rtpengine.detach_ws_bridge(call)   # back to relaying A<->B

on_dtmf

on_dtmf(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for inbound DTMF events from rtpengine.

Usage::

@rtpengine.on_dtmf
def handle_any(call_id, from_tag, digit, duration_ms, volume):
    ...

@rtpengine.on_dtmf(call_id="abc", from_tag="ftag1")
def handle_specific(call_id, from_tag, digit, duration_ms, volume):
    ...

A digit on a leg anchored under a per-call scope (offer(..., scope=)) arrives under the SIP Call-ID, and a handler that takes a scope keyword is also given the scope. Every media event decorator works this way; a handler without the keyword is called as before::

@rtpengine.on_dtmf
def handle_leg(call_id, from_tag, digit, duration_ms, volume, scope=None):
    if scope == "term":
        ...

fire_dtmf

fire_dtmf(
    call_id: str,
    from_tag: str,
    digit: str,
    duration_ms: int = 0,
    volume: int = 0,
    scope: Optional[str] = None,
) -> int

Test helper: fire a DTMF event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_media_timeout

on_media_timeout(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for media-timeout events from the media engine.

The engine reaps a call whose media went dead and pushes a media-timeout event; the handler releases the per-call state no BYE will now clear (Rx/N5 QoS, charging, dialog).

Usage::

@rtpengine.on_media_timeout
def handle_any(call_id, from_tag):
    ...

@rtpengine.on_media_timeout(call_id="abc", from_tag="ftag1")
def handle_specific(call_id, from_tag):
    ...

fire_media_timeout

fire_media_timeout(
    call_id: str, from_tag: str, scope: Optional[str] = None
) -> int

Test helper: fire a media-timeout event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_text

on_text(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for RFC 4103 real-time text (T.140) increments.

Fires once per increment the engine's text processor recovers on the call's m=text stream, carrying the UTF-8 text that packet newly delivered. Only non-empty increments are reported — a duplicate, a reordered packet or an idle keepalive produces no event — so the handler firing always means new characters arrived. A � in the text is a gap RED redundancy could not repair (RFC 4103 §5.3), left in place so a consumer sees where loss occurred rather than silently reading a shorter message.

Requires the call's media profile to set text_events. Delivered by the native siphon-rtp backend only.

Usage::

@rtpengine.on_text
def transcript(call_id, from_tag, to_tag, text, direction):
    log.info(f"[{call_id}] {direction}: {text}")

@rtpengine.on_text(call_id="abc", from_tag="ftag1")
def transcript_specific(call_id, from_tag, to_tag, text, direction):
    ...

fire_text

fire_text(
    call_id: str,
    from_tag: str,
    text: str,
    to_tag: Optional[str] = None,
    direction: Optional[str] = "a_to_b",
    scope: Optional[str] = None,
) -> int

Test helper: fire a real-time text event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_beep

on_beep(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for record-tone ("voicemail beep") events.

Fires when the engine hears the short single tone an answering machine plays before it starts recording, on a leg whose media profile set beep_detection. This is the media half of answering-machine detection: a script can abort an attended transfer here instead of bridging the caller into a voicemail box.

Arm it per leg -- the profile used toward the callee is what watches the party that might be a machine. It fires once per leg per call (the engine drops the detector after the first tone, so a handler never has to de-duplicate, and there is no mid-call re-arm).

offset_ms is how much decoded audio was seen on the leg before the tone started -- the offset of the tone itself, not of this event. The event trails it by roughly the profile's beep_cadence_guard_ms (4500 ms by default), which is the detector's cadence guard and its detection latency.

Delivered by the native siphon-rtp backend only.

Usage::

@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)")
    b2bua.terminate(call_id, "Answering machine detected")

@rtpengine.on_beep(call_id="abc", from_tag="ftag1")
def machine_specific(call_id, from_tag, to_tag, frequency_hz, duration_ms, offset_ms):
    ...

fire_beep

fire_beep(
    call_id: str,
    from_tag: str,
    to_tag: Optional[str] = None,
    frequency_hz: float = 1000.0,
    duration_ms: int = 420,
    offset_ms: int = 7300,
    scope: Optional[str] = None,
) -> int

Test helper: fire a record-tone event. Returns the number of handlers that matched (and were invoked).

The defaults describe a typical voicemail beep: a ~1 kHz tone a few hundred milliseconds long, several seconds into the leg's audio.

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_active_speaker

on_active_speaker(
    func_or_none: Any = None,
    *,
    conference_id: Optional[str] = None
) -> Any

Register a handler for a conference room's active speaker.

Called with (conference_id, from_tag) when the loudest speaking seat of a room changes, and with from_tag=None when the floor goes silent: what floor control or a roster's speaking indicator (the RFC 4575 <media> status of a focus) reads. The seat is the participant's From-tag, as :meth:conference_join seated it. conference_id= filters on one room.

Delivered by the native siphon-rtp backend only.

Usage::

@rtpengine.on_active_speaker
def floor(conference_id, from_tag):
    log.info(f"{conference_id}: {from_tag or 'nobody'} has the floor")

fire_active_speaker

fire_active_speaker(
    conference_id: str, from_tag: Optional[str]
) -> int

Test helper: fire an active-speaker change. Returns the number of handlers that matched (and were invoked).

on_media_started

on_media_started(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for media started events.

Fires when the first packet on one of a call's engine legs clears the engine's source gate, within about 20 ms of it arriving: media is flowing toward the engine on that leg. Once per leg, so a two-party call raises two, and not again on a re-latch or a re-offer.

leg is "near" (the offerer's side, from_tag) or "far" (the answerer's, to_tag). It names the engine leg rather than a party: before an answer the far leg carries the callee's early media on a relayed call. source is the "ip:port" the engine latched and signalled the one the SDP gave; they differ behind a NAT. Either is None when the engine did not report it.

Delivered by the native siphon-rtp backend only.

Usage::

@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})")

@rtpengine.on_media_started(call_id="abc", from_tag="ftag1")
def flowing_specific(call_id, from_tag, to_tag, leg, source, signalled):
    ...

fire_media_started

fire_media_started(
    call_id: str,
    from_tag: str,
    to_tag: Optional[str] = None,
    leg: str = "near",
    source: Optional[str] = None,
    signalled: Optional[str] = None,
    scope: Optional[str] = None,
) -> int

Test helper: fire a media-started event. Returns the number of handlers that matched (and were invoked).

leg must be "near" or "far", the only two the engine sends.

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_ws_tee_started

on_ws_tee_started(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for WebSocket tee started events.

Fires once the engine has dialled the tee's WebSocket server, sent its start envelope, and begun streaming. The handler receives the negotiated wire shape, so it can decode the binary frames without guessing — stream_id is the correlator between this control event and the media stream on the socket.

Delivered by the native siphon-rtp backend only.

Usage::

@rtpengine.on_ws_tee_started
def tee_up(call_id, from_tag, stream_id, ws_uri, direction, channels, sample_rate):
    log.info(f"tee {stream_id}: {channels}ch @ {sample_rate}Hz -> {ws_uri}")

@rtpengine.on_ws_tee_started(call_id="abc", from_tag="ftag1")
def tee_up_specific(call_id, from_tag, stream_id, ws_uri, direction, channels, sample_rate):
    ...

fire_ws_tee_started

fire_ws_tee_started(
    call_id: str,
    from_tag: str,
    stream_id: str,
    ws_uri: str,
    direction: str = "both",
    channels: int = 2,
    sample_rate: int = 8000,
    scope: Optional[str] = None,
) -> int

Test helper: fire a ws-tee-started event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_ws_tee_ended

on_ws_tee_ended(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for WebSocket tee ended events.

Fires exactly once per started tee, including when the server ends it. That is the point of the hook: "server_closed", "server_stopped" and "transport_error" mean the audio stream died while the call is still up, which is otherwise invisible — the call carries on and nothing reaches the consumer. Re-attach, fail over, or alert from here.

reason is one of "detached" (the script or the call teardown asked for it), "call_ended" (the call ended under it — an ordinary hang-up), "server_closed", "server_stopped" or "transport_error". The first two are the orderly ends.

frames_dropped non-zero means the consumer could not keep up; the call itself was never affected.

Delivered by the native siphon-rtp backend only.

Usage::

@rtpengine.on_ws_tee_ended
async def tee_down(call_id, from_tag, stream_id, reason, frames_sent, frames_dropped):
    if reason not in ("detached", "call_ended"):
        log.warn(f"tee {stream_id} died: {reason}")

fire_ws_tee_ended

fire_ws_tee_ended(
    call_id: str,
    from_tag: str,
    stream_id: str,
    reason: str = "detached",
    frames_sent: Optional[int] = None,
    frames_dropped: Optional[int] = None,
    scope: Optional[str] = None,
) -> int

Test helper: fire a ws-tee-ended event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_recording_finished

on_recording_finished(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for recording finished events: a recording's file is closed and can be read, moved or uploaded.

Fires once per recording the engine accepted, whoever started it and however it ended. reason is "stopped", "max_duration", "silence", "call_ended", "bridged" or "error"; path is None only when the recording aborted before a file existed. call_id is the SIP Call-ID of the party the recording was on.

Delivered by the native siphon-rtp backend only.

Usage::

@rtpengine.on_recording_finished
async def closed(call_id, from_tag, recording_id, path, reason, duration_ms):
    if path and duration_ms > 0:
        await archive(call_id, path)

fire_recording_finished

fire_recording_finished(
    call_id: str,
    from_tag: str,
    recording_id: str,
    path: Optional[str] = None,
    reason: str = "stopped",
    duration_ms: int = 0,
    scope: Optional[str] = None,
) -> int

Test helper: fire a recording-finished event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_play_finished

on_play_finished(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for playback finished events.

Fires when a playback the engine accepted ends — for every play, including a fire-and-forget one. A blocking play_media(wait=True) already learns the outcome as its return value; this is for everything else, and it is what an announcement-then-act flow hangs on instead of a timer that a stop or a decode error would make wrong.

reason is one of completed, stopped, superseded or error. Only completed means the prompt was heard in full. play_id correlates with the accept and with PlayStarted.

Delivered by the native siphon-rtp backend only.

Usage::

@rtpengine.on_play_finished
async def prompt_done(call_id, from_tag, play_id, reason, played_ms):
    if reason == "completed":
        await rtpengine.play_dtmf(call_id, "1")

fire_play_finished

fire_play_finished(
    call_id: str,
    from_tag: str,
    play_id: int,
    reason: str = "completed",
    played_ms: Optional[int] = None,
    scope: Optional[str] = None,
) -> int

Test helper: fire a play-finished event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_ws_bridge_started

on_ws_bridge_started(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for WebSocket takeover bridge started events.

Fires once the engine has dialled the bridge's WebSocket server and the leg's far side is that server — A<->B is unwired for the bridge's lifetime. stream_id is the correlator between this control event and the media stream on the socket.

A re-point (attach_ws_bridge on a call that already had one) ends the old bridge and starts a new one, so it delivers an ended with reason detached followed by a fresh started carrying the new stream_id.

Delivered by the native siphon-rtp backend only.

Usage::

@rtpengine.on_ws_bridge_started
def bridge_up(call_id, from_tag, stream_id, ws_uri, sample_rate):
    log.info(f"bridge {stream_id} @ {sample_rate}Hz -> {ws_uri}")

fire_ws_bridge_started

fire_ws_bridge_started(
    call_id: str,
    from_tag: str,
    stream_id: str,
    ws_uri: str,
    sample_rate: int = 8000,
    scope: Optional[str] = None,
) -> int

Test helper: fire a ws-bridge-started event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

on_ws_bridge_ended

on_ws_bridge_ended(
    func_or_none: Any = None,
    *,
    call_id: Optional[str] = None,
    from_tag: Optional[str] = None
) -> Any

Register a handler for WebSocket takeover bridge ended events.

Fires exactly once per started bridge, including when the server ends it. reason is one of detached, server_closed, server_stopped, call_ended or transport_error.

detached and call_ended (the call was torn down, taking the bridge with it) are orderly. Every other reason leaves a live call with no media far side — both parties are up and hearing nothing — so unlike the tee's equivalent this handler usually has to act: re-point with attach_ws_bridge, fall back with detach_ws_bridge, or tear the call down. siphon logs an unexpected end at WARN even when no handler is registered.

Delivered by the native siphon-rtp backend only.

Usage::

@rtpengine.on_ws_bridge_ended
async def bridge_down(call_id, from_tag, stream_id, reason):
    if reason not in ("detached", "call_ended"):
        log.warn(f"{call_id}: bridge died ({reason})")

fire_ws_bridge_ended

fire_ws_bridge_ended(
    call_id: str,
    from_tag: str,
    stream_id: str,
    reason: str = "detached",
    scope: Optional[str] = None,
) -> int

Test helper: fire a ws-bridge-ended event. Returns the number of handlers that matched (and were invoked).

scope: fire it as an event of a leg anchored under that per-call scope; a handler that accepts a scope keyword is given it, any other is called as before.

set_subscribe_request_sdp

set_subscribe_request_sdp(sdp: bytes) -> None

Configure the SDP returned by :meth:subscribe_request (test helper).

set_subscribe_answer_sdp

set_subscribe_answer_sdp(sdp: bytes) -> None

Configure the SDP returned by :meth:subscribe_answer (test helper).

set_play_media_duration

set_play_media_duration(duration_ms: Optional[int]) -> None

Configure the duration returned by :meth:play_media (test helper).

set_play_overlay_id

set_play_overlay_id(play_id: Optional[int]) -> None

Configure the play_id returned by :meth:play_overlay (test helper).

Set it to None to model an engine that accepted the overlay without assigning a handle, which is what a script's "can I duck this later" branch has to cope with.

set_answer_local_sdp

set_answer_local_sdp(sdp: str) -> None

Configure the answer SDP returned by :meth:answer_local (test helper).

set_answer_local_no_codec

set_answer_local_no_codec(no_codec: bool = True) -> None

Prime :meth:answer_local to model a no-encodable-codec offer (test helper) — the next answer_local records a deferred 488 (auto-reject) or raises ValueError (auto_reject=False).

clear

clear() -> None

Clear recorded operations and registered event handlers (test helper).

qos namespace

Mock qos namespace — turns SDP offer/answer pairs into the media_components structure consumed by diameter.rx_aar and sbi.create_session.

The mock parses SDP just enough to produce the same media_components list siphon does: one entry per m= line with RTP + RTCP sub-components, a=rtcp-mux in offer and answer collapsing RTCP into the RTP one, and the Flow-Status of TS 29.213 table 6.2.1 named on every entry.

Example::

from siphon import qos, diameter

components = qos.media_flows_from_sdp(
    offer=request.body, answer=reply.body, direction="orig",
)
await diameter.rx_aar(framed_ip=request.source_ip, media_components=components)

media_flows_from_sdp

media_flows_from_sdp(
    *, offer: Any, answer: Any, direction: str = "orig"
) -> list[dict]

Translate an SDP offer/answer pair into a media_components list.

Parameters:

Name Type Description Default
offer Any

the original (offer) SDP — str, bytes, or a Request/Reply/Call mock with a body attribute.

required
answer Any

the answer SDP (typically post rtpengine.answer()).

required
direction str

"orig" (UE is offerer — UE addr from offer, remote from answer) or "term" (UE is answerer — addresses flipped).

'orig'

Returns:

Type Description
list[dict]

list[dict]: one entry per m= line. number is the ordinal

list[dict]

of the m= line, so a line keeps its number when an earlier

list[dict]

one is disabled.

Every entry names flow_status, derived per TS 29.213 table 6.2.1 from the offer and the answer (NOTE 5: the answer's port and direction, but a=inactive in the offer wins; a media line without a direction attribute takes the session-level one):

  • "removed": port 0 in the offer or the answer. The entry has no flows.
  • "enabled": TCP transport (TCP, TCP/MSRP, UDP/DTLS/SCTP), a=rtcp-mux in offer and answer, or a=sendrecv / no direction attribute.
  • "disabled": a=inactive.
  • "enabled-up" / "enabled-down": a=sendonly / a=recvonly, read from the side of the SDP that carries it (sendonly in the UE's SDP is uplink, in the network's SDP downlink; recvonly the other way round).

"enabled" is named, not left out: on an update an absent Flow-Status means "unchanged" (TS 29.214 §5.3.16).

Each sub-component carries two descriptions, uplink first (TS 29.214 clause 5.3.8: in is the uplink flow, out the downlink flow; the UE is the source of in and the destination of out)::

permit in  <proto> from <UE> <port> to <remote> <port>
permit out <proto> from <remote> <port> to <UE> <port>