SIP Voice Backend¶
A voice backend that handles the full SIP call lifecycle: listening for incoming INVITE requests, negotiating codecs via SDP, creating RTP sessions for audio, and managing call teardown (BYE/CANCEL). Uses aiosipua for SIP signaling and aiortp for media transport.
Quick start¶
from roomkit.voice.backends.sip import SIPVoiceBackend
from roomkit.voice import VoiceSession
backend = SIPVoiceBackend(
local_sip_addr=("0.0.0.0", 5060),
local_rtp_ip="10.0.0.5",
rtp_port_start=10000,
)
# Route incoming calls to rooms
def on_call(session: VoiceSession):
room_id = session.metadata.get("room_id", session.id)
print(f"Incoming call for room {room_id}")
backend.on_call(on_call)
backend.on_call_disconnected(lambda s: print(f"Call ended: {s.id}"))
await backend.start()
Install with:
This pulls in both aiosipua and aiortp transitively.
How it works¶
Unlike the RTP backend which requires manual address configuration and no SIP signaling, the SIP backend manages the complete call flow:
PBX/SIP Trunk SIPVoiceBackend
───────────── ───────────────
INVITE ──────────────────────► receives call
(SDP offer, │
X-Room-ID, ├── SDP negotiation (codec selection)
X-Session-ID) ├── RTP session creation
│
◄──── 100 Trying │
◄──── 180 Ringing │
◄──── 200 OK (SDP answer) ├── on_call callback fires
│
RTP audio ◄──────────────────► audio pipeline (VAD → STT → AI → TTS)
DTMF (RFC 4733) ◄──────────► │
│
BYE ─────────────────────────► on_call_disconnected callback fires
◄──── 200 OK cleanup
- PBX sends an INVITE with SDP offer and optional X-headers
- Backend negotiates codecs, sends 100 Trying → 180 Ringing → 200 OK
- RTP session is created automatically from the negotiated SDP
on_callcallback fires with aVoiceSessionfor the app to route- Audio flows through the pipeline (same as RTP backend)
- When the remote party sends BYE,
on_call_disconnectedfires
Constructor parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
local_sip_addr |
(str, int) |
("0.0.0.0", 5060) |
Host and port to bind the SIP UDP listener. |
local_rtp_ip |
str |
"0.0.0.0" |
IP address for RTP media binding. Use your server's actual IP in production. |
rtp_port_start |
int |
10000 |
First port in the RTP allocation range. |
rtp_port_end |
int |
20000 |
Last port in the RTP allocation range. |
supported_codecs |
list[int] \| None |
[9, 0, 8] |
Codec payload types to accept (G.722, PCMU, PCMA). |
dtmf_payload_type |
int |
101 |
RTP payload type for RFC 4733 DTMF events. |
jitter_capacity |
int |
32 |
Max packets the RTP jitter buffer can hold (~640 ms at 20 ms/packet). |
jitter_prefetch |
int |
0 |
Packets to accumulate before starting playout. 0 = start immediately. |
skip_audio_gaps |
bool |
True |
Skip confirmed-lost packets rather than stalling the jitter buffer. |
plc |
bool |
True |
Replace confirmed-lost packets with concealment PCM, keeping the inbound stream temporally continuous. Effective only with skip_audio_gaps. |
playout |
bool |
False |
Deliver inbound audio on a steady clock through an adaptive jitter buffer (depth tracks measured jitter). jitter_prefetch only applies when this is off. |
playout_max_delay_ms |
int |
200 |
Upper bound on the adaptive playout depth — the most latency it may add to absorb jitter. |
rtp_inactivity_timeout |
float |
30.0 |
Seconds of RTP silence before forcing session disconnect (0 to disable). |
X-header routing¶
The backend extracts routing metadata from SIP X-headers set by the PBX/proxy:
| X-Header | Maps to | Fallback |
|---|---|---|
X-Room-ID |
session.room_id / session.metadata["room_id"] |
Call-ID |
X-Session-ID |
session.id / session.participant_id |
Caller URI |
All X-headers are available in session.metadata["x_headers"] as a dict.
Kamailio example — adding X-headers before forwarding to roomkit:
# kamailio.cfg
route[FORWARD_TO_ROOMKIT] {
append_hf("X-Room-ID: $var(room_id)\r\n");
append_hf("X-Session-ID: $ci\r\n");
append_hf("X-Tenant-ID: $var(tenant)\r\n");
t_relay("udp:10.0.0.5:5060");
}
Authentication¶
Inbound INVITEs can be challenged with RFC 2617 digest authentication. The backend supports two ways to configure credentials, and they can be combined.
Static auth_users dict¶
Pass a username → password mapping at construction. Every INVITE without valid credentials gets a 401 challenge; on retry, the response is validated against the dict.
backend = SIPVoiceBackend(
local_sip_addr=("0.0.0.0", 5060),
local_rtp_ip="10.0.0.5",
rtp_port_start=10000,
auth_users={"6001": "secret123", "agent": "s3cret"},
auth_realm="mycompany.com", # appears in the WWW-Authenticate header
)
Best for single-tenant deployments where the credential set is small and known at startup.
Runtime resolver — set_auth_resolver()¶
For multi-tenant or large credential stores, install a callback that looks up the password on demand. The resolver is consulted on every authenticated INVITE, so credentials can be added, rotated, or revoked without restarting the backend.
def lookup_password(username: str) -> str | None:
"""Look up the password for a SIP username — None denies the call."""
row = inbound_credentials_cache.get(username) # in-memory cache populated from DB
return row["password"] if row else None
backend.set_auth_resolver(lookup_password)
Key properties:
- Resolver wins over the static dict. When both are configured, the resolver is consulted first; the dict is only used as fallback when the resolver returns
None. - Synchronous. The resolver runs inside the SIP message loop, so back it with an in-memory cache when the source of truth is remote (database, secret manager). Refresh the cache on credential changes.
- Exceptions are caught. A raising resolver is treated as denial — the call is rejected with 403, not propagated up. A buggy lookup callback can't crash the SIP message loop.
set_auth_resolver(None)removes a previously installed resolver and falls back to the dict (or disables auth entirely ifauth_usersis also unset).
Pair the resolver with set_auth_resolver(None) + auth_users=None to dynamically enable/disable auth at runtime.
Multi-tenant pattern¶
In a SaaS deployment where each tenant has their own SIP trunk credentials, the resolver becomes the routing primitive — once a username authenticates, it tells you which tenant owns the call:
# At startup: build a per-process credential cache from the DB.
credentials: dict[str, str] = {} # username → password
tenant_by_username: dict[str, str] = {} # username → tenant_id
async def refresh_credentials() -> None:
rows = await db.fetch(
"SELECT username, password, tenant_id FROM sip_trunks WHERE auth_enabled"
)
credentials.clear()
tenant_by_username.clear()
for row in rows:
credentials[row["username"]] = row["password"]
tenant_by_username[row["username"]] = row["tenant_id"]
await refresh_credentials()
backend.set_auth_resolver(lambda u: credentials.get(u))
# Re-call refresh_credentials() after any DB write that changes credentials —
# the next INVITE picks up the new state.
@backend.on_call
async def handle_call(session):
# Once authenticated, session.metadata["caller_user"] is the username
# that just satisfied the digest challenge — look up the tenant.
username = session.metadata.get("caller_user")
tenant_id = tenant_by_username.get(username)
await route_to_tenant(tenant_id, session)
This pattern lets the application own credential storage entirely — no need to hold every tenant's secrets in SIPVoiceBackend's constructor argument, and no restart required when a tenant onboards.
Empty-dict gotcha (fixed in this release)¶
If you want to start with no credentials and add them later (via mutation or a resolver), pass auth_users=None (the default) — not auth_users={}. The auth gate (backend.has_auth()) returns False for both None and {} until a credential source is actually populated. This guards against accidentally enabling auth challenges before any credentials exist.
Realm¶
auth_realm (default "roomkit") is the value sent in the WWW-Authenticate header. Most carriers don't care what it says — they sign the digest with whatever realm they receive — so a single global realm is usually fine. Use a per-deployment realm only when your carrier requires it.
Pre-accept rejection — set_invite_filter()¶
By default the SIP backend auto-accepts any INVITE that passes auth, sends 200 OK, and then fires the on_call callback. Applications that want to reject calls based on routing rules (DID not provisioned, tenant not authorized, outside business hours, etc.) can do so from on_call by calling backend.disconnect(session) — but the carrier will already have seen 200 OK and the call appears in CDRs as briefly answered.
set_invite_filter installs a hook that runs inside _handle_invite before 200 OK. The filter receives the IncomingCall, returns None to accept (proceed to SDP and 200 OK) or a (status, reason) tuple to reject with that 4xx/5xx response. The carrier never sees 200 OK on a rejection.
async def my_invite_filter(call) -> tuple[int, str] | None:
"""Accept calls only for provisioned DIDs."""
callee = call.callee # e.g. "sip:8888@my.host"
did = callee.split("@", 1)[0].split(":", 1)[-1]
route = await db.find_did_route(did)
if route is None:
return (404, f"Number {did} Not Found")
if route.tenant_disabled:
return (403, "Forbidden")
return None # accept
backend.set_invite_filter(my_invite_filter)
Key properties:
- Runs after auth. A filter receiving the call can trust that any digest authentication has already succeeded. The authenticated SIP username is available via
call.invite.get_header("Authorization")(parse withaiosipua.parse_auth) or, less safely, viacall.invite.from_addr.uri.user. - Sync or async. The dispatcher detects coroutine functions and awaits them. Async filters run inside the SIP message dispatch task — keep DB / network calls fast (the dialog is half-set-up while the filter runs).
- Exception-safe. A raising filter is caught and treated as a
500 Server Internal Errorrejection. A buggy lookup callback can't crash the SIP message loop or affect other in-flight INVITEs. - Choose appropriate status codes for the rejection:
403 Forbidden(no permission),404 Not Found(no route),488 Not Acceptable Here(codec/SDP),486 Busy Here, etc. The reason phrase appears in theSIP/2.0 4xx <reason>line so keep it generic — carrier and CDR fields can see it. Internal identifiers (tenant UUIDs, agent IDs) should not appear in the reason text. set_invite_filter(None)removes a previously installed filter and reverts to the default auto-accept behavior.
Callbacks¶
The SIP backend provides two additional callbacks beyond the standard VoiceBackend interface:
on_call(callback)¶
Fired after an incoming INVITE is accepted and the RTP session is active. This is where you route the session to a room:
async def handle_call(session: VoiceSession):
room_id = session.metadata.get("room_id", session.id)
await kit.create_room(room_id=room_id)
await kit.attach_channel(room_id, "voice")
# Push model: pass the SIP-created session to join()
await kit.join(room_id, "voice", session=session)
backend.on_call(handle_call)
on_call_disconnected(callback)¶
Fired when the remote party sends BYE:
async def handle_disconnect(session: VoiceSession):
# Previously disconnect_voice() + close_room(), now unified as leave()
await kit.leave(session)
await kit.close_room(session.room_id)
backend.on_call_disconnected(handle_disconnect)
Standard callbacks¶
| Callback | Description |
|---|---|
on_audio_received(cb) |
Raw inbound audio frames from RTP. |
on_barge_in(cb) |
Barge-in detection (user speaks during TTS). |
on_dtmf_received(cb) |
RFC 4733 DTMF digits with duration. |
Connecting sessions to rooms¶
Unlike other backends where you call kit.join() (pull model) to create a session, SIP sessions are created automatically during INVITE handling. Use kit.join() with the push model to bind the pre-created session:
Disconnecting¶
Call backend.disconnect(session) to hang up from the server side. This sends a SIP BYE to the remote party and closes the RTP session:
DTMF¶
Inbound (receiving)¶
DTMF works the same as the RTP backend — digits arrive out-of-band via RFC 4733 and integrate with the hook system:
@kit.hook(HookTrigger.ON_DTMF, execution=HookExecution.ASYNC)
async def on_dtmf(event, ctx):
print(f"DTMF digit: {event.digit}, duration: {event.duration_ms}ms")
Outbound (sending)¶
You can send DTMF digits into an active call via VoiceChannel.send_dtmf(). This is essential for AI agents navigating IVR menus, entering PINs, or interacting with phone systems:
# Send a single digit
voice.send_dtmf(session, "1")
# Send with custom duration (ms)
voice.send_dtmf(session, "#", duration_ms=250)
# Valid digits: 0-9, *, #, A-D
Digits are sent as RFC 4733 telephone-events (out-of-band). See the examples/voice_sip_dtmf.py example for a complete AI agent that navigates an IVR menu using tool calling.
Capabilities¶
| Capability | Description |
|---|---|
DTMF_SIGNALING |
DTMF digits sent and received out-of-band via RFC 4733. |
INTERRUPTION |
Outbound audio playback can be cancelled mid-stream (barge-in). |
Audio flow¶
Inbound¶
Remote → RTP packets → aiortp decode → PCM-16 LE
→ AudioFrame(sample_rate=8000, channels=1, sample_width=2)
→ on_audio_received → AudioPipeline inbound chain
Outbound¶
TTS → AudioChunk stream or bytes → PCM-16 LE
→ 20ms RTP frames (160 samples at 8kHz)
→ CallSession.send_audio_pcm → aiortp encode → RTP packets → remote
RTP port allocation¶
The backend allocates RTP ports sequentially in pairs (RTP + RTCP) starting at rtp_port_start. When the range is exhausted, it wraps around to the start. Each call uses one port pair.
For production, ensure your firewall allows UDP traffic on the configured port range.
Jitter buffer tuning¶
The SIP backend uses a packet-level jitter buffer in the RTP bridge to smooth out network timing variations. The defaults are tuned for low-latency voice AI (start playout immediately, tolerate small jitter), but you can adjust them for different network conditions:
# Lossy / high-jitter network — larger buffer, pre-fill before playout
backend = SIPVoiceBackend(
local_sip_addr=("0.0.0.0", 5060),
local_rtp_ip="10.0.0.5",
rtp_port_start=10000,
jitter_capacity=64, # ~1.3 s buffer
jitter_prefetch=4, # wait for 4 packets (~80 ms) before playout
skip_audio_gaps=False, # wait for late packets instead of skipping
)
# Ultra-low latency (LAN / localhost)
backend = SIPVoiceBackend(
local_sip_addr=("0.0.0.0", 5060),
local_rtp_ip="10.0.0.5",
rtp_port_start=10000,
jitter_capacity=8, # minimal buffer
jitter_prefetch=0, # start immediately
)
| Parameter | Effect of increasing | Trade-off |
|---|---|---|
jitter_capacity |
Absorbs larger bursts of delayed packets | Higher memory usage; stale packets stay buffered longer |
jitter_prefetch |
Smoother playout start, fewer underruns | Adds fixed latency before audio begins |
Packet loss concealment¶
With skip_audio_gaps=True (the default), the jitter buffer confirms losses by
sequence-number analysis and skips them instead of stalling. On its own that
compresses the delivered timeline: a lost 20 ms packet means 20 ms of audio
simply missing — recordings shorten and AEC reference alignment drifts.
plc=True (the default) repairs this at the transport layer. Confirmed-lost
packets are replaced with concealment PCM before delivery to the pipeline:
- Opus — native libopus PLC (the decoder synthesizes plausible audio from its internal state)
- G.711 / G.722 / L16 — generic concealer: the last received frame is repeated with a linear fade to silence over 60 ms; longer bursts are filled with silence to preserve timeline alignment
Concealment is transparent to the pipeline — recorder, AEC, and STT receive a temporally continuous stream and need no loss awareness. Sender pauses (RFC 4733 DTMF, VAD suppression) never trigger concealment: only sequence-number gaps count as loss.
backend = SIPVoiceBackend(
local_sip_addr=("0.0.0.0", 5060),
local_rtp_ip="10.0.0.5",
plc=True, # default — set False to skip lost audio silently
)
Diagnostics: the periodic per-session stats line (DEBUG) and the final stats
line on hangup (INFO) both include concealed=N — the number of lost packets
replaced by concealment. See examples/voice_sip_packet_loss.py for a
runnable demo.
Requires aiortp >= 0.5.0 and aiosipua >= 0.4.2 (0.5.0 also accounts for
RFC 4733 telephone-events in loss detection, so DTMF digits are never
concealed).
| skip_audio_gaps (off) | Continuous audio stream with silence fill | May mask packet loss from downstream processing |
SIP vs RTP backend¶
| Feature | SIP backend | RTP backend |
|---|---|---|
| SIP signaling | Built-in (INVITE, BYE, CANCEL) | Not included |
| SDP negotiation | Automatic codec selection | Manual codec configuration |
| Session creation | Automatic on INVITE | Manual via connect() |
| Remote address | From SDP offer | Must be configured |
| Dependencies | aiosipua[rtp] |
aiortp |
| Use case | PBX/trunk integration | Direct RTP endpoints |
API Reference¶
See the SIP Backend API Reference for auto-generated class documentation.
Example¶
See examples/voice_sip.py for a complete runnable example with incoming call handling and cleanup.