Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
7144413
Add outbound calling ablity
codingjoe Mar 29, 2026
f8d7fa7
Add proper BYE hang_up to SIP sessions with dialog cleanup
Copilot Mar 30, 2026
c946126
Fix address
codingjoe Mar 30, 2026
1d2e345
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 30, 2026
0370426
Add ByeTransaction to acknowledge outbound BYE (RFC 3261 §17.1.2)
Copilot Mar 30, 2026
c834a4e
Await BYE 200 OK in hang_up() with configurable timeout (RFC 3261 §15)
Copilot Mar 30, 2026
69807ed
Refactor: transactions awaitable, Dialog owns call lifecycle, dialog_…
Copilot Mar 31, 2026
f8c8cb3
Address code review: simplify getattr, improve BYE_ACK_TIMEOUT docstr…
Copilot Mar 31, 2026
74c05f3
Refactor
codingjoe Mar 31, 2026
0398663
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 31, 2026
5a78337
Drop tests
codingjoe Mar 31, 2026
5139dbf
Address review: Transaction inherits asyncio.Event, ByeTransaction bu…
Copilot Mar 31, 2026
727bdcf
Fix: ByeTransaction.method defaults to SIPMethod.BYE; remove method= …
Copilot Mar 31, 2026
8dfc8c6
Update docs
codingjoe Apr 1, 2026
282820b
Update code
codingjoe Apr 1, 2026
7e38b87
Update docs
codingjoe Apr 1, 2026
14b780e
Rename
codingjoe Apr 1, 2026
35110bf
Move import
codingjoe Apr 1, 2026
c74bd39
Refactor: Transaction→Future, symmetric send/receive, fix routing, de…
Copilot Apr 1, 2026
22fb921
Fix docs
codingjoe Apr 1, 2026
08169c0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 1, 2026
10f011b
Cleanup
codingjoe Apr 2, 2026
8279155
more cleanup
codingjoe Apr 2, 2026
a01aa29
Fix tests: add dialog=Dialog() to Session instantiations in test_rtp.py
Copilot Apr 2, 2026
aeb89ce
Refactor URIs
codingjoe Apr 2, 2026
67f284f
Rename
codingjoe Apr 2, 2026
7f5216c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 2, 2026
b1610c4
Support TelURI as dial target in Dialog.dial(), InviteTransaction.sen…
Copilot Apr 2, 2026
0e1df3d
Convert TelURI to SipURI in InviteTransaction.send() using AOR host
Copilot Apr 2, 2026
bf28897
fixes
codingjoe Apr 3, 2026
7010596
update docs
codingjoe Apr 3, 2026
36de403
Drop tel URi
codingjoe Apr 3, 2026
0b6ab37
Acknowledge all responses
codingjoe Apr 3, 2026
58beb31
Fix tests
codingjoe Apr 3, 2026
b499392
Fix docs
codingjoe Apr 3, 2026
3eaff47
Set pre-commit python version to 3.14
codingjoe Apr 3, 2026
35b7fd0
Address some review comments
codingjoe Apr 4, 2026
c137ce4
Use correct IPs
codingjoe Apr 4, 2026
7c96eb6
Fix address names
codingjoe Apr 4, 2026
e784760
refactor: move dialog/tx state to protocol; fix dialog tags, double A…
Copilot Apr 4, 2026
016661c
fix: improve docstring and deduplicate complete() in response_received
Copilot Apr 4, 2026
63c0705
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 6, 2026
b947eed
Rename things
codingjoe Apr 7, 2026
acb0b12
Slim down implementation
codingjoe Apr 7, 2026
92ea1bb
Fix cookbook.md examples to match current API
Copilot Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ repos:
- mdformat-footnote
- mdformat-gfm
- mdformat-gfm-alerts
- mdformat-mkdocs
- mdformat-ruff
exclude: ^\.github/agents/.*\.agent\.md$
- repo: https://github.com/astral-sh/ruff-pre-commit
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,24 @@ uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe

A simple echo server can be started with:

````console
```console
uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo
````
```

Each command supports an optional `--dial TARGET` flag to initiate an
outbound call instead of waiting for an inbound one:

```console
uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo --dial sip:+15551234567@sip.example.com
uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe --dial sip:+15551234567@sip.example.com
uvx 'voip[cli]' sip sips:alice:********@sip.example.com agent --dial sip:+15551234567@sip.example.com --initial-prompt "Hello, how can I help you?"
```

To dial a number, say a message, and hang up automatically:

```console
uvx 'voip[cli]' sip sips:alice:********@sip.example.com say sip:+15551234567@sip.example.com "Your package has arrived."
```

You can also talk to a local agent (needs [Ollama]):

Expand Down Expand Up @@ -72,7 +86,7 @@ class TranscribeInviteTransaction(InviteTransaction):
def invite_received(self, request) -> None:
self.ringing()
self.answer(
call_class=TranscribingCall,
session_class=TranscribingCall,
stt_model=WhisperModel("kyutai/stt-1b-en_fr-trfs", device="cuda"),
)

