Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ jobs:
os:
- ubuntu-latest
python-version:
- "3.13"
- "3.14"
runs-on: ${{ matrix.os }}
steps:
Expand All @@ -47,7 +46,6 @@ jobs:
os:
- ubuntu-latest
python-version:
- "3.13"
- "3.14"
extra:
- audio
Expand All @@ -58,6 +56,8 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
- run: uv run --group=test --extra=${{ matrix.extra }} pytest
- uses: codecov/codecov-action@v5
with:
Expand All @@ -71,12 +71,13 @@ jobs:
- windows-latest
- macos-latest
python-version:
- "3.13"
- "3.14"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
- run: uv run --group=test pytest
- uses: codecov/codecov-action@v5
with:
Expand Down
14 changes: 1 addition & 13 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ repos:
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py313-plus]
args: [--py314-plus]
- repo: https://github.com/hukkin/mdformat
rev: 1.0.0
hooks:
Expand All @@ -48,18 +48,6 @@ repos:
rev: v0.21.0
hooks:
- id: yamlfmt
- repo: local
hooks:
- id: ty
name: ty
description: An extremely fast Python type checker and language server, written in Rust.
entry: ty check
language: python
additional_dependencies: [ty, '.[cli]']
types_or: [python, pyi, jupyter]
require_serial: true
exclude: ^tests\/.*\.py$
ci:
skip:
- no-commit-to-branch
- ty
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ To run the tests, use the following command:
uv run pytest
```

Avoid mocking in your tests and instead use real dependencies to ensure that your tests are as close to real-world scenarios as possible.
You may only mock transports to avoid network IO or to mimic network counterparts.

Before your first commit, ensure that the pre-commit hooks are installed by running:

```bash
uv pre-commit install
uvx prek install
```

## Testing with Extra Dependencies
Expand Down
40 changes: 27 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,20 @@ Async VoIP Python library for the AI age.
Answer calls and transcribe them live from the terminal:

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

A simple echo server can be started with:

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

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

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

### Python API
Expand All @@ -52,29 +52,43 @@ Pass it as `call_class` when answering an incoming call:

```python
import asyncio
import dataclasses
import ssl
from voip.ai import TranscribeCall
from voip.sip.protocol import SIP
from voip.sip.types import SipUri
from voip.sip.transactions import InviteTransaction
from voip.rtp import RealtimeTransportProtocol
from faster_whisper import WhisperModel


class MyCall(TranscribeCall):
def transcription_received(self, text: str) -> None:
print(f"[{self.caller}] {text}")
@dataclasses.dataclass(kw_only=True, slots=True)
class TranscribingCall(TranscribeCall):
def transcription_received(self, text) -> None:
print(text)


class MySession(SIP):
def call_received(self, request) -> None:
asyncio.create_task(self.answer(request=request, call_class=MyCall))
class TranscribeInviteTransaction(InviteTransaction):
def invite_received(self, request) -> None:
self.ringing()
self.answer(
call_class=TranscribingCall,
stt_model=WhisperModel("kyutai/stt-1b-en_fr-trfs", device="cuda"),
)


