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
¶
List of (operation, profile_or_detail) tuples recorded.
media_calls
instance-attribute
¶
Full parameter dicts for each media-injection call.
ws_uris
instance-attribute
¶
(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
¶
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_a, conference_b, direction) per live bridge.
conference_plays
instance-attribute
¶
Running room playbacks by play_id.
conference_offers
instance-attribute
¶
Seats offered by :meth:conference_offer and not yet answered,
by (conference_id, seat_tag).
media_scopes
instance-attribute
¶
(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
¶
(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
¶
Number of active media sessions (mock: count of offer - delete).
instance_count
property
¶
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 toTrue. - 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.requestthen only names the offerer. No message body is read or written; the call resolves to the rewritten SDP asstrfor 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 |
required |
profile
|
Optional[str]
|
RTP profile name. Defaults to |
None
|
ws_uri
|
Optional[str]
|
Bridge this leg's audio to an external WebSocket media
server ( |
None
|
sdp
|
Union[str, bytes, None]
|
The offering party's SDP ( |
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. |
None
|
Returns:
| Type | Description |
|---|---|
Union[bool, str]
|
|
Union[bool, str]
|
The mock rewrites nothing and returns |
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 toTrue. - 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 asstrfor the script to send itself.
Profile precedence (matches the real implementation):
- Explicit
profile=argument (script override). - Profile recorded by the matching
offer(looked up by A-leg Call-ID). Lets@b2bua.on_answer/@b2bua.on_early_mediacallrtpengine.answer(reply)with noprofile=and still get the directional flags from the offer-side profile. "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
answerhalf. - The caller replies to a re-INVITE or an UPDATE from the callee. The
result goes to the callee, shaped by the
offerhalf, 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 |
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
|
None
|
ws_uri
|
Optional[str]
|
WebSocket bridge URI override for this call ( |
None
|
sdp
|
Union[str, bytes, None]
|
The far side's answer SDP ( |
None
|
to_tag
|
Optional[str]
|
Tag naming the answering party to the engine. Raw mode only
( |
None
|
scope
|
Optional[str]
|
The per-call scope the leg's |
None
|
Returns:
| Type | Description |
|---|---|
Union[bool, str]
|
|
Union[bool, str]
|
The mock rewrites nothing and returns |
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 aCalltarget, a deferred488 Not Acceptable Hereis recorded on the call (call.reject(488, "Not Acceptable Here")) and the coroutine resolves toNone; - with
auto_reject=False(or a non-Calltarget) it raisesValueErrorinstead.
Native siphon-rtp backend only.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call
|
Any
|
A |
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
|
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 |
None
|
scope
|
Optional[str]
|
Answer this leg as an engine call of its own, as
:meth: |
None
|
Returns:
| Type | Description |
|---|---|
Optional[str]
|
The answer SDP as |
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
¶
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.
|
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
Example::
@proxy.on_request("BYE")
async def bye(request):
await rtpengine.delete(request) # both scoped legs go
request.relay()
delete_call
async
¶
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 |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
bool
|
longer held it (already timed out, or never anchored). The mock |
bool
|
answers |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
RuntimeError
|
the engine could not be reached or refused the delete. |
ping
async
¶
Health check: ping RTPEngine instance(s).
Returns:
| Type | Description |
|---|---|
bool
|
|
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 ( |
None
|
url
|
Optional[str]
|
An |
None
|
repeat
|
Union[int, str, None]
|
Total number of times to play the prompt (default once),
or |
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
|
scope
|
Optional[str]
|
The per-call scope the leg was offered under
( |
None
|
Returns:
| Type | Description |
|---|---|
Optional[int]
|
Prompt duration in ms if rtpengine reports one (mock returns |
Optional[int]
|
the value set via :meth: |
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: |
None
|
url
|
Optional[str]
|
An |
None
|
repeat
|
Union[int, str, None]
|
Total number of times to play, or |
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 ( |
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
( |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
Optional[int]
|
The |
|
via |
Optional[int]
|
meth: |
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
¶
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 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: |
None
|
scope
|
Optional[str]
|
The per-call scope the leg was offered under
( |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
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: |
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
( |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
conference_seat
¶
(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 bytarget(a seat's From-tag);"monitor"hearstargetalone, unknown to it, and may whisper towhisper_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 |
required |
role
|
str
|
See above. |
'talker'
|
target
|
Optional[str]
|
The seat a |
None
|
whisper_target
|
Optional[str]
|
The seat a |
None
|
profile
|
Optional[str]
|
RTP profile name, resolved as for :meth: |
None
|
dtmf
|
Optional[str]
|
|
None
|
Returns:
| Type | Description |
|---|---|
str
|
The answer SDP as |
Raises:
| Type | Description |
|---|---|
ValueError
|
the engine cannot encode the room mix in the
participant's codec (primed in tests with
:meth: |
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
¶
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
|
|
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
|
|
conference_bridge
async
¶
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
|
|
conference_unbridge
async
¶
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
|
|
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: |
required |
role
|
str
|
The seat's role, as for :meth: |
'talker'
|
target
|
Optional[str]
|
As for :meth: |
None
|
whisper_target
|
Optional[str]
|
As for :meth: |
None
|
profile
|
Optional[str]
|
The RTP profile whose offer half shapes the seat. |
None
|
dtmf
|
Optional[str]
|
As for :meth: |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The offer SDP as |
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
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
no offer is pending for the seat, the answer is empty,
|
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 |
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 |
conference_stop_play
async
¶
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
|
|
conference_set_play_gain
async
¶
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
|
|
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
¶
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
¶
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
¶
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
¶
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
¶
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 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 ( |
required |
duration_ms
|
Optional[int]
|
Tone duration per digit. |
None
|
volume_dbm0
|
Optional[int]
|
Tone volume in dBm0 (typically |
None
|
pause_ms
|
Optional[int]
|
Inter-tone gap when |
None
|
to_tag
|
Optional[str]
|
Optional peer tag for MPTY scoping. |
None
|
scope
|
Optional[str]
|
The per-call scope the leg was offered under
( |
None
|
Example::
await rtpengine.play_dtmf(call, "123#", duration_ms=100)
silence_media
async
¶
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
¶
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
¶
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
¶
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
¶
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 |
None
|
scope
|
Optional[str]
|
The per-call scope the leg was offered under
( |
None
|
Returns:
| Type | Description |
|---|---|
bytes
|
The subscriber SDP as |
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 |
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
¶
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_uriis 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 |
required |
ws_uri
|
str
|
|
required |
direction
|
str
|
Which leg(s) to stream — |
'both'
|
channels
|
Optional[int]
|
Wire channel count — |
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
|
scope
|
Optional[str]
|
The per-call scope the leg was offered under
( |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
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 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
( |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
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 |
required |
ws_uri
|
str
|
|
required |
profile
|
Optional[str]
|
Name of a media profile (built-in such as |
None
|
scope
|
Optional[str]
|
The per-call scope the leg was offered under
( |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
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 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
( |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
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
¶
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
¶
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
¶
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
¶
Configure the SDP returned by :meth:subscribe_request (test helper).
set_subscribe_answer_sdp
¶
Configure the SDP returned by :meth:subscribe_answer (test helper).
set_play_media_duration
¶
Configure the duration returned by :meth:play_media (test helper).
set_play_overlay_id
¶
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
¶
Configure the answer SDP returned by :meth:answer_local (test helper).
set_answer_local_no_codec
¶
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).
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
¶
Translate an SDP offer/answer pair into a media_components list.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
offer
|
Any
|
the original (offer) SDP — |
required |
answer
|
Any
|
the answer SDP (typically post |
required |
direction
|
str
|
|
'orig'
|
Returns:
| Type | Description |
|---|---|
list[dict]
|
list[dict]: one entry per |
list[dict]
|
of the |
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": port0in the offer or the answer. The entry has no flows."enabled": TCP transport (TCP,TCP/MSRP,UDP/DTLS/SCTP),a=rtcp-muxin offer and answer, ora=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 (sendonlyin the UE's SDP is uplink, in the network's SDP downlink;recvonlythe 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>