A conference focus with mixing¶
A conference focus (RFC 4579) is the user agent every participant of a conference has a dialog with. siphon can be that focus with the media engine doing the mixing: the script answers each participant's INVITE itself, the engine seats the participant in a room and hands back the answer SDP, and every seat hears the room minus itself.
Mixing needs the native siphon-rtp engine (media.backend: siphon-rtp).
rtpengine and rtpproxy have no mixer, and every conference_* verb raises
RuntimeError naming the backend on them.
Rooms and seats¶
- A room is named by an id the script chooses. The engine creates it on its
first
conference_joinand ends it with its lastconference_leave, so the script owns the id space: one room per conference URI. - A seat is a participant's call, addressed by its From-tag. That is the
name
@rtpengine.on_active_speakerand a room's@rtpengine.on_dtmfhand back. - A room holds at most 64 seats. Bridged rooms (
conference_bridge) each hear the other's participants.
The focus¶
The factory URI (RFC 4579 section 5.2) mints a room; an INVITE to a conference URI joins it. A BYE leaves. The roster is just what joined and has not left.
import uuid
from siphon import b2bua, proxy, rtpengine, log
FACTORY = "conference-factory"
# room id -> {call_id: participant URI}
ROSTER = {}
# call_id -> room id, since a factory INVITE's own URI names no room
ROOM_OF_CALL = {}
def room_of(call):
"""The room an INVITE names: a fresh one for the factory URI."""
user = call.ruri.user
if user == FACTORY:
return f"room-{uuid.uuid4().hex[:12]}"
return user
@proxy.on_request("OPTIONS")
def health(request):
request.reply(200, "OK")
@b2bua.on_invite
async def on_invite(call):
room = room_of(call)
try:
sdp = await rtpengine.conference_join(room, call)
except ValueError as error:
# The room cannot mix the participant's codec (RFC 3264 section 6).
log.warn(f"{room}: {error}")
call.reject(488, "Not Acceptable Here")
return
# RFC 4579 section 5.1: the focus's Contact is the conference URI, with
# `isfocus`. A factory-minted room's creator learns its URI here.
call.answer(200, "OK", body=sdp, content_type="application/sdp",
contact=f"sip:{room}@{call.ruri.host}", contact_params="isfocus")
ROOM_OF_CALL[call.call_id] = room
ROSTER.setdefault(room, {})[call.call_id] = str(call.from_uri)
log.info(f"{room}: {call.from_uri} joined, {len(ROSTER[room])} seated")
# RFC 4575: a <user> joined the roster; NOTIFY the subscribers here.
@b2bua.on_bye
async def on_bye(call, initiator):
room = ROOM_OF_CALL.pop(call.call_id, None)
if room is None:
return
# `rtpengine.delete(call)` would leave the room as well.
await rtpengine.conference_leave(room, call)
seats = ROSTER.get(room, {})
seats.pop(call.call_id, None)
if not seats:
ROSTER.pop(room, None)
# RFC 4575: a <user> left the roster; NOTIFY the subscribers here.
@rtpengine.on_active_speaker
def floor(conference_id, from_tag):
# `from_tag` is None when the floor goes silent. RFC 4575 carries the
# speaker as a <media> status; a focus would NOTIFY it from here.
log.info(f"{conference_id}: {from_tag or 'nobody'} has the floor")
A factory-minted room's URI goes back to its creator as the focus's Contact,
with the isfocus feature parameter (RFC 4579 section 5.1), so that later
participants can dial it, or be referred to it. The URI has to route back to
siphon: the creator's in-dialog requests are sent to it. The RFC 4575 event
package itself is not shown: the comments mark where its NOTIFYs would go.
With five participants the room has five seats and each hears the other four.
rtpengine.delete(call) on a seated call leaves its room as well, so a script
that already deletes media on BYE does not need a second call.
Who hears whom¶
role= on conference_join, or later on conference_route, places a seat in
the room's routing matrix, live and without a re-INVITE:
| role | hears | is heard by |
|---|---|---|
talker |
the room minus itself | everyone (default) |
listener |
the room | no one |
muted |
the room | no one |
whisper |
the room minus itself | only target |
monitor |
target alone, unknown to it |
no one, or whisper_target |
from siphon import rtpengine
async def mute(room, call):
await rtpengine.conference_route(room, call, "muted")
async def coach(room, supervisor, agent_tag):
# The supervisor hears the agent and talks to the agent alone.
await rtpengine.conference_route(
room, supervisor, "monitor", target=agent_tag, whisper_target=agent_tag
)
A seat can also be named by a (call_id, from_tag) pair, which is what an
event handler holds.
Merging two conferences¶
from siphon import rtpengine
async def merge(room_a, room_b):
await rtpengine.conference_bridge(room_a, room_b) # each hears the other
# await rtpengine.conference_bridge(room_a, room_b, direction="a_to_b")
rtpengine.conference_unbridge(room_a, room_b) splits them again, whichever
way the link was made; a link also ends with either room.
Calling a participant in¶
To add someone who has not dialled in (RFC 4579 ยง5.5, a REFER to the focus
naming a user; TS 24.605 CONF) the focus places the call, so the engine is the
offerer: conference_offer allocates a new seat and resolves to the offer for
the INVITE, and conference_answer seats the participant on its answer. A seat
is named by a tag the script chooses.
import uuid
from siphon import b2bua, rtpengine
DIALLING = {} # Call-ID of the INVITE the focus sent -> (room, seat tag)
async def call_in(room, target, replaces=None, referred_by=None):
tag = uuid.uuid4().hex[:10]
sdp = await rtpengine.conference_offer(room, tag)
headers = {}
if replaces:
headers["Replaces"] = replaces # RFC 3891: take a call over
if referred_by:
headers["Referred-By"] = referred_by # RFC 3892
call_id = b2bua.originate(target, sdp=sdp, headers=headers)
DIALLING[call_id] = (room, tag)
@b2bua.on_answer
async def answered(call, reply):
room, tag = DIALLING.pop(call.call_id, (None, None))
if room is not None:
await rtpengine.conference_answer(room, (call.call_id, tag), reply.body)
@b2bua.on_failure
async def not_answered(call, code, reason):
room, tag = DIALLING.pop(call.call_id, (None, None))
if room is not None:
await rtpengine.conference_leave(room, (call.call_id, tag))
The offer lists every codec the room can mix in that engine build (a profile's
codec flags edit it) as plain RTP/AVP; an answer has to pick one of them.
rtpengine.delete(call) on the answered call leaves the room, as for a joined
seat. An offer nobody answers is freed by conference_leave or by the engine's
setup timeout.
Playing into a room¶
conference_play mixes a file, blob or tone into the room as a source
nobody is mixed-minus-self against, and resolves to its play_id. Its end
arrives as @rtpengine.on_play_finished with the room id as call_id and an
empty from_tag. conference_set_play_gain ducks it,
conference_stop_play ends one or all of them. Music for a lone participant:
from siphon import rtpengine
MUSIC = {} # room id -> play_id
async def seated(room, seats):
if seats == 1:
MUSIC[room] = await rtpengine.conference_play(room, file="/prompts/moh.wav", repeat="inf")
elif room in MUSIC:
await rtpengine.conference_stop_play(room, MUSIC.pop(room))
@rtpengine.on_play_finished
def ended(call_id, from_tag, play_id, reason, played_ms):
if MUSIC.get(call_id) == play_id:
MUSIC.pop(call_id)
What the engine does not do yet¶
These are limits of siphon-rtp 0.14.0, not of the script API:
- Codec choice. A seat takes the offer's first audio codec, and the engine
must be able to decode it and encode the mix back into it (AMR needs the
engine's
amrbuild, G.729 itsg729build, and EVS is not implemented). A seat it cannot mix is refused withValueError; a profile's codec policy is not applied to a seat, soconference_joinrefuses a profile that sets one rather than seating the call without it (the same goes forws_uri,ws_tee,record_calland the DSP knobs). - Hold and mute by re-INVITE. A seat is no media session of its call, so
siphon answers a participant's re-INVITE or UPDATE that changes the media
488unless@b2bua.on_reofferanswers it. A hold (sendonly) is answered byoffer.answer(), afterconference_route(room, call, "listener")or"muted"so the room stops hearing it:
@b2bua.on_reoffer
async def seat_reoffer(call, offer):
room = ROOM_OF_CALL.get(call.call_id)
if room is None:
return
if offer.kind == "hold":
await rtpengine.conference_route(room, call, "muted")
elif offer.kind == "resume":
await rtpengine.conference_route(room, call, "talker")
offer.answer()
conference_join raises ValueError. A re-INVITE that
changes the media has to leave and join again, which ends the room (and its
playbacks and bridges) when the call was its only seat.
- The 64-seat cap and a seat's packetization limits are checked by the
room after the engine has answered the join, so a 65th participant is
answered and then not mixed. Keep the count in the roster.
- Recording a room (conference_start_recording) is not exposed yet: its
completion has no script hook to arrive on.
The script on this page is
examples/conference_focus.py,
with its config in
examples/conference_focus.yaml.