Expand Down
179 changes: 159 additions & 20 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,47 @@
# Cookbook

To build anything we need to understand two fundamental concepts: **[Dialogs][voip.sip.Dialog] and [Sessions](sessions.md).**

Dial, accept, reject, hold, transfer, etc. are part of a **dialog** between you and the remote party.

The actual audio or video exchange happens in a multimedia **session**. A session is established by a dialog.

## Call Transcription

Subclass \[`TranscribeCall`\][voip.ai.TranscribeCall] and override
\[`transcription_received`\][voip.ai.TranscribeCall.transcription_received] to
Subclass [TranscribeCall][voip.ai.TranscribeCall] and override
[transcription_received][voip.ai.TranscribeCall.transcription_received] to
handle each utterance after a silence gap:

```python
import asyncio
import ssl

from voip.ai import TranscribeCall
from voip.sip.dialog import Dialog
from voip.sip.protocol import SIP


class MyCall(TranscribeCall):
class PrintTranscribeCall(TranscribeCall):
"""Print the transcription to the console."""

def transcription_received(self, text: str) -> None:
print(f"[{self.caller}] {text}")


class MySession(SIP):
def call_received(self, request) -> None:
asyncio.create_task(self.answer(request=request, call_class=MyCall))
class AutoAcceptDialog(Dialog):
"""Accept every incoming call and transcribe it using MyCall."""

def call_received(self) -> None:
self.ringing()
self.accept(session_class=PrintTranscribeCall)


async def main():
loop = asyncio.get_running_loop()
await loop.create_connection(
lambda: MySession(
lambda: SIP(
aor="sips:alice@example.com",
username="alice",
password="secret",
dialog_class=AutoAcceptDialog,
),
host="sip.example.com",
port=5061,
Expand All @@ -44,8 +55,8 @@ asyncio.run(main())

## Sharing a Whisper Model Across Calls

Loading the model is expensive. Pass a pre-loaded
[`WhisperModel`](https://github.com/SYSTRAN/faster-whisper) instance as a
Loading the model is expensive. Pass a preloaded
[WhisperModel](https://github.com/SYSTRAN/faster-whisper) instance as a
class attribute to share it across all incoming calls:

```python
Expand All @@ -62,7 +73,7 @@ class MyCall(TranscribeCall):

## AI Call Agent

