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
1 change: 1 addition & 0 deletions CLAUDE.md
25 changes: 12 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,21 @@ Check your ISP's documentation or router for details.
You will need a SIP AOR (URI), which looks like this:

```INI
sip:USER:PASSWORD@SIP_SERVER;transport=TCP
sip:USER:PASSWORD@SIP_SERVER
```

> [!NOTE]
> This library uses secure defaults (TLS transport on port 5061).
> However, most SIP servers only support unencrypted connections.
> Therefore, you will need to provide an explict transport parameter.
> This library defaults to **UDP transport on port 5060** for `sip:` URIs, which
> is the most widely supported configuration. To use TCP or TLS, add an explicit
> `transport` parameter, e.g. `sip:user@host;transport=TCP` or
> `sips:user@host` (SIPS always uses TLS on port 5061).

### CLI

A simple echo call can be started with:

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

Each command supports an optional `--dial` argument to initiate an
Expand All @@ -52,13 +53,13 @@ outbound call instead of waiting for an inbound one.
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."
uvx 'voip[cli]' sip sip:alice:********@sip.example.com say sip:+15551234567@sip.example.com "Your package has arrived."
```

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

```console
uvx 'voip[cli]' sip sips:alice:********@sip.example.com agent --initial-prompt "Hi, I am looking for a Mr. Ron, first name Mo?"
uvx 'voip[cli]' sip sip:alice:********@sip.example.com agent --initial-prompt "Hi, I am looking for a Mr. Ron, first name Mo?"
```

### MCP
Expand All @@ -77,7 +78,7 @@ that exposes tools to make phone calls on your behalf to any MCP client.
"mcp"
],
"env": {
"SIP_AOR": "sip:****:****@example.com:5060?transport=tcp"
"SIP_AOR": "sip:number:password@example.com"
}
}
}
Expand All @@ -96,7 +97,7 @@ Pass it as `session_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
Expand Down Expand Up @@ -126,16 +127,14 @@ async def main():
RealtimeTransportProtocol,
local_addr=("0.0.0.0", 0),
)
ssl_context = ssl.create_default_context()
await loop.create_connection(
lambda: SIP(
rtp=rtp_protocol,
aor=SipURI.parse("sips:alice:********@example.com"),
aor=SipURI.parse("sip:alice:********@example.com"),
transaction_class=TranscribeInviteTransaction,
),
host="sip.example.com",
port=5061,
ssl=ssl_context,
port=5060,
)
await asyncio.Future()

Expand Down
30 changes: 11 additions & 19 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ handle each utterance after a silence gap:

```python
import asyncio
import ssl

from voip.ai import TranscribeCall
from voip.sip.dialog import Dialog
Expand All @@ -40,12 +39,11 @@ async def main():
loop = asyncio.get_running_loop()
await loop.create_connection(
lambda: SIP(
aor="sips:alice@example.com",
aor="sip:alice@example.com",
dialog_class=AutoAcceptDialog,
),
host="sip.example.com",
port=5061,
ssl=ssl.create_default_context(),
port=5060,
)
await asyncio.Future()

Expand Down Expand Up @@ -80,7 +78,6 @@ Share both heavy models across calls to avoid reloading them per call:

```python
import asyncio
import ssl

from pocket_tts import TTSModel

Expand Down Expand Up @@ -112,11 +109,10 @@ async def main():
loop = asyncio.get_running_loop()
await loop.create_connection(
lambda: MySession(
aor="sips:alice@example.com", username="alice", password="secret"
aor="sip:alice@example.com", username="alice", password="secret"
),
host="sip.example.com",
port=5061,
ssl=ssl.create_default_context(),
port=5060,
)
await asyncio.Future()

Expand Down Expand Up @@ -193,10 +189,10 @@ domain. Pass `outbound_proxy` to route all signalling through it:
from voip.sip.protocol import SIP

session = SIP(
aor="sips:alice@carrier.com",
aor="sip:alice@carrier.com",
username="alice",
password="secret",
outbound_proxy=("proxy.carrier.com", 5061),
outbound_proxy=("proxy.carrier.com", 5060),
)
```

Expand All @@ -209,7 +205,7 @@ discovery round-trip by setting `rtp_stun_server_address=None`:
from voip.sip.protocol import SIP

session = SIP(
aor="sips:alice@example.com",
aor="sip:alice@example.com",
username="alice",
password="secret",
rtp_stun_server_address=None,
Expand All @@ -230,7 +226,6 @@ the call class when you want to terminate:

```python
import asyncio
import ssl

import numpy as np

Expand Down Expand Up @@ -263,13 +258,12 @@ async def main():
loop = asyncio.get_running_loop()
await loop.create_connection(
lambda: MySession(
aor="sips:alice@example.com",
aor="sip:alice@example.com",
username="alice",
password="secret",
),
host="sip.example.com",
port=5061,
ssl=ssl.create_default_context(),
port=5060,
)
await asyncio.Future()

