Skip to content
2 changes: 2 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Set `SIP_AOR` to your SIP address-of-record.

::: voip.mcp.say

::: voip.mcp.send_fax

::: voip.mcp.call

[cc-mcp]: https://docs.anthropic.com/en/docs/claude-code/mcp
Expand Down
1 change: 1 addition & 0 deletions docs/rfc_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
| [RFC 7983](https://datatracker.ietf.org/doc/html/rfc7983) | Multiplexing Scheme Updates for SRTP Extension for DTLS | Complete | First-byte demultiplexing of STUN vs. RTP/SRTP |
| [RFC 7587](https://datatracker.ietf.org/doc/html/rfc7587) | RTP Payload Format for the Opus Speech and Audio Codec | Complete | Dynamic payload type 111 |
| [RFC 3533](https://datatracker.ietf.org/doc/html/rfc3533) | The Ogg Encapsulation Format Version 0 | Partial | Minimal Ogg page writer for Opus audio export |
| [RFC 3362](https://datatracker.ietf.org/doc/html/rfc3362) | Real-time Facsimile (T.38) — image/t38 MIME Sub-type Registration | Complete | T.38 over SIP/UDPTL |
| [RFC 4733](https://datatracker.ietf.org/doc/html/rfc4733) | RTP Payload for DTMF Digits, Telephony Tones, and Telephony Signals | Planned | In-band DTMF over RTP |

## IVR and Application Services
Expand Down
15 changes: 14 additions & 1 deletion docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
[Session][voip.rtp.Session] and its subclasses handle the media exchange between call parties.
They are created by the [Dialog][voip.sip.Dialog] when a call is accepted or initiated.

Sessions can be audio, video, and more. However, this library currently only provides audio sessions via the [AudioCall][voip.audio.AudioCall] class. Video and other media types are fairly uncommon outside of consumer applications, and implementing them is on the roadmap but not yet a priority.
Sessions can be audio, video, and more. This library provides audio sessions via
[AudioCall][voip.audio.AudioCall] and T.38 FAX sessions via [FaxSession][voip.fax.FaxSession].

::: voip.rtp.Session

Expand All @@ -22,3 +23,15 @@ Sessions can be audio, video, and more. However, this library currently only pro
::: voip.ai.AgentCall

::: voip.ai.SayCall

## FAX (T.38)
Comment thread
codingjoe marked this conversation as resolved.

Implements [RFC 3362] for T.38 fax over SIP/UDPTL.

::: voip.fax.FaxSession

::: voip.fax.OutboundFaxSession

::: voip.fax.InboundFaxSession

[rfc 3362]: https://datatracker.ietf.org/doc/html/rfc3362
106 changes: 106 additions & 0 deletions tests/sdp/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,3 +759,109 @@ def test_sample_rate__unknown_dynamic_pt__returns_none(self):
"""Dynamic PTs without an a=rtpmap have sample_rate=None (no RFC 3551 default)."""
f = RTPPayloadFormat(payload_type=99)
assert f.sample_rate is None


class TestRTPPayloadFormatPostInit:
def test_post_init__str_payload_type__skips_static_defaults(self) -> None:
"""String payload types (e.g. 't38') skip StaticPayloadType defaults."""
f = RTPPayloadFormat(payload_type="t38")
assert f.encoding_name is None
assert f.sample_rate is None


class TestRTPPayloadFormatFrameSize:
def test_frame_size__static_pcmu(self) -> None:
"""frame_size returns the RFC 3551 frame size for PCMU (PT 0)."""
assert RTPPayloadFormat(payload_type=0).frame_size == 160

def test_frame_size__dynamic_with_sample_rate(self) -> None:
"""frame_size is derived from sample_rate for dynamic payload types."""
f = RTPPayloadFormat(
payload_type=111, encoding_name="opus", sample_rate=48_000, channels=2
)
assert f.frame_size == 48_000 * 20 // 1000

def test_frame_size__dynamic_without_sample_rate__uses_8000_default(self) -> None:
"""frame_size falls back to 8000 Hz when sample_rate is None."""
f = RTPPayloadFormat(payload_type=99)
assert f.frame_size == 8_000 * 20 // 1000

def test_frame_size__str_payload_type__uses_sample_rate_fallback(self) -> None:
"""String payload types bypass StaticPayloadType and use sample_rate fallback."""
f = RTPPayloadFormat(payload_type="t38")
assert f.frame_size == 8_000 * 20 // 1000


class TestMediaDescriptionGetFormatNonInt:
def test_get_format__non_numeric_string__matches_str_payload_type(self) -> None:
"""get_format with a non-numeric string matches str payload types."""
media = MediaDescription(
media="image",
port=0,
proto="udptl",
fmt=[RTPPayloadFormat(payload_type="t38")],
)
assert media.get_format("t38") is not None

def test_get_format__non_numeric_string__returns_none_when_not_found(self) -> None:
"""get_format returns None for an unrecognised non-numeric string."""
media = MediaDescription(
media="image",
port=0,
proto="udptl",
fmt=[RTPPayloadFormat(payload_type="t38")],
)
assert media.get_format("vp8") is None


class TestMediaDescriptionApplyAttributeEdgeCases:
def test_apply_attribute__preserves_existing_fmtp_on_rtpmap(self) -> None:
"""Applying a=rtpmap preserves an already-set fmtp on the format."""
media = MediaDescription(
media="audio",
port=0,
proto="RTP/AVP",
fmt=[
RTPPayloadFormat(
payload_type=111, fmtp="minptime=10", sample_rate=48_000
)
],
)
media.apply_attribute(Attribute(name="rtpmap", value="111 opus/48000/2"))
assert media.fmt[0].fmtp == "minptime=10"

def test_apply_attribute__fmtp_non_numeric_pt__returns_false(self) -> None:
"""apply_attribute returns False for a=fmtp with a non-numeric payload type."""
media = MediaDescription(
media="image",
port=0,
proto="udptl",
fmt=[RTPPayloadFormat(payload_type="t38")],
)
assert not media.apply_attribute(
Attribute(name="fmtp", value="t38 T38FaxVersion=0")
)


class TestMediaDescriptionParseEdgeCases:
def test_parse__with_title(self) -> None:
"""parse() populates title from an i= line."""
media = MediaDescription.parse("audio 5004 RTP/AVP 0\r\ni=Voice Channel")
assert media.title == "Voice Channel"

def test_parse__with_connection(self) -> None:
"""parse() populates connection from a c= line."""
media = MediaDescription.parse("audio 5004 RTP/AVP 0\r\nc=IN IP4 192.0.2.1")
assert media.connection is not None
assert "192.0.2.1" in str(media.connection)

def test_parse__with_bandwidth(self) -> None:
"""parse() appends bandwidth entries from b= lines."""
media = MediaDescription.parse("audio 5004 RTP/AVP 0\r\nb=AS:64")
assert len(media.bandwidths) == 1

def test_parse__empty_and_no_equals_lines__skipped(self) -> None:
"""parse() skips blank lines and lines without an = sign."""
media = MediaDescription.parse("audio 5004 RTP/AVP 0\r\n\r\nNOEQUALS\r\n")
assert media.media == "audio"
assert media.port == 5004
Loading
Loading