\[`AgentCall`\][voip.ai.AgentCall] extends transcription with an
[AgentCall][voip.ai.AgentCall] extends transcription with an
[Ollama](https://ollama.com/) LLM response loop and
[Pocket TTS](https://github.com/pocket-ai/pocket-tts) voice synthesis.
Share both heavy models across calls to avoid reloading them per call:
Expand All @@ -74,6 +85,7 @@ import ssl
from pocket_tts import TTSModel

from voip.ai import AgentCall
from voip.sip.dialog import Dialog
from voip.sip.protocol import SIP

shared_tts = TTSModel.load_model()
Expand All @@ -86,9 +98,14 @@ class MyCall(AgentCall):
voice = "azelma"


class MyDialog(Dialog):
def call_received(self) -> None:
self.ringing()
self.accept(session_class=MyCall)


class MySession(SIP):
def call_received(self, request) -> None:
asyncio.create_task(self.answer(request=request, call_class=MyCall))
dialog_class = MyDialog


async def main():
Expand All @@ -109,8 +126,8 @@ asyncio.run(main())

## Raw Audio Access

Subclass \[`AudioCall`\][voip.audio.AudioCall] and override
\[`audio_received`\][voip.audio.AudioCall.audio_received] to receive decoded
Subclass [AudioCall][voip.audio.AudioCall] and override
[audio_received][voip.audio.AudioCall.audio_received] to receive decoded
float32 PCM frames without transcription:

```python
Expand All @@ -134,8 +151,8 @@ class RecordCall(AudioCall):

## Sending Audio to the Caller

Use \[`_send_rtp_audio`\][voip.audio.AudioCall.\_send_rtp_audio] inside any
\[`AudioCall`\][voip.audio.AudioCall] subclass to stream float32 PCM back to
Use [\_send_rtp_audio][voip.audio.AudioCall.\_send_rtp_audio] inside any
[AudioCall][voip.audio.AudioCall] subclass to stream float32 PCM back to
the caller using the negotiated codec:

```python
Expand All @@ -154,8 +171,8 @@ class GreetingCall(AudioCall):

## Low-Level RTP Packet Handling

For protocols other than audio, subclass \[`Session`\][voip.rtp.Session]
directly and override \[`packet_received`\]\[voip.rtp.Session.packet_received\]:
For protocols other than audio, subclass [Session][voip.rtp.Session]
directly and override [packet_received]\[voip.rtp.Session.packet_received\]:

```python
from voip.rtp import Session, RTPPacket
Expand Down Expand Up @@ -198,3 +215,125 @@ session = SIP(
rtp_stun_server_address=None,
)
```

## Hanging Up a Call

Every [Session][voip.rtp.Session] subclass exposes a
[hang_up][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE
request (RFC 3261 §15) by delegating to
[Dialog.bye][voip.sip.dialog.Dialog.bye]. It deregisters the RTP
handler and awaits the 200 OK acknowledgment before returning.

Override [Dialog.call_received][voip.sip.dialog.Dialog.call_received]
to hook into the call lifecycle, and call `await self.hang_up()` from within
the call class when you want to terminate:

```python
import asyncio
import ssl

import numpy as np

from voip.audio import AudioCall
from voip.sip.messages import Dialog
from voip.sip.protocol import SIP

Comment thread
codingjoe marked this conversation as resolved.
Outdated

class OneUtteranceCall(AudioCall):
"""Hang up as soon as the first voice utterance is received."""

async def voice_received(self, audio: np.ndarray) -> None:
await self.hang_up()
# dialog.sip.close() to also shut down the SIP transport:
if self.dialog and self.dialog.sip:
self.dialog.sip.close()


class MyDialog(Dialog):
def call_received(self) -> None:
self.ringing()
self.accept(session_class=OneUtteranceCall)


class MySession(SIP):
dialog_class = MyDialog


async def main():
loop = asyncio.get_running_loop()
await loop.create_connection(
lambda: MySession(
aor="sips:alice@example.com",
username="alice",
password="secret",
),
host="sip.example.com",
port=5061,
ssl=ssl.create_default_context(),
)
await asyncio.Future()


asyncio.run(main())
```

[hang_up][voip.rtp.Session.hang_up] sends the BYE and cleans up the dialog
and RTP handler — it does **not** close the SIP transport so that the same
[SIP][voip.sip.protocol.SessionInitiationProtocol] instance can continue
handling other calls. Access `self.dialog.sip.close()` when you also want to
tear down the transport.

## Making Outbound Calls

Create a [Dialog][voip.sip.dialog.Dialog] subclass, set it as
`dialog_class` on your SIP session, and call
[dial][voip.sip.dialog.Dialog.dial] from
[on_registered]\[voip.sip.protocol.SessionInitiationProtocol.on_registered\]:

```python
import asyncio
import ssl

from voip.audio import AudioCall
from voip.sip.messages import Dialog
from voip.sip.protocol import SIP


class MyCall(AudioCall):
pass


class OutboundDialog(Dialog):
def hangup_received(self) -> None:
"""Remote party hung up — close the SIP transport."""
if self.sip:
self.sip.close()


class MySession(SIP):
dialog_class = OutboundDialog

def on_registered(self) -> None:
dialog = OutboundDialog(sip=self)
asyncio.create_task(
dialog.dial("sip:+15551234567@carrier.com", session_class=MyCall)
)


async def main():
loop = asyncio.get_running_loop()
await loop.create_connection(
lambda: MySession(
aor="sips:alice@carrier.com",
username="alice",
password="secret",
),
host="sip.carrier.com",
port=5061,
ssl=ssl.create_default_context(),
)
await asyncio.Future()


asyncio.run(main())
```
4 changes: 0 additions & 4 deletions docs/sdp.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
# Session Description Protocol (SDP)

::: voip.sdp

## Types

::: voip.sdp.types
6 changes: 5 additions & 1 deletion docs/sessions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Multimedia Dessions / Call Leg Handlers
# Multimedia Sessions / Call Leg Handlers

[Session][voip.rtp.Session] is the base class for all call leg handlers.
Each call leg is associated with a [Dialog][voip.sip.dialog.Dialog] that
carries the SIP dialog state and provides the call lifecycle hooks.

## Audio Handling

Expand All @@ -15,3 +17,5 @@
::: voip.ai.TranscribeCall

::: voip.ai.AgentCall

::: voip.ai.SayCall
6 changes: 0 additions & 6 deletions docs/sip.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
# Session Initiation Protocol (SIP)

::: voip.sip

## Transactions

::: voip.sip.transactions.InviteTransaction

::: voip.sip.transactions.RegistrationTransaction
4 changes: 2 additions & 2 deletions tests/sip/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import pytest
from voip.rtp import RealtimeTransportProtocol, Session
from voip.sdp.types import MediaDescription, RTPPayloadFormat
from voip.sip.dialog import Dialog
from voip.sip.protocol import SessionInitiationProtocol
from voip.sip.transactions import InviteTransaction
from voip.sip.types import SipUri
from voip.types import NetworkAddress

Expand Down Expand Up @@ -79,7 +79,7 @@ async def sip(
session = SessionInitiationProtocol(
aor=SipUri.parse("sips:alice:secret@example.com:5061"),
rtp=rtp,
transaction_class=InviteTransaction,
dialog_class=Dialog,
)
session.connection_made(fake_transport)
if session.keepalive_task is not None:
Expand Down
Loading
Loading