async def main():
loop = asyncio.get_running_loop()
_, rtp_protocol = await loop.create_datagram_endpoint(
RealtimeTransportProtocol,
local_addr=("0.0.0.0", 0),
)
ssl_context = ssl.create_default_context()
await loop.create_connection(
lambda: MySession(
aor="sips:alice@example.com",
username="alice",
password="secret", # noqa: S106
lambda: SIP(
rtp=rtp_protocol,
aor=SipUri.parse("sips:alice:********@example.com"),
transaction_class=TranscribeInviteTransaction,
),
host="sip.example.com",
port=5061,
Expand Down
8 changes: 4 additions & 4 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,14 @@ class GreetingCall(AudioCall):

## Low-Level RTP Packet Handling

For protocols other than audio, subclass \[`RTPCall`\][voip.rtp.RTPCall]
directly and override \[`packet_received`\]\[voip.rtp.RTPCall.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 RTPCall, RTPPacket
from voip.rtp import Session, RTPPacket


class EchoCall(RTPCall):
class EchoCall(Session):
def packet_received(self, packet: RTPPacket, addr: tuple[str, int]) -> None:
# Echo every packet straight back to the sender.
self.send_packet(packet, addr)
Expand Down
4 changes: 2 additions & 2 deletions docs/calls.md → docs/sessions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Call legs
# Multimedia Dessions / Call Leg Handlers

[RTPCall][voip.rtp.RTPCall] is the base class for all call leg handlers.
[Session][voip.rtp.Session] is the base class for all call leg handlers.

## Audio Handling

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

::: voip.sip

## Transactions

::: voip.sip.transactions.InviteTransaction

::: voip.sip.transactions.RegistrationTransaction
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ nav:
- Feature Roadmap: feature_roadmap.md
- RFC Implementation Status: rfc_status.md
- API Reference:
- Calls: calls.md
- Sessions: sessions.md
- Codecs: codecs.md
- RTP: rtp.md
- SDP: sdp.md
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: AsyncIO",
"Topic :: Communications :: Internet Phone",
Expand All @@ -35,7 +34,7 @@ classifiers = [
"Topic :: System :: Networking",
"Topic :: Home Automation",
]
requires-python = ">=3.13"
requires-python = ">=3.14"
dependencies = ["cryptography"]

[project.optional-dependencies]
Expand Down
2 changes: 0 additions & 2 deletions tests/codecs/test_av.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Tests for the PyAVCodec base class (voip.codecs.av)."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
Expand Down
2 changes: 0 additions & 2 deletions tests/codecs/test_base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Tests for the RTPCodec base class (voip.codecs.base)."""

from __future__ import annotations

from unittest.mock import patch

import pytest
Expand Down
2 changes: 0 additions & 2 deletions tests/codecs/test_codecs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Tests for the voip.codecs package (voip/codecs/__init__.py)."""

from __future__ import annotations

import importlib
import sys

Expand Down
2 changes: 0 additions & 2 deletions tests/codecs/test_g722.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Tests for the G.722 codec (voip.codecs.g722)."""

from __future__ import annotations

from unittest.mock import patch

import pytest
Expand Down
2 changes: 0 additions & 2 deletions tests/codecs/test_opus.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Tests for the Opus codec (voip.codecs.opus)."""

from __future__ import annotations

from unittest.mock import patch

import pytest
Expand Down
2 changes: 0 additions & 2 deletions tests/codecs/test_pcm.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Tests for the PCMA and PCMU codecs (voip.codecs.pcma, voip.codecs.pcmu)."""

from __future__ import annotations

import pytest

np = pytest.importorskip("numpy")
Expand Down
119 changes: 119 additions & 0 deletions tests/sip/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Shared fixtures for SIP tests."""

import dataclasses
import ipaddress

import pytest
from voip.rtp import RealtimeTransportProtocol, Session
from voip.sdp.types import MediaDescription, RTPPayloadFormat
from voip.sip.protocol import SessionInitiationProtocol
from voip.sip.transactions import InviteTransaction
from voip.sip.types import SipUri
from voip.types import NetworkAddress


@dataclasses.dataclass
class FakeTransport:
"""Minimal asyncio.Transport stub that records written data."""

_local_address: tuple = ("127.0.0.1", 5061)
_peer_address: tuple = ("192.0.2.1", 5061)
_ssl: bool = True
sent: list[bytes] = dataclasses.field(default_factory=list)
closed: bool = False

def write(self, data: bytes) -> None:
"""Record outgoing data."""
self.sent.append(data)

def close(self) -> None:
"""Mark transport as closed."""
self.closed = True

def get_extra_info(self, key: str, default=None):
"""Return socket metadata."""
match key:
case "sockname":
return self._local_address
case "peername":
return self._peer_address
case "ssl_object":
return object() if self._ssl else None
case _:
return default


class CallFixture(Session):
"""Minimal Session subclass for testing codec negotiation."""

@classmethod
def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription:
"""Return the first format from the offered media."""
return MediaDescription(
media="audio",
port=5004,
proto="RTP/AVP",
fmt=remote_media.fmt[:1] or [RTPPayloadFormat.from_pt(0)],
)


@pytest.fixture
def fake_transport() -> FakeTransport:
"""Return a fresh FakeTransport with TLS."""
return FakeTransport()


@pytest.fixture
def rtp() -> RealtimeTransportProtocol:
"""Return a RealtimeTransportProtocol with a pre-set public address."""
mux = RealtimeTransportProtocol()
mux.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004)
return mux


@pytest.fixture
async def sip(
fake_transport: FakeTransport, rtp: RealtimeTransportProtocol
) -> SessionInitiationProtocol:
"""Return a connected SIP session with keepalive cancelled."""
session = SessionInitiationProtocol(
aor=SipUri.parse("sips:alice:secret@example.com:5061"),
rtp=rtp,
transaction_class=InviteTransaction,
)
session.connection_made(fake_transport)
if session.keepalive_task is not None:
session.keepalive_task.cancel()
session.keepalive_task = None
return session


#: A minimal incoming INVITE request as raw bytes.
INVITE_BYTES = (
b"INVITE sip:alice@example.com SIP/2.0\r\n"
b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n"
b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n"
b"To: sip:alice@example.com\r\n"
b"Call-ID: test-call-id@biloxi.com\r\n"
b"CSeq: 1 INVITE\r\n"
b"\r\n"
)

#: INVITE bytes that include an SDP body with audio media.
INVITE_WITH_SDP_BYTES = (
b"INVITE sip:alice@example.com SIP/2.0\r\n"
b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKsdp456\r\n"
b"From: sip:bob@biloxi.com;tag=from-tag-2\r\n"
b"To: sip:alice@example.com\r\n"
b"Call-ID: test-call-id-sdp@biloxi.com\r\n"
b"CSeq: 1 INVITE\r\n"
b"Content-Type: application/sdp\r\n"
b"\r\n"
b"v=0\r\n"
b"o=- 1 1 IN IP4 192.0.2.1\r\n"
b"s=-\r\n"
b"c=IN IP4 192.0.2.1\r\n"
b"t=0 0\r\n"
b"m=audio 5004 RTP/AVP 0\r\n"
b"a=rtpmap:0 PCMU/8000\r\n"
)
Loading
Loading