Expand All @@ -292,7 +286,6 @@ Create a [Dialog][voip.sip.Dialog] subclass, set it as

```python
import asyncio
import ssl

from voip.audio import AudioCall
from voip.sip import SipURI
Expand Down Expand Up @@ -327,13 +320,12 @@ async def main():
loop = asyncio.get_running_loop()
await loop.create_connection(
lambda: MySession(
aor="sips:alice@carrier.com",
aor="sip:alice@carrier.com",
username="alice",
password="secret",
),
host="sip.carrier.com",
port=5061,
ssl=ssl.create_default_context(),
port=5060,
)
await asyncio.Future()

Expand Down
2 changes: 1 addition & 1 deletion docs/feature_roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### SIP Signalling

SIP User Agent Client (UAC) over TLS/TCP ([RFC 3261]). Handles incoming
SIP User Agent Client (UAC) over TLS/TCP/UDP ([RFC 3261]). Handles incoming

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MPC isn't mentioned here yet.

`INVITE`, `BYE`, `ACK`, `CANCEL`, and `OPTIONS` requests, carrier
`REGISTER` with digest authentication ([RFC 8760]: MD5, SHA-256,
SHA-512/256), and double-CRLF keepalive ping/pong ([RFC 5626 §4.4.1]).
Expand Down
2 changes: 1 addition & 1 deletion docs/rfc_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

| RFC | Title | Status | Notes |
| --------------------------------------------------------- | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [RFC 3261](https://datatracker.ietf.org/doc/html/rfc3261) | SIP: Session Initiation Protocol | Partial | UAC only; REGISTER, INVITE, BYE, and digest authentication over TLS/TCP |
| [RFC 3261](https://datatracker.ietf.org/doc/html/rfc3261) | SIP: Session Initiation Protocol | Partial | UAC only; REGISTER, INVITE, BYE, and digest authentication over TLS/TCP/UDP |
| [RFC 5626](https://datatracker.ietf.org/doc/html/rfc5626) | Managing Client-Initiated Connections in SIP | Complete | Double-CRLF keepalive ping/pong (§4.4.1); client keepalive task; `Supported: outbound` and `;ob` Contact parameter (§5); reconnect with exponential back-off |
| [RFC 8760](https://datatracker.ietf.org/doc/html/rfc8760) | SIP Digest Authentication Using AES-HMAC-SHA2 | Complete | MD5, SHA-256, and SHA-512/256 digest responses |
| [RFC 3824](https://datatracker.ietf.org/doc/html/rfc3824) | Using E.164 Numbers with SIP | Planned | Phone number mapping into SIP/ENUM |
Expand Down
4 changes: 3 additions & 1 deletion docs/sip.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
::: voip.sip.SessionInitiationProtocol
options:
heading_level: 2
members: false
members:
- run
- serve

## Types

Expand Down
130 changes: 120 additions & 10 deletions tests/codecs/test_opus.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
np = pytest.importorskip("numpy")
av = pytest.importorskip("av")

from voip.codecs.opus import Opus # noqa: E402
from voip.codecs.opus import Opus, OpusDecoder # noqa: E402


class TestOggCRC32:
Expand Down Expand Up @@ -92,6 +92,18 @@ def test_decode__real_decode_returns_float32(self):
result = Opus.decode(sample, 16000)
assert result.dtype == np.float32

def test_decode__real_decode_not_empty(self):
"""Decode produces non-empty audio for a non-empty Opus packet.

Regression test: a too-large OpusHead pre-skip combined with a zero
granule position previously discarded all decoded samples, yielding
an empty array and silent calls.
"""
rng = np.random.default_rng(0)
sample = Opus.encode(rng.uniform(-0.3, 0.3, 960).astype(np.float32))
result = Opus.decode(sample, 16000)
assert result.size > 0


class TestOpusEncode:
def test_encode__returns_bytes(self):
Expand All @@ -100,15 +112,57 @@ def test_encode__returns_bytes(self):
assert isinstance(result, bytes)
assert len(result) > 0

def test_encode__uses_libopus_codec(self):
"""Encode delegates to encode_pcm with libopus codec name."""
with patch.object(Opus, "encode_pcm", return_value=b"encoded") as mock_enc:
Opus.encode(np.zeros(960, dtype=np.float32))
mock_enc.assert_called_once_with(
pytest.approx(np.zeros(960, dtype=np.float32)),
"libopus",
Opus.sample_rate_hz,
)
def test_encode__produces_single_opus_frame(self):
"""Encode produces exactly one Code-0 Opus frame per 960-sample chunk.

Regression test: the previous implementation concatenated two raw Opus
frames (one from `codec.encode(frame)` and one from the flush
`codec.encode(None)`) into a single RTP payload. A remote decoder
receiving such a payload sees Code-0 (single frame) in the TOC byte
and tries to decode the entire concatenated blob as one frame, which is
malformed — causing silence on outbound Opus echo calls.
"""
rng = np.random.default_rng(0)
result = Opus.encode(rng.uniform(-0.3, 0.3, 960).astype(np.float32))
# Code-0 single-frame payload: TOC byte only, rest is frame data.
# Verify size is consistent with a single 20 ms Opus frame (not double).
assert (result[0] & 0x03) == 0 # TOC code bits: 0 = single frame
# A correctly encoded single 20 ms Opus frame is well under 1200 bytes.
# Two concatenated frames from the old code would be ~500+ bytes for noise.
# Silence is highly compressed; noise at 0.3 amplitude is a better bound.
assert len(result) < 1200


class TestOpusPacketize:
def test_packetize__yields_single_frame_packets(self):
"""Packetize yields only Code-0 (single-frame) Opus packets."""
rng = np.random.default_rng(0)
audio = rng.uniform(-0.3, 0.3, 48000).astype(np.float32)
for pkt in Opus.packetize(audio):
assert (pkt[0] & 0x03) == 0

def test_packetize__frame_count(self):
"""Packetize yields exactly one packet per 20 ms frame, no flush packet.

Regression test: the previous implementation appended a flush packet
(`codec.encode(None)`) after all frames, producing N+1 RTP packets
for N frames of audio. `_dispatch_next_packet` sends every yielded
payload at a fixed 20 ms interval, so the extra packet shifted the
receiver's playback timeline by one ptime (20 ms), causing audible
timing glitches.
"""
# 5 full frames of 960 samples each → exactly 5 packets, no flush
audio = np.zeros(4800, dtype=np.float32)
assert len(list(Opus.packetize(audio))) == 5

def test_packetize__pads_partial_final_frame(self):
"""Packetize zero-pads a partial last frame to a full 960-sample frame."""
# 5 full frames + 100 extra samples → 6 frames (5 full + 1 padded), no flush
audio = np.zeros(4900, dtype=np.float32)
packets = list(Opus.packetize(audio))
assert len(packets) == 6 # 6 frames (5 full + 1 padded), no flush
for pkt in packets:
assert (pkt[0] & 0x03) == 0


class TestOpusConstants:
Expand All @@ -135,3 +189,59 @@ def test_frame_size(self):
def test_timestamp_increment(self):
"""Opus timestamp increment is 960 ticks per frame."""
assert Opus.timestamp_increment == 960


class TestOpusCreateDecoder:
def test_create_decoder__returns_opus_decoder(self):
"""create_decoder returns an OpusDecoder instance."""
decoder = Opus.create_decoder(16000)
assert isinstance(decoder, OpusDecoder)

def test_create_decoder__ignores_input_rate_hz(self):
"""create_decoder ignores input_rate_hz for API consistency."""
decoder = Opus.create_decoder(16000, input_rate_hz=8000)
assert isinstance(decoder, OpusDecoder)
assert decoder.output_rate_hz == 16000


class TestOpusDecoderDecode:
def test_decode__returns_float32(self):
"""OpusDecoder.decode produces a float32 array."""
decoder = Opus.create_decoder(16000)
payload = Opus.encode(np.zeros(960, dtype=np.float32))
result = decoder.decode(payload)
assert result.dtype == np.float32

def test_decode__non_empty_for_real_packet(self):
"""OpusDecoder.decode produces non-empty audio for a real Opus packet."""
rng = np.random.default_rng(0)
decoder = Opus.create_decoder(16000)
payload = Opus.encode(rng.uniform(-0.3, 0.3, 960).astype(np.float32))
result = decoder.decode(payload)
assert result.size > 0

def test_decode__preserves_state_across_packets(self):
"""OpusDecoder.decode produces consistent per-packet output for sequential packets.

Regression test: the previous per-packet Ogg-container decode reset the
`libopus` CELT MDCT overlap window every 20 ms, producing 50 Hz
window-boundary discontinuities heard as choppiness on echo calls.
A persistent decoder context preserves overlap state, so packets after
the first warm-up packet each produce exactly `frame_size / 3` samples
at the 16 kHz output rate.
"""
rng = np.random.default_rng(42)
decoder = Opus.create_decoder(16000)
counts = []
for _ in range(10):
payload = Opus.encode(rng.uniform(-0.3, 0.3, 960).astype(np.float32))
result = decoder.decode(payload)
counts.append(len(result))
# After the first warm-up packet all packets must produce 320 samples.
assert all(c == 320 for c in counts[1:]), f"Inconsistent counts: {counts}"

def test_decode__empty_payload_returns_empty(self):
"""OpusDecoder.decode returns an empty array for an empty payload."""
decoder = Opus.create_decoder(16000)
result = decoder.decode(b"")
assert result.size == 0
Loading