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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ SIP_PASSWORD=******** uvx 'voip[cli]' sip sips:alice@sip.example.com agent
uv add voip[audio,ai,pygments]
```

Subclass `WhisperCall` and override `transcription_received` to handle results.
Subclass `TranscribeCall` and override `transcription_received` to handle results.
Pass it as `call_class` when answering an incoming call:

```python
Expand Down
6 changes: 1 addition & 5 deletions docs/calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@

::: voip.audio.AudioCall

## Voice Activity Detection

::: voip.audio.VoiceActivityCall

## Echo Call

::: voip.audio.EchoCall

## AI / Agentic Calls
## AI Calls

::: voip.ai.TranscribeCall

Expand Down
3 changes: 1 addition & 2 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,13 @@ from pocket_tts import TTSModel
from voip.ai import AgentCall
from voip.sip.protocol import SIP


shared_tts = TTSModel.load_model()


class MyCall(AgentCall):
tts_model = shared_tts
system_prompt = "You are a friendly hotel receptionist. Keep answers brief."
ollama_model = "llama3"
llm_model = "llama3"
voice = "azelma"


Expand Down
58 changes: 31 additions & 27 deletions tests/sip/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ async def _run_answer(self, protocol, invite, fake_rtp_transport):
protocol._rtp_transport = fake_rtp_transport
# Resolve the SIP protocol's own local address (for Contact header).
protocol.local_address = ("127.0.0.1", 5061)
await protocol._answer(invite, _CodecAwareCall)
await protocol.answer(invite, call_class=_CodecAwareCall)

@pytest.mark.asyncio
async def test_answer__selects_pcma_from_offer(self, fake_rtp_transport):
Expand Down Expand Up @@ -662,7 +662,7 @@ async def test_answer__no_address_logs_error(self, caplog):
invite = self._make_invite("no-addr-answer-1")

with caplog.at_level("ERROR"):
await protocol._answer(invite, RTPCall)
await protocol.answer(invite, call_class=RTPCall)
assert "No pending INVITE found" in caplog.text
assert not protocol._sent_responses

Expand Down Expand Up @@ -1116,7 +1116,7 @@ async def test_answer__sends_200_ok(self):
protocol._rtp_transport = mock_rtp_transport
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
await protocol._answer(request, RTPCall)
await protocol.answer(request, call_class=RTPCall)
assert len(protocol._sent) == 1
response, _ = protocol._sent[0]
assert response.status_code == 200
Expand All @@ -1137,7 +1137,7 @@ async def test_answer__sdp_contains_opus_audio_line(self):
protocol._rtp_transport = mock_rtp_transport
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
await protocol._answer(request, RTPCall)
await protocol.answer(request, call_class=RTPCall)
response, _ = protocol._sent[0]
assert b"m=audio" in bytes(response.body)
assert b"RTP/SAVP 0" in bytes(response.body)
Expand Down Expand Up @@ -1183,9 +1183,9 @@ async def test_answer__rtp_avp_offer_returns_rtp_avp(self):

request = Request.parse(invite_bytes)
protocol._pending_invites.add(request.headers["Call-ID"])
AudioCall = pytest.importorskip("voip.audio.AudioCall")
AudioCall = pytest.importorskip("voip.audio").AudioCall

await protocol._answer(request, AudioCall)
await protocol.answer(request, call_class=AudioCall)
response, _ = protocol._sent[0]
body = bytes(response.body)
assert b"RTP/AVP" in body
Expand Down Expand Up @@ -1221,9 +1221,9 @@ async def test_answer__rtp_savp_offer_returns_rtp_savp(self):

request = Request.parse(invite_bytes)
protocol._pending_invites.add(request.headers["Call-ID"])
AudioCall = pytest.importorskip("voip.audio.AudioCall")
AudioCall = pytest.importorskip("voip.audio").AudioCall

await protocol._answer(request, AudioCall)
await protocol.answer(request, call_class=AudioCall)
response, _ = protocol._sent[0]
body = bytes(response.body)
assert b"RTP/SAVP" in body
Expand All @@ -1247,7 +1247,7 @@ async def test_answer__copies_dialog_headers(self):
protocol._rtp_transport = mock_rtp_transport
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
await protocol._answer(request, RTPCall)
await protocol.answer(request, call_class=RTPCall)
response, _ = protocol._sent[0]
assert response.headers["Via"] == "SIP/2.0/UDP pc33.atlanta.com"
assert response.headers["To"] == "sip:alice@atlanta.com"
Expand Down Expand Up @@ -1277,7 +1277,7 @@ def __post_init__(self) -> None:
protocol._rtp_transport = mock_rtp_transport
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
await protocol._answer(request, MyCall)
await protocol.answer(request, call_class=MyCall)
assert created == ["sip:bob@biloxi.com"]

