Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
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
10 changes: 7 additions & 3 deletions tests/sip/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Shared fixtures for SIP tests."""

import asyncio
import dataclasses
import ipaddress

Expand Down Expand Up @@ -64,10 +65,13 @@ def fake_transport() -> FakeTransport:


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


Expand Down
24 changes: 4 additions & 20 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,15 +392,14 @@ async def test_run__sets_connection_pool_sip(self) -> None:
aor = SipURI.parse("sip:alice@example.com")
mock_protocol = MagicMock(spec=SessionInitiationProtocol)

fn = MagicMock()
with patch.object(
SessionInitiationProtocol,
"run",
new_callable=AsyncMock,
return_value=mock_protocol,
):
with patch.object(voip.mcp.mcp, "run_async", new_callable=AsyncMock):
await run(fn, aor)
await run(aor)

assert connection_pool.sip is mock_protocol

Expand All @@ -418,7 +417,7 @@ async def test_run__calls_mcp_run_async_with_transport(self) -> None:
with patch.object(
voip.mcp.mcp, "run_async", new_callable=AsyncMock
) as mock_run:
await run(lambda: None, aor, transport="stdio")
await run(aor, transport="stdio")

mock_run.assert_awaited_once_with(transport="stdio")

Expand All @@ -434,7 +433,7 @@ async def test_run__passes_no_verify_tls(self) -> None:
return_value=mock_protocol,
) as mock_sip_run:
with patch.object(voip.mcp.mcp, "run_async", new_callable=AsyncMock):
await run(lambda: None, aor, no_verify_tls=True)
await run(aor, no_verify_tls=True)

_, kwargs = mock_sip_run.call_args
assert kwargs["no_verify_tls"] is True
Expand All @@ -452,7 +451,7 @@ async def test_run__passes_stun_server(self) -> None:
return_value=mock_protocol,
) as mock_sip_run:
with patch.object(voip.mcp.mcp, "run_async", new_callable=AsyncMock):
await run(lambda: None, aor, stun_server=stun)
await run(aor, stun_server=stun)

_, kwargs = mock_sip_run.call_args
assert kwargs["stun_server"] is stun
Expand All @@ -468,22 +467,7 @@ def test_registered_event__set_by_on_registered(self) -> None:
"""on_registered() sets registered_event so run() can unblock."""
protocol = SessionInitiationProtocol.__new__(SessionInitiationProtocol)
protocol.registered_event = asyncio.Event()
protocol.ready_callback = None

assert not protocol.registered_event.is_set()
protocol.on_registered()
assert protocol.registered_event.is_set()

def test_registered_event__ready_callback_called_after_event(self) -> None:
"""ready_callback is invoked after registered_event is set."""
call_order: list[str] = []
protocol = SessionInitiationProtocol.__new__(SessionInitiationProtocol)
protocol.registered_event = asyncio.Event()

def _cb() -> None:
call_order.append("cb" if protocol.registered_event.is_set() else "early")

protocol.ready_callback = _cb
protocol.on_registered()

assert call_order == ["cb"]
Loading
Loading