Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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: 2 additions & 2 deletions tests/codecs/test_g722.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def _make_encoded_packets(self, packet_count: int = 3) -> list[bytes]:

encoder = av.CodecContext.create("g722", "w")
encoder.sample_rate = 16000
encoder.format = av.AudioFormat("s16")
encoder.format = av.AudioFormat("fltp")
encoder.layout = av.AudioLayout("mono")
encoder.open()
Comment thread
codingjoe marked this conversation as resolved.
t = np.linspace(
Expand All @@ -149,7 +149,7 @@ def _make_encoded_packets(self, packet_count: int = 3) -> list[bytes]:
signal = (np.sin(2 * np.pi * 440 * t) * 0.5).astype(np.float32)
pcm = np.clip(np.round(signal * 32768.0), -32768, 32767).astype(np.int16)
frame = av.AudioFrame.from_ndarray(
pcm[np.newaxis, :], format="s16", layout="mono"
pcm[np.newaxis, :], format="fltp", layout="mono"
)
frame.sample_rate = 16000
frame.pts = 0
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