async def test_answer__rtp_receives_audio(self):
Expand All @@ -1304,7 +1304,7 @@ def packet_received(self, packet: RTPPacket, addr) -> None:
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
try:
await protocol._answer(request, PacketCapture)
await protocol.answer(request, call_class=PacketCapture)
response, _ = protocol._sent[0]
sdp_line = next(
line
Expand Down Expand Up @@ -1352,7 +1352,7 @@ def packet_received(self, packet: RTPPacket, addr) -> None:
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
try:
await protocol._answer(request, PacketCapture)
await protocol.answer(request, call_class=PacketCapture)
response, _ = protocol._sent[0]
sdp_line = next(
line
Expand Down Expand Up @@ -1391,7 +1391,7 @@ async def test_answer__content_length_serialized(self):
protocol._rtp_transport = mock_rtp_transport
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
await protocol._answer(request, RTPCall)
await protocol.answer(request, call_class=RTPCall)
response, _ = protocol._sent[0]
serialized = bytes(response)
parsed = Message.parse(serialized)
Expand Down Expand Up @@ -1429,7 +1429,7 @@ async def test_answer__reuses_shared_rtp_socket_for_second_call(self):
body=sdp_body1,
)
protocol._pending_invites.add("call-1@test")
await protocol._answer(invite1, _MinimalCall)
await protocol.answer(invite1, call_class=_MinimalCall)
rtp_proto_1 = protocol._rtp_protocol
rtp_transport_1 = protocol._rtp_transport

Expand All @@ -1449,7 +1449,7 @@ async def test_answer__reuses_shared_rtp_socket_for_second_call(self):
body=sdp_body2,
)
protocol._pending_invites.add("call-2@test")
await protocol._answer(invite2, _MinimalCall)
await protocol.answer(invite2, call_class=_MinimalCall)

assert protocol._rtp_protocol is rtp_proto_1
assert protocol._rtp_transport is rtp_transport_1
Expand All @@ -1473,7 +1473,7 @@ async def test_answer__bye_unregisters_call_from_rtp_mux(self):
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
try:
await protocol._answer(request, RTPCall)
await protocol.answer(request, call_class=RTPCall)
assert None in mux.calls

bye = Request(
Expand Down Expand Up @@ -1510,7 +1510,7 @@ async def test_answer__logs_info(self, caplog):
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
with caplog.at_level(logging.INFO, logger="voip.sip"):
await protocol._answer(request, RTPCall)
await protocol.answer(request, call_class=RTPCall)
assert any("call_answered" in r.message for r in caplog.records)

def test_reject__sends_busy_here_by_default(self):
Expand Down Expand Up @@ -1592,26 +1592,30 @@ async def test_request_received__unsupported_method__raises(self):

async def test_answer__via_call_received__schedules_answer(self):
"""answer() is async; wrapping it in create_task from call_received works."""
answered = []

class MySIP(SIP):
class MySIP(self._CapturingSIP):
def call_received(self, request):
asyncio.create_task(
self.answer(request=request, call_class=_MinimalCall)
)

async def _answer(self, request, call_class):
answered.append((request, call_class))

protocol = MySIP(outbound_proxy=("127.0.0.1", 5060), aor="sip:test@example.com")
protocol.connection_made(MagicMock())
loop = asyncio.get_running_loop()
protocol = MySIP()
protocol.transport = make_mock_transport()
protocol.local_address = ("127.0.0.1", 5061)
mux = RealtimeTransportProtocol()
mux.public_address = loop.create_future()
mux.public_address.set_result(("127.0.0.1", 12000))
mock_rtp_transport = MagicMock()
mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000)
protocol._rtp_protocol = mux
protocol._rtp_transport = mock_rtp_transport
request = make_invite()
protocol._pending_invites.add(request.headers["Call-ID"])
protocol.call_received(request)

await asyncio.sleep(0.01)
assert len(answered) == 1
assert answered[0][1] is _MinimalCall
await asyncio.sleep(0.05)
assert len(protocol._sent) == 1


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading