rtpbench 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtpbench/.dockerignore +4 -0
- rtpbench/Dockerfile +12 -0
- rtpbench/__init__.py +6 -0
- rtpbench/__main__.py +3 -0
- rtpbench/agent.py +746 -0
- rtpbench/analyze.py +520 -0
- rtpbench/bargein.py +272 -0
- rtpbench/bench.py +1069 -0
- rtpbench/caller.py +781 -0
- rtpbench/capture.py +33 -0
- rtpbench/carrier/compose.yaml +68 -0
- rtpbench/carrier/opensips.cfg +226 -0
- rtpbench/carrier/tls.cfg +25 -0
- rtpbench/carrier/tls.yaml +9 -0
- rtpbench/cases.py +1989 -0
- rtpbench/check.py +369 -0
- rtpbench/cli.py +616 -0
- rtpbench/clock.py +174 -0
- rtpbench/compose.yaml +55 -0
- rtpbench/corpus/README.md +55 -0
- rtpbench/corpus/cough-c1-0.wav +0 -0
- rtpbench/corpus/cough-c1-1.wav +0 -0
- rtpbench/corpus/cough-c2-0.wav +0 -0
- rtpbench/corpus/cough-c2-1.wav +0 -0
- rtpbench/corpus/cough-man-0.wav +0 -0
- rtpbench/corpus/cough-man-1.wav +0 -0
- rtpbench/corpus/cough-short-0.wav +0 -0
- rtpbench/corpus/cough-short-1.wav +0 -0
- rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
- rtpbench/corpus/human-en-dr-0.wav +0 -0
- rtpbench/corpus/human-en-dr-1.wav +0 -0
- rtpbench/corpus/human-en-sc-0.wav +0 -0
- rtpbench/corpus/human-en-sc-1.wav +0 -0
- rtpbench/corpus/human-en-sc-2.wav +0 -0
- rtpbench/corpus/human-es-f012-0.wav +0 -0
- rtpbench/corpus/human-es-f012-1.wav +0 -0
- rtpbench/corpus/human-es-f012-2.wav +0 -0
- rtpbench/corpus/human-es-f016-0.wav +0 -0
- rtpbench/corpus/human-es-f016-1.wav +0 -0
- rtpbench/corpus/human-es-f016-2.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-00.wav +0 -0
- rtpbench/corpus/lines/en/prompt-01.wav +0 -0
- rtpbench/corpus/lines/en/prompt-02.wav +0 -0
- rtpbench/corpus/lines/en/prompt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-04.wav +0 -0
- rtpbench/corpus/lines/en/prompt-05.wav +0 -0
- rtpbench/corpus/lines/en/prompt-06.wav +0 -0
- rtpbench/corpus/lines/en/prompt-07.wav +0 -0
- rtpbench/corpus/lines/en/prompt-08.wav +0 -0
- rtpbench/corpus/lines/en/prompt-09.wav +0 -0
- rtpbench/corpus/lines/en/prompt.wav +0 -0
- rtpbench/corpus/lines/en/transfer.wav +0 -0
- rtpbench/corpus/lines/en/turn-00.wav +0 -0
- rtpbench/corpus/lines/en/turn-01.wav +0 -0
- rtpbench/corpus/lines/en/turn-02.wav +0 -0
- rtpbench/corpus/lines/en/turn-03.wav +0 -0
- rtpbench/corpus/lines/en/turn-04.wav +0 -0
- rtpbench/corpus/lines/en/turn-05.wav +0 -0
- rtpbench/corpus/lines/en/turn-06.wav +0 -0
- rtpbench/corpus/lines/en/turn-07.wav +0 -0
- rtpbench/corpus/lines/en/turn-08.wav +0 -0
- rtpbench/corpus/lines/en/turn-09.wav +0 -0
- rtpbench/corpus/lines/en/turn-10.wav +0 -0
- rtpbench/corpus/lines/en/turn-11.wav +0 -0
- rtpbench/corpus/tts-ald-bc.wav +0 -0
- rtpbench/corpus/tts-ald-long.wav +0 -0
- rtpbench/corpus/tts-ald-s03.wav +0 -0
- rtpbench/corpus/tts-ald-s07.wav +0 -0
- rtpbench/corpus/tts-ald-s11.wav +0 -0
- rtpbench/corpus/tts-cori-bc.wav +0 -0
- rtpbench/corpus/tts-cori-long.wav +0 -0
- rtpbench/corpus/tts-cori-s01.wav +0 -0
- rtpbench/corpus/tts-cori-s05.wav +0 -0
- rtpbench/corpus/tts-cori-s09.wav +0 -0
- rtpbench/corpus/tts-davefx-bc.wav +0 -0
- rtpbench/corpus/tts-davefx-long.wav +0 -0
- rtpbench/corpus/tts-davefx-s00.wav +0 -0
- rtpbench/corpus/tts-davefx-s04.wav +0 -0
- rtpbench/corpus/tts-davefx-s08.wav +0 -0
- rtpbench/corpus/tts-libritts0-bc.wav +0 -0
- rtpbench/corpus/tts-libritts0-long.wav +0 -0
- rtpbench/corpus/tts-libritts0-s02.wav +0 -0
- rtpbench/corpus/tts-libritts0-s06.wav +0 -0
- rtpbench/corpus/tts-libritts0-s10.wav +0 -0
- rtpbench/corpus/tts-libritts3-bc.wav +0 -0
- rtpbench/corpus/tts-libritts3-long.wav +0 -0
- rtpbench/corpus/tts-libritts3-s03.wav +0 -0
- rtpbench/corpus/tts-libritts3-s07.wav +0 -0
- rtpbench/corpus/tts-libritts3-s11.wav +0 -0
- rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
- rtpbench/corpus/tts-ljspeech-long.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
- rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardf-long.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
- rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardm-long.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
- rtpbench/detect.py +145 -0
- rtpbench/g711.py +82 -0
- rtpbench/mock/compose.yaml +18 -0
- rtpbench/pcap.py +106 -0
- rtpbench/progress.py +184 -0
- rtpbench/realtime.py +1015 -0
- rtpbench/report.py +450 -0
- rtpbench/rtp.py +166 -0
- rtpbench/sip.py +653 -0
- rtpbench/speech.py +100 -0
- rtpbench/speechcal.py +367 -0
- rtpbench/srtp.py +191 -0
- rtpbench/targets/aivc/aivc.ini +25 -0
- rtpbench/targets/aivc/compose.yaml +72 -0
- rtpbench/targets/aivc/constraints.txt +52 -0
- rtpbench/targets/aivc/opensips.cfg +45 -0
- rtpbench/targets/ava/ai-agent.yaml +275 -0
- rtpbench/targets/ava/ari.conf +7 -0
- rtpbench/targets/ava/compose.yaml +128 -0
- rtpbench/targets/ava/constraints.txt +75 -0
- rtpbench/targets/ava/extensions.conf +10 -0
- rtpbench/targets/ava/http.conf +4 -0
- rtpbench/targets/ava/pjsip.conf +19 -0
- rtpbench/targets/ava/websocket_client.conf +15 -0
- rtpbench/targets/livekit/agent.py +111 -0
- rtpbench/targets/livekit/compose.yaml +86 -0
- rtpbench/targets/livekit/constraints.txt +72 -0
- rtpbench/tls.py +284 -0
- rtpbench/uri/advertise.yaml +7 -0
- rtpbench/uri/network.yaml +14 -0
- rtpbench/voice.py +18 -0
- rtpbench-0.3.0.dist-info/METADATA +123 -0
- rtpbench-0.3.0.dist-info/RECORD +144 -0
- rtpbench-0.3.0.dist-info/WHEEL +4 -0
- rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
- rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
rtpbench/agent.py
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
"""Reference agent: answers each caller turn a known delay after the caller stops talking.
|
|
2
|
+
|
|
3
|
+
The delay runs from the last active sample it received (kernel arrival time plus the sample's
|
|
4
|
+
offset in its packet) to the reply's onset (tick time plus offset), so the onset is placed to
|
|
5
|
+
the sample, not to the packet. The synthetic reply's onset is its sample 0; a speech clip's is
|
|
6
|
+
its first labelled speech sample, and whatever precedes it in the clip is sent before it.
|
|
7
|
+
|
|
8
|
+
With a barge-in plan, caller speech that starts while the agent is replying is an interjection,
|
|
9
|
+
never a turn: the agent stops (hard cut) the planned delay after the interjection's first
|
|
10
|
+
active sample arrived, or carries on, as the plan says.
|
|
11
|
+
|
|
12
|
+
In a conformance case the agent answers no turns; it does what the case's `Behaviour` says.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import random
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import select
|
|
19
|
+
import socket
|
|
20
|
+
import ssl
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from rtpbench import bargein, g711, sip, speech, srtp
|
|
29
|
+
from rtpbench.clock import CLOCK
|
|
30
|
+
from rtpbench.rtp import FRAME, PTIME_NS, SAMPLE_NS, Frame, Packet, Receiver, Sender
|
|
31
|
+
from rtpbench.voice import GATE, voice
|
|
32
|
+
|
|
33
|
+
HANGOVER_NS = 200_000_000 # silence that ends a caller turn
|
|
34
|
+
DELAY_RANGE = (0.3, 1.3) # seconds; the low end leaves 100 ms after the hangover to schedule
|
|
35
|
+
RESPONSE_SECONDS = 0.8
|
|
36
|
+
# Real speech pauses for up to 0.8 s inside a turn as the gate sees it, and clips lead in by up
|
|
37
|
+
# to 0.35 s before their labelled onset.
|
|
38
|
+
SPEECH_HANGOVER_NS = 1_000_000_000
|
|
39
|
+
SPEECH_DELAY_RANGE = (1.5, 2.5)
|
|
40
|
+
|
|
41
|
+
Reply = tuple[str, list[int], list[tuple[int, int]]] # name, PCM, labelled speech spans
|
|
42
|
+
DIGITS = "0123456789*#ABCD" # RFC 4733 events 0-15
|
|
43
|
+
EVENT_END_NS = 3 * PTIME_NS # no packet of an event this long: it ended (RFC 4733 2.5.2.2)
|
|
44
|
+
# where transfers go: the `operator` service, which answers (after ringing, and says a few words)
|
|
45
|
+
# unless the INVITE carries the BUSY header, which the transferee adds for a transfer meant to fail
|
|
46
|
+
OPERATOR = "sip:operator@operator"
|
|
47
|
+
BUSY = ("X-Operator", "busy")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Behaviour:
|
|
52
|
+
"""What the reference agent does in a conformance case (see `cases`)."""
|
|
53
|
+
|
|
54
|
+
ring_s: float = 0.0 # send 180 Ringing and answer this long after the INVITE
|
|
55
|
+
cancel: bool = True # a CANCEL while ringing ends the call (487); else answer anyway
|
|
56
|
+
# a copy of the INVITE while ringing: "replay" the latest response (RFC 3261 17.2.1); "reject"
|
|
57
|
+
# it 503 without a To-tag, as a stack that takes it for another call; "ignore" it
|
|
58
|
+
copies: str = "replay"
|
|
59
|
+
greet_s: float | None = None # greet this long after sending the 200 OK
|
|
60
|
+
greeting: str = "short" # a short corpus clip, or a long one (9-15 s)
|
|
61
|
+
linger_s: float = 0.0 # keep sending RTP this long after answering BYE
|
|
62
|
+
hold: bool = True # answer a re-INVITE's direction as offered; else sendrecv, never pause
|
|
63
|
+
clock: bool = True # RTP timestamps keep time while paused; else resume where they stopped
|
|
64
|
+
dtmf_s: float | None = None # answer a digit (RFC 4733 or INFO) with speech this long after
|
|
65
|
+
events: bool = True # telephone-event in the answer when offered; else leave it out
|
|
66
|
+
info: bool = True # take a DTMF INFO (200); else 405 Method Not Allowed
|
|
67
|
+
refer_s: float | None = None # blind REFER this long after the caller's first turn ends
|
|
68
|
+
# then: "complete" answers NOTIFYs and hangs up after a final one reporting 2xx; "always"
|
|
69
|
+
# hangs up after the final one whatever it reports; "stay" answers them and stays; "early"
|
|
70
|
+
# hangs up on the 202 and answers NOTIFYs 481
|
|
71
|
+
transfer: str = "complete"
|
|
72
|
+
retake_s: float | None = None # a transfer failed (final NOTIFY not 2xx): speak this long after
|
|
73
|
+
codec: int | None = None # answer with this payload type whatever the offer
|
|
74
|
+
only: int | None = None # take this G.711 law alone: 488 to an offer, or re-offer, without it
|
|
75
|
+
timer: bool = False # take session timers (RFC 4028) with the caller as refresher
|
|
76
|
+
expire: bool = True # with timer: BYE when the session expires unrefreshed
|
|
77
|
+
reply_s: float | None = None # answer the caller's first turn with speech this long after
|
|
78
|
+
# what a carrier may send (see `cases`); each False is a way real agents fail it
|
|
79
|
+
compact: bool = True # compact header names (RFC 3261 7.3.3); else 500 to an INVITE with them
|
|
80
|
+
lenient_sdp: bool = True # ignore `a=ssrc:<id>` without an attribute; else 488
|
|
81
|
+
late_offer: bool = True # offer in the 2xx to an INVITE without SDP; else 488
|
|
82
|
+
any_ptime: bool = True # hear audio packets of any length; else 20 ms ones only
|
|
83
|
+
any_ssrc: bool = True # hear the caller from any SSRC; else from the first one only
|
|
84
|
+
refresh: bool = True # keep sending after a re-INVITE; else stop (one-way audio)
|
|
85
|
+
zero_hold: bool = True # an offer with c=0.0.0.0 holds (RFC 3264 8.4); else keep sending
|
|
86
|
+
reoffer: bool = True # offer in the 2xx to a re-INVITE without SDP; else a 2xx without SDP
|
|
87
|
+
recodec: bool = True # take a re-INVITE's other G.711 law; else answer the old one (stale)
|
|
88
|
+
tcp: bool = True # listen on TCP too (RFC 3261 18: every element MUST); else UDP only
|
|
89
|
+
busy: bool = False # 486 Busy Here to the INVITE (the operator of a failed transfer)
|
|
90
|
+
# resend our own requests (REFER, BYE) until answered (timer E, RFC 3261 17.1.2.2); else once
|
|
91
|
+
retransmit: bool = True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def speech_replies(seed: int, directory: Path | None = None, long: bool = False) -> list[Reply]:
|
|
95
|
+
"""Every WAV in `directory`, or the short corpus replies (`long`: barge-in ones), shuffled."""
|
|
96
|
+
if directory:
|
|
97
|
+
clips = [(p.stem, speech.read_wav(p)) for p in sorted(directory.glob("*.wav"))]
|
|
98
|
+
else:
|
|
99
|
+
corpus = speech.clips("tts-", "human-").items()
|
|
100
|
+
clips = [(n, pcm) for n, pcm in corpus if ("-long" in n) == long]
|
|
101
|
+
random.Random(seed).shuffle(clips)
|
|
102
|
+
return [(n, pcm, speech.label(pcm)) for n, pcm in clips]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Responder:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
pt: int,
|
|
109
|
+
seq0: int,
|
|
110
|
+
delays: list[float],
|
|
111
|
+
replies: list[Reply] | None = None,
|
|
112
|
+
hangover_ns: int = HANGOVER_NS,
|
|
113
|
+
plan: list[dict[str, Any]] | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.pt, self.seq0, self.delays, self.hangover_ns = pt, seq0, delays, hangover_ns
|
|
116
|
+
self.plan = plan
|
|
117
|
+
self.gate = g711.gate(pt, GATE)
|
|
118
|
+
self.silence = bytes([g711.SILENCE[pt]]) * FRAME
|
|
119
|
+
replies = replies or [("synthetic", voice(RESPONSE_SECONDS, f0=210), [])]
|
|
120
|
+
self.replies = [(n, g711.encode(pt, pcm), spans) for n, pcm, spans in replies]
|
|
121
|
+
self.response = b""
|
|
122
|
+
self.lock = threading.Lock()
|
|
123
|
+
self.turns: list[dict[str, Any]] = []
|
|
124
|
+
self.speaking = False
|
|
125
|
+
self.end_ns, self.end_seq, self.end_off = 0, 0, 0
|
|
126
|
+
self.pending: dict[str, Any] | None = None
|
|
127
|
+
self.pos: int | None = None # response sample index at the start of the next tick
|
|
128
|
+
self.current: dict[str, Any] = {} # the turn being replied to
|
|
129
|
+
self.interjecting = False # the caller's current burst started while we replied
|
|
130
|
+
self.cut_ns: int | None = None
|
|
131
|
+
self.sending, self.clock = True, True # not sending: on hold
|
|
132
|
+
self.skipped, self.paused = 0, True # ticks not sent; marker on the next packet
|
|
133
|
+
self.events: int | None = None # telephone-event payload type
|
|
134
|
+
self.dtmf_ns: int | None = None # reply this long after a digit
|
|
135
|
+
self.digits: list[dict[str, Any]] = []
|
|
136
|
+
self.event_ts: int | None = None # RTP timestamp of the last event that ended
|
|
137
|
+
self.event_open: tuple[int, int, str] | None = None # ts, last arrival, digit: no end yet
|
|
138
|
+
self.ends: list[int] = [] # caller turn ends, when answering no turns
|
|
139
|
+
|
|
140
|
+
def speak(self, target_ns: int, reply: int, kind: str) -> None:
|
|
141
|
+
"""Say `self.replies[reply]`, its labelled onset at `target_ns`."""
|
|
142
|
+
with self.lock:
|
|
143
|
+
turn = len(self.turns)
|
|
144
|
+
self.pending = {"turn": turn, "kind": kind, "reply": reply, "target_ns": target_ns}
|
|
145
|
+
|
|
146
|
+
def recodec(self, pt: int) -> None:
|
|
147
|
+
"""Speak and hear the other G.711 law from now on (the caller changed codec)."""
|
|
148
|
+
replies = [(n, g711.encode(pt, g711.decode(self.pt, a)), s) for n, a, s in self.replies]
|
|
149
|
+
with self.lock:
|
|
150
|
+
self.response = g711.encode(pt, g711.decode(self.pt, self.response))
|
|
151
|
+
self.pt, self.replies, self.gate = pt, replies, g711.gate(pt, GATE)
|
|
152
|
+
self.silence = bytes([g711.SILENCE[pt]]) * FRAME
|
|
153
|
+
|
|
154
|
+
def digit(self, at_ns: int, digit: str, via: str) -> None:
|
|
155
|
+
"""A digit came in (the key released): note it and, if the case says so, answer."""
|
|
156
|
+
self.digits.append({"digit": digit, "via": via, "at_ns": at_ns})
|
|
157
|
+
if self.dtmf_ns is not None:
|
|
158
|
+
reply = len(self.digits) % len(self.replies)
|
|
159
|
+
self.speak(at_ns + self.dtmf_ns, reply, "dtmf")
|
|
160
|
+
|
|
161
|
+
def on_packet(self, arrival: int, pkt: Packet) -> None:
|
|
162
|
+
if pkt.pt == self.events and len(pkt.payload) >= 4:
|
|
163
|
+
if pkt.ts == self.event_ts: # an event already taken
|
|
164
|
+
return
|
|
165
|
+
if pkt.payload[1] & 0x80: # end bit, first copy
|
|
166
|
+
self.event_ts, self.event_open = pkt.ts, None
|
|
167
|
+
self.digit(arrival, DIGITS[pkt.payload[0] % 16], "rfc4733")
|
|
168
|
+
else:
|
|
169
|
+
self.event_open = (pkt.ts, arrival, DIGITS[pkt.payload[0] % 16])
|
|
170
|
+
return
|
|
171
|
+
if pkt.pt != self.pt:
|
|
172
|
+
return
|
|
173
|
+
with self.lock:
|
|
174
|
+
if m := self.gate.search(pkt.payload[::-1]):
|
|
175
|
+
if not self.speaking and self.plan is not None:
|
|
176
|
+
self.interjecting = self.pos is not None
|
|
177
|
+
if self.interjecting and "barge" not in self.current:
|
|
178
|
+
self._interjection(arrival, pkt)
|
|
179
|
+
off = len(pkt.payload) - 1 - m.start()
|
|
180
|
+
if (end := arrival + off * SAMPLE_NS) > self.end_ns:
|
|
181
|
+
self.end_ns, self.end_seq, self.end_off = end, pkt.seq, off
|
|
182
|
+
self.speaking = True
|
|
183
|
+
elif self.speaking and arrival - self.end_ns >= self.hangover_ns:
|
|
184
|
+
self.speaking = False
|
|
185
|
+
if self.interjecting or not self.delays:
|
|
186
|
+
if not self.interjecting:
|
|
187
|
+
self.ends.append(self.end_ns)
|
|
188
|
+
self.interjecting = False
|
|
189
|
+
return
|
|
190
|
+
turn = len(self.turns) + (self.pending is not None)
|
|
191
|
+
delay_ns = round(self.delays[turn % len(self.delays)] * 1e9)
|
|
192
|
+
self.pending = {
|
|
193
|
+
"turn": turn,
|
|
194
|
+
"delay_ns": delay_ns,
|
|
195
|
+
"end_ns": self.end_ns,
|
|
196
|
+
"end_seq": self.end_seq,
|
|
197
|
+
"end_off": self.end_off,
|
|
198
|
+
"target_ns": self.end_ns + delay_ns,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def _interjection(self, arrival: int, pkt: Packet) -> None:
|
|
202
|
+
assert self.plan is not None
|
|
203
|
+
first = self.gate.search(pkt.payload)
|
|
204
|
+
assert first is not None
|
|
205
|
+
step = self.plan[self.current["turn"] % len(self.plan)]
|
|
206
|
+
trigger = arrival + first.start() * SAMPLE_NS
|
|
207
|
+
delay_ns = round(step["stop_delay_s"] * 1e9)
|
|
208
|
+
self.current["barge"] = {
|
|
209
|
+
"trigger_ns": trigger,
|
|
210
|
+
"trigger_seq": pkt.seq,
|
|
211
|
+
"trigger_off": first.start(),
|
|
212
|
+
"stop_delay_ns": delay_ns,
|
|
213
|
+
}
|
|
214
|
+
if step["stop"]:
|
|
215
|
+
self.cut_ns = trigger + delay_ns
|
|
216
|
+
|
|
217
|
+
def source(self, n: int, tick: int) -> bytes | Frame | None:
|
|
218
|
+
# an event whose end packets were all lost still happened: RFC 4733 2.5.2.2 ends it once
|
|
219
|
+
# packets stop (and extends a tone by no more than three packet interarrival times)
|
|
220
|
+
if (e := self.event_open) is not None and tick - e[1] > EVENT_END_NS:
|
|
221
|
+
self.event_ts, self.event_open = e[0], None
|
|
222
|
+
self.digit(e[1] + EVENT_END_NS, e[2], "rfc4733")
|
|
223
|
+
with self.lock:
|
|
224
|
+
out = self._audio(n - self.skipped, tick)
|
|
225
|
+
if not self.sending:
|
|
226
|
+
self.skipped += 1
|
|
227
|
+
self.paused = True
|
|
228
|
+
return None
|
|
229
|
+
marker, self.paused = self.paused, False
|
|
230
|
+
if self.clock:
|
|
231
|
+
return out # timestamp n * 160: the clock ran on while paused
|
|
232
|
+
return Frame(self.pt, out, (n - self.skipped) * FRAME, marker)
|
|
233
|
+
|
|
234
|
+
def _audio(self, n: int, tick: int) -> bytes:
|
|
235
|
+
"""Tick n's payload (under the lock); n counts packets sent, as seq does."""
|
|
236
|
+
p = self.pending
|
|
237
|
+
pick = p.get("reply", p["turn"]) if p else 0
|
|
238
|
+
name, audio, spans = self.replies[pick % len(self.replies)] if p else ("", b"", [])
|
|
239
|
+
lead = spans[0][0] if spans else 0
|
|
240
|
+
# a start in the last half sample rounds to the next tick's sample 0, not to 160
|
|
241
|
+
off = round((p["target_ns"] - tick) / SAMPLE_NS) - lead if p else FRAME
|
|
242
|
+
if self.pos is None and p is not None and off < FRAME:
|
|
243
|
+
off = max(0, off)
|
|
244
|
+
start = n * FRAME + off # stream sample index (from seq0) of the clip's sample 0
|
|
245
|
+
onset = start + lead
|
|
246
|
+
p |= {
|
|
247
|
+
"onset_seq": (self.seq0 + onset // FRAME) & 0xFFFF,
|
|
248
|
+
"onset_off": onset % FRAME,
|
|
249
|
+
"tick_ns": tick,
|
|
250
|
+
}
|
|
251
|
+
if spans:
|
|
252
|
+
p |= {
|
|
253
|
+
"clip": name,
|
|
254
|
+
"start": start,
|
|
255
|
+
"speech": [[a + start, b + start] for a, b in spans],
|
|
256
|
+
}
|
|
257
|
+
self.turns.append(p)
|
|
258
|
+
self.pending, self.pos, self.response, self.current = None, -off, audio, p
|
|
259
|
+
if self.pos is None:
|
|
260
|
+
return self.silence
|
|
261
|
+
pos, self.pos = self.pos, self.pos + FRAME
|
|
262
|
+
if self.pos >= len(self.response):
|
|
263
|
+
self.pos = None
|
|
264
|
+
head = self.silence[: max(0, -pos)]
|
|
265
|
+
out = (head + self.response[max(0, pos) : pos + FRAME]).ljust(FRAME, self.silence[:1])
|
|
266
|
+
if self.cut_ns is not None and (cut := round((self.cut_ns - tick) / SAMPLE_NS)) < FRAME:
|
|
267
|
+
cut = max(0, cut)
|
|
268
|
+
self.current["barge"]["cut"] = n * FRAME + cut # first sample not sent
|
|
269
|
+
self.pos = None
|
|
270
|
+
out = out[:cut] + self.silence[cut:]
|
|
271
|
+
if self.pos is None:
|
|
272
|
+
self.cut_ns = None
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _savp(sdp: sip.Sdp) -> bool:
|
|
277
|
+
return sdp.proto == "RTP/SAVP"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _usable(sdp: sip.Sdp) -> list[tuple[int, str, bytes]]:
|
|
281
|
+
"""The a=crypto lines of `sdp` this agent can take, in the offer's order."""
|
|
282
|
+
out = []
|
|
283
|
+
for value in sdp.crypto:
|
|
284
|
+
try:
|
|
285
|
+
out.append(srtp.parse(value))
|
|
286
|
+
except ValueError:
|
|
287
|
+
continue
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _local_ip(peer: tuple[str, int]) -> str:
|
|
292
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
293
|
+
s.connect(peer)
|
|
294
|
+
return str(s.getsockname()[0])
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Uas:
|
|
298
|
+
"""The reference agent's SIP side of one call: answers (after ringing, if the case says so),
|
|
299
|
+
resends its final response to the INVITE until ACKed, and takes CANCEL and BYE as the case
|
|
300
|
+
says. `sock`: the transport the INVITE came on (a UDP socket or `sip.Tcp`)."""
|
|
301
|
+
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
sock: socket.socket | sip.Tcp,
|
|
305
|
+
rtp: socket.socket,
|
|
306
|
+
case: Behaviour,
|
|
307
|
+
responder: Callable[[int, int], Responder],
|
|
308
|
+
greeting: int,
|
|
309
|
+
) -> None:
|
|
310
|
+
self.sock, self.rtp, self.case = sock, rtp, case
|
|
311
|
+
self.make, self.greeting = responder, greeting
|
|
312
|
+
self.to_tag = secrets.token_hex(4)
|
|
313
|
+
self.invite: sip.Message | None = None
|
|
314
|
+
self.addr = ("", 0)
|
|
315
|
+
self.last = b"" # latest response to the INVITE, resent if the INVITE is
|
|
316
|
+
self.unacked = b"" # final response to the INVITE, resent until ACKed
|
|
317
|
+
self.resend_at, self.interval = 0.0, sip.T1
|
|
318
|
+
self.answer_at: float | None = None
|
|
319
|
+
self.reinvite = "" # branch of the latest re-INVITE
|
|
320
|
+
self.origin = [secrets.randbelow(1 << 31), 0] # SDP session id, version
|
|
321
|
+
self.end_at: float | None = None
|
|
322
|
+
self.done = False
|
|
323
|
+
self.responder: Responder | None = None
|
|
324
|
+
self.pt, self.dtmf = 0, False
|
|
325
|
+
self.late = False # the INVITE had no offer: ours goes in the 2xx, theirs in the ACK
|
|
326
|
+
self.seq0 = 0
|
|
327
|
+
self.heard_ssrc: int | None = None
|
|
328
|
+
self.replied = False
|
|
329
|
+
self.dialog: sip.Dialog | None = None # for requests of our own (REFER, BYE)
|
|
330
|
+
self.refer: sip.Message | None = None
|
|
331
|
+
self.bye: sip.Message | None = None
|
|
332
|
+
self.expire_at: float | None = None # session timer: BYE then, unless refreshed
|
|
333
|
+
self.sender: Sender | None = None
|
|
334
|
+
self.threads: list[Sender | Receiver] = []
|
|
335
|
+
self.meta: dict[str, Any] = {}
|
|
336
|
+
# SRTP when the caller offers it (SDES): our master key per suite; the suite we took,
|
|
337
|
+
# what we send with and what we hear with
|
|
338
|
+
self.keys = srtp.master()
|
|
339
|
+
self.suite: str | None = None
|
|
340
|
+
self.tx: srtp.Context | None = None
|
|
341
|
+
self.rx: srtp.Context | None = None
|
|
342
|
+
|
|
343
|
+
def _send(self, resp: sip.Message, final: bool = False) -> None:
|
|
344
|
+
data = resp.render()
|
|
345
|
+
self.sock.sendto(data, self.addr)
|
|
346
|
+
if resp.cseq_method == "INVITE":
|
|
347
|
+
self.last = data
|
|
348
|
+
if final:
|
|
349
|
+
self.unacked, self.resend_at, self.interval = (
|
|
350
|
+
data,
|
|
351
|
+
time.monotonic() + sip.T1,
|
|
352
|
+
sip.T1,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def handle(
|
|
356
|
+
self, msg: sip.Message, addr: tuple[str, int], via: socket.socket | sip.Tcp | None = None
|
|
357
|
+
) -> None:
|
|
358
|
+
"""A message from `addr`, on `via` (the call's transport if None)."""
|
|
359
|
+
inv = self.invite
|
|
360
|
+
if msg.status:
|
|
361
|
+
self._response(msg)
|
|
362
|
+
return
|
|
363
|
+
if inv is None and msg.method == "INVITE":
|
|
364
|
+
self.addr, self.sock = addr, via or self.sock
|
|
365
|
+
self._invite(msg)
|
|
366
|
+
elif inv is None or msg.get("call-id") != inv.get("call-id"):
|
|
367
|
+
if msg.method not in ("ACK", "INVITE"):
|
|
368
|
+
self.sock.sendto(sip.response(msg, 481, "Call Does Not Exist").render(), addr)
|
|
369
|
+
elif msg.method == "INVITE" and msg.branch in (inv.branch, self.reinvite): # a copy
|
|
370
|
+
ringing = self.answer_at is not None and msg.branch == inv.branch
|
|
371
|
+
if ringing and self.case.copies == "reject":
|
|
372
|
+
self.sock.sendto(sip.response(msg, 503, "Service Unavailable").render(), addr)
|
|
373
|
+
elif not (ringing and self.case.copies == "ignore"):
|
|
374
|
+
self.sock.sendto(self.last, addr)
|
|
375
|
+
elif msg.method == "INVITE":
|
|
376
|
+
self._reinvite(msg)
|
|
377
|
+
elif msg.method == "ACK":
|
|
378
|
+
self.unacked = b""
|
|
379
|
+
if self.late and self.sender is None and self.dialog and msg.body:
|
|
380
|
+
answer = sip.parse_sdp(msg.body)
|
|
381
|
+
if (pt := next((p for p in answer.pts if p in g711.NAMES), self.pt)) != self.pt:
|
|
382
|
+
self.pt = pt
|
|
383
|
+
self._prepare(answer)
|
|
384
|
+
self._media(answer)
|
|
385
|
+
elif msg.body and self.suite: # the answer to our offer in a 2xx: the caller's key
|
|
386
|
+
self._crypto(sip.parse_sdp(msg.body))
|
|
387
|
+
# ACK for a 487: the call never was (one for a 503 to a copy, while ringing, is not)
|
|
388
|
+
self.done |= self.responder is None and self.answer_at is None
|
|
389
|
+
elif msg.method == "CANCEL":
|
|
390
|
+
self._send(sip.response(msg, 200, "OK", self.to_tag))
|
|
391
|
+
if self.answer_at is not None and self.case.cancel: # still ringing
|
|
392
|
+
self.answer_at = None
|
|
393
|
+
self._send(sip.response(inv, 487, "Request Terminated", self.to_tag), final=True)
|
|
394
|
+
self.end_at = time.monotonic() + 64 * sip.T1
|
|
395
|
+
elif msg.method == "BYE":
|
|
396
|
+
self._send(sip.response(msg, 200, "OK", self.to_tag))
|
|
397
|
+
self.end_at = time.monotonic() + self.case.linger_s
|
|
398
|
+
elif msg.method == "NOTIFY":
|
|
399
|
+
if self.case.transfer == "early":
|
|
400
|
+
self._send(sip.response(msg, 481, "Subscription Does Not Exist", self.to_tag))
|
|
401
|
+
return
|
|
402
|
+
self._send(sip.response(msg, 200, "OK", self.to_tag))
|
|
403
|
+
final = "terminated" in msg.get("subscription-state")
|
|
404
|
+
ok = msg.body[8:9] == b"2" # sipfrag "SIP/2.0 2xx"
|
|
405
|
+
if final and (
|
|
406
|
+
self.case.transfer == "always" or (ok and self.case.transfer == "complete")
|
|
407
|
+
):
|
|
408
|
+
self._hang_up()
|
|
409
|
+
elif final and not ok and self.case.retake_s is not None and (r := self.responder):
|
|
410
|
+
retake = round(self.case.retake_s * 1e9)
|
|
411
|
+
r.speak(CLOCK.now() + retake, (self.greeting + 1) % len(r.replies), "retake")
|
|
412
|
+
elif msg.method == "INFO" and not self.case.info: # RFC 3261 21.4.6: Allow lists ours
|
|
413
|
+
allow = [("Allow", "INVITE, ACK, CANCEL, BYE, OPTIONS, NOTIFY")]
|
|
414
|
+
self._send(sip.response(msg, 405, "Method Not Allowed", self.to_tag, extra=allow))
|
|
415
|
+
elif msg.method == "INFO" and "dtmf-relay" in msg.get("content-type").lower():
|
|
416
|
+
self._send(sip.response(msg, 200, "OK", self.to_tag))
|
|
417
|
+
body = dict(
|
|
418
|
+
line.partition("=")[::2] for line in msg.body.decode(errors="replace").split()
|
|
419
|
+
)
|
|
420
|
+
if self.responder and (signal := body.get("Signal", "").strip()):
|
|
421
|
+
self.responder.digit(CLOCK.now(), signal, "info")
|
|
422
|
+
else:
|
|
423
|
+
self._send(sip.response(msg, 200, "OK", self.to_tag))
|
|
424
|
+
|
|
425
|
+
def _response(self, msg: sip.Message) -> None:
|
|
426
|
+
if self.dialog is None:
|
|
427
|
+
return
|
|
428
|
+
self.dialog.answered(msg)
|
|
429
|
+
taken = self.refer is not None and msg.branch == self.refer.branch and msg.status == 202
|
|
430
|
+
if self.bye and msg.branch == self.bye.branch and msg.status >= 200:
|
|
431
|
+
self.end_at = time.monotonic() + (5.0 if self.case.transfer == "early" else 0.0)
|
|
432
|
+
elif taken and self.case.transfer == "early": # blind: done once the REFER is taken
|
|
433
|
+
self._hang_up()
|
|
434
|
+
|
|
435
|
+
def _hang_up(self) -> None:
|
|
436
|
+
assert self.dialog is not None
|
|
437
|
+
if self.bye is None:
|
|
438
|
+
self.bye = self.dialog.request("BYE")
|
|
439
|
+
self.dialog.send(self.bye)
|
|
440
|
+
|
|
441
|
+
def _invite(self, msg: sip.Message) -> None:
|
|
442
|
+
self.invite = msg
|
|
443
|
+
self._send(sip.response(msg, 100, "Trying"))
|
|
444
|
+
self.late = not msg.body and self.case.late_offer
|
|
445
|
+
compact = any(k.lower() in sip._COMPACT for k, _ in msg.headers)
|
|
446
|
+
malformed = re.search(rb"^a=ssrc:\d+\r?$", msg.body, re.MULTILINE)
|
|
447
|
+
if self.case.busy:
|
|
448
|
+
self._send(sip.response(msg, 486, "Busy Here", self.to_tag), final=True)
|
|
449
|
+
self.end_at = time.monotonic() + 64 * sip.T1
|
|
450
|
+
elif compact and not self.case.compact: # a parser that knows the long names only
|
|
451
|
+
self._send(sip.response(msg, 500, "Server Internal Error", self.to_tag), final=True)
|
|
452
|
+
self.end_at = time.monotonic() + 64 * sip.T1
|
|
453
|
+
elif not self.late and (
|
|
454
|
+
(malformed and not self.case.lenient_sdp)
|
|
455
|
+
or not [pt for pt in sip.parse_sdp(msg.body).pts if pt in g711.NAMES]
|
|
456
|
+
or self.case.only not in (None, *sip.parse_sdp(msg.body).pts)
|
|
457
|
+
or (_savp(sip.parse_sdp(msg.body)) and not _usable(sip.parse_sdp(msg.body)))
|
|
458
|
+
):
|
|
459
|
+
self._send(sip.response(msg, 488, "Not Acceptable Here", self.to_tag), final=True)
|
|
460
|
+
self.end_at = time.monotonic() + 64 * sip.T1
|
|
461
|
+
elif self.case.ring_s:
|
|
462
|
+
self._send(sip.response(msg, 180, "Ringing", self.to_tag))
|
|
463
|
+
self.answer_at = time.monotonic() + self.case.ring_s
|
|
464
|
+
else:
|
|
465
|
+
self._answer()
|
|
466
|
+
|
|
467
|
+
def _answer(self) -> None:
|
|
468
|
+
"""Answer with the session (or, to an INVITE without one, an offer of both laws, the
|
|
469
|
+
media starting on the ACK's answer)."""
|
|
470
|
+
assert self.invite is not None
|
|
471
|
+
offer = sip.parse_sdp(self.invite.body)
|
|
472
|
+
pt = next((pt for pt in offer.pts if pt in g711.NAMES), g711.PCMU)
|
|
473
|
+
self.pt = next(c for c in (self.case.codec, self.case.only, pt) if c is not None)
|
|
474
|
+
self.dtmf = offer.dtmf is not None and self.case.events
|
|
475
|
+
self._prepare(offer) # before the 2xx: it takes a while (encodes every clip)
|
|
476
|
+
self._send(self._ok(self.invite, "sendrecv"), final=True)
|
|
477
|
+
self.dialog = sip.Dialog.uas(
|
|
478
|
+
self.sock, self.addr, self.invite, _local_ip(self.addr), self.to_tag
|
|
479
|
+
)
|
|
480
|
+
if not self.late:
|
|
481
|
+
self._media(offer)
|
|
482
|
+
|
|
483
|
+
def _prepare(self, remote: sip.Sdp) -> None:
|
|
484
|
+
self.seq0 = secrets.randbelow(1 << 16)
|
|
485
|
+
self.responder = r = self.make(self.pt, self.seq0)
|
|
486
|
+
r.clock, r.events = self.case.clock, remote.dtmf
|
|
487
|
+
r.dtmf_ns = None if self.case.dtmf_s is None else round(self.case.dtmf_s * 1e9)
|
|
488
|
+
|
|
489
|
+
def _crypto(self, offer: sip.Sdp | None) -> list[str] | None:
|
|
490
|
+
"""a=crypto values for our SDP: the answer to an SRTP `offer` (its first suite we take,
|
|
491
|
+
or the one in use), our offer of the suite in use when there is no offer, else none."""
|
|
492
|
+
if offer is None:
|
|
493
|
+
return [srtp.line(1, self.suite, self.keys[self.suite])] if self.suite else None
|
|
494
|
+
if not _savp(offer):
|
|
495
|
+
return None
|
|
496
|
+
# the suite stays the call's: a re-INVITE without it is refused (`_reinvite`)
|
|
497
|
+
pick = next((c for c in _usable(offer) if c[1] == (self.suite or c[1])), None)
|
|
498
|
+
if pick is None:
|
|
499
|
+
return None
|
|
500
|
+
tag, suite, key = pick
|
|
501
|
+
if self.tx is None:
|
|
502
|
+
self.suite, self.tx = suite, srtp.Context(suite, self.keys[suite])
|
|
503
|
+
if self.rx is None or self.rx.master != key:
|
|
504
|
+
self.rx = srtp.Context(suite, key, self.rx.state if self.rx else None)
|
|
505
|
+
for t in self.threads:
|
|
506
|
+
if isinstance(t, Receiver):
|
|
507
|
+
t.srtp = self.rx
|
|
508
|
+
return [srtp.line(tag, suite, self.keys[suite])]
|
|
509
|
+
|
|
510
|
+
def _media(self, remote: sip.Sdp) -> None:
|
|
511
|
+
"""Start sending to and hearing the far end of the negotiated session."""
|
|
512
|
+
r = self.responder
|
|
513
|
+
assert r is not None
|
|
514
|
+
ssrc = secrets.randbelow(1 << 32)
|
|
515
|
+
start = CLOCK.now() + PTIME_NS
|
|
516
|
+
addr = (remote.ip, remote.port)
|
|
517
|
+
self.sender = Sender(
|
|
518
|
+
self.rtp, addr, self.pt, r.source, start, self.seq0, ssrc, srtp=self.tx
|
|
519
|
+
)
|
|
520
|
+
self.threads = [self.sender, Receiver(self.rtp, self._hear, srtp=self.rx)]
|
|
521
|
+
for t in self.threads:
|
|
522
|
+
t.start()
|
|
523
|
+
if self.case.greet_s is not None:
|
|
524
|
+
r.speak(CLOCK.now() + round(self.case.greet_s * 1e9), self.greeting, "greeting")
|
|
525
|
+
self.meta = {"pt": self.pt, "ssrc": ssrc, "seq0": self.seq0, "srtp": self.suite}
|
|
526
|
+
|
|
527
|
+
def _hear(self, arrival: int, pkt: Packet) -> None:
|
|
528
|
+
"""The caller's RTP, as far as this agent's receiver takes it."""
|
|
529
|
+
if pkt.pt == self.pt and len(pkt.payload) != FRAME and not self.case.any_ptime:
|
|
530
|
+
return
|
|
531
|
+
self.heard_ssrc = pkt.ssrc if self.heard_ssrc is None else self.heard_ssrc
|
|
532
|
+
if pkt.ssrc != self.heard_ssrc and not self.case.any_ssrc:
|
|
533
|
+
return
|
|
534
|
+
assert self.responder is not None
|
|
535
|
+
self.responder.on_packet(arrival, pkt)
|
|
536
|
+
|
|
537
|
+
def _ok(self, req: sip.Message, direction: str) -> sip.Message:
|
|
538
|
+
local = _local_ip(self.addr)
|
|
539
|
+
assert self.invite is not None
|
|
540
|
+
transport = getattr(self.sock, "transport", "UDP")
|
|
541
|
+
sips = self.invite.first.split()[1].lower().startswith("sips:")
|
|
542
|
+
contact = sip.contact("agent", local, self.sock.getsockname()[1], transport, sips)
|
|
543
|
+
extra = [("Contact", contact)]
|
|
544
|
+
origin = (self.origin[0], self.origin[1])
|
|
545
|
+
pts = list(g711.NAMES) if self.late and self.sender is None else [self.pt]
|
|
546
|
+
crypto = self._crypto(sip.parse_sdp(req.body) if req.body else None)
|
|
547
|
+
body = sip.sdp(
|
|
548
|
+
local, self.rtp.getsockname()[1], pts, direction, origin, self.dtmf, crypto=crypto
|
|
549
|
+
)
|
|
550
|
+
self.origin[1] += 1
|
|
551
|
+
if self.case.timer and (expires := req.get("session-expires")):
|
|
552
|
+
# RFC 4028 9: keep the interval and the caller as refresher, so Require: timer
|
|
553
|
+
interval = int(expires.split(";")[0])
|
|
554
|
+
extra += [("Require", "timer"), ("Supported", "timer")]
|
|
555
|
+
extra.append(("Session-Expires", f"{interval};refresher=uac"))
|
|
556
|
+
if self.case.expire: # 10: BYE min(32 s, a third) before it would expire
|
|
557
|
+
self.expire_at = time.monotonic() + interval - min(32, interval / 3)
|
|
558
|
+
return sip.response(req, 200, "OK", self.to_tag, body, extra)
|
|
559
|
+
|
|
560
|
+
def _reinvite(self, msg: sip.Message) -> None:
|
|
561
|
+
"""Answer the offered direction (or sendrecv, ignoring hold); pause sending while the
|
|
562
|
+
answer says the agent does not send."""
|
|
563
|
+
offer = sip.parse_sdp(msg.body)
|
|
564
|
+
self.reinvite = msg.branch # a retransmission gets the same answer
|
|
565
|
+
if (self.suite and _savp(offer) and all(c[1] != self.suite for c in _usable(offer))) or (
|
|
566
|
+
msg.body and self.case.only not in (None, *offer.pts)
|
|
567
|
+
):
|
|
568
|
+
self._send(sip.response(msg, 488, "Not Acceptable Here", self.to_tag), final=True)
|
|
569
|
+
return
|
|
570
|
+
direction = sip.ANSWER[offer.direction] if self.case.hold else "sendrecv"
|
|
571
|
+
zero = offer.ip == "0.0.0.0" # RFC 3264 8.4: send nothing
|
|
572
|
+
law = next((pt for pt in offer.pts if pt in g711.NAMES), self.pt)
|
|
573
|
+
if self.responder:
|
|
574
|
+
if law != self.pt and self.case.recodec:
|
|
575
|
+
self.pt = law
|
|
576
|
+
self.responder.recodec(law)
|
|
577
|
+
if self.sender:
|
|
578
|
+
self.sender.pt = law
|
|
579
|
+
with self.responder.lock:
|
|
580
|
+
sending = direction in ("sendrecv", "sendonly") and self.case.refresh
|
|
581
|
+
self.responder.sending = sending and not (zero and self.case.zero_hold)
|
|
582
|
+
if offer.port and self.sender and not zero:
|
|
583
|
+
self.sender.addr = (offer.ip, offer.port)
|
|
584
|
+
ok = self._ok(msg, direction)
|
|
585
|
+
if not msg.body and not self.case.reoffer:
|
|
586
|
+
ok.body, ok.headers = b"", [h for h in ok.headers if h[0] != "Content-Type"]
|
|
587
|
+
self._send(ok, final=True)
|
|
588
|
+
|
|
589
|
+
def tick(self) -> None:
|
|
590
|
+
now = time.monotonic()
|
|
591
|
+
if self.dialog and self.case.retransmit:
|
|
592
|
+
self.dialog.retransmit()
|
|
593
|
+
if self.refer is None and self._since_turn(self.case.refer_s):
|
|
594
|
+
self._transfer()
|
|
595
|
+
if self.case.reply_s is not None and not self.replied and (r := self.responder) and r.ends:
|
|
596
|
+
self.replied = True
|
|
597
|
+
r.speak(r.ends[0] + round(self.case.reply_s * 1e9), self.greeting + 1, "reply")
|
|
598
|
+
if self.expire_at is not None and now >= self.expire_at:
|
|
599
|
+
self.expire_at = None
|
|
600
|
+
self._hang_up()
|
|
601
|
+
if self.answer_at is not None and now >= self.answer_at:
|
|
602
|
+
self.answer_at = None
|
|
603
|
+
self._answer()
|
|
604
|
+
if self.unacked and now >= self.resend_at:
|
|
605
|
+
self.sock.sendto(self.unacked, self.addr)
|
|
606
|
+
self.interval = min(self.interval * 2, sip.T2)
|
|
607
|
+
self.resend_at = now + self.interval
|
|
608
|
+
if self.end_at is not None and now >= self.end_at:
|
|
609
|
+
self.done = True
|
|
610
|
+
|
|
611
|
+
def _since_turn(self, seconds: float | None) -> bool:
|
|
612
|
+
"""The caller's first turn ended at least `seconds` ago."""
|
|
613
|
+
r = self.responder
|
|
614
|
+
if r is None or not r.ends or seconds is None:
|
|
615
|
+
return False
|
|
616
|
+
return CLOCK.now() >= r.ends[0] + seconds * 1e9
|
|
617
|
+
|
|
618
|
+
def _transfer(self) -> None:
|
|
619
|
+
"""Blind transfer (RFC 5589): REFER the caller to an operator."""
|
|
620
|
+
assert self.dialog is not None
|
|
621
|
+
local = self.dialog.local_ip
|
|
622
|
+
headers = [("Refer-To", f"<{OPERATOR}>"), ("Referred-By", f"<sip:agent@{local}>")]
|
|
623
|
+
self.refer = self.dialog.request("REFER", headers=headers)
|
|
624
|
+
self.dialog.send(self.refer)
|
|
625
|
+
|
|
626
|
+
def stop(self) -> None:
|
|
627
|
+
for t in self.threads:
|
|
628
|
+
t.stop.set()
|
|
629
|
+
for t in self.threads:
|
|
630
|
+
t.join()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def serve(
|
|
634
|
+
ready: Path,
|
|
635
|
+
sip_port: int = 5060,
|
|
636
|
+
rtp_port: int = 40000,
|
|
637
|
+
seed: int = 1,
|
|
638
|
+
real: bool = False,
|
|
639
|
+
replies: Path | None = None,
|
|
640
|
+
barge: bool = False,
|
|
641
|
+
case: Behaviour | None = None,
|
|
642
|
+
tls: Path | None = None,
|
|
643
|
+
) -> dict[str, Any]:
|
|
644
|
+
"""Answer one call; return its per-turn ground truth. Touches `ready` once listening.
|
|
645
|
+
|
|
646
|
+
`real`: reply with speech (corpus clips, or the WAVs in `replies`), not the synthetic voice.
|
|
647
|
+
`barge`: reply with the long corpus clips and follow the barge-in plan.
|
|
648
|
+
`case`: answer no turns; behave as a conformance case asks (short then long corpus clips).
|
|
649
|
+
`tls`: a directory with server.crt and server.key; SIP over TLS on 5061 too.
|
|
650
|
+
"""
|
|
651
|
+
real = real or barge
|
|
652
|
+
rng = random.Random(seed)
|
|
653
|
+
delays = [rng.uniform(*(SPEECH_DELAY_RANGE if real else DELAY_RANGE)) for _ in range(1000)]
|
|
654
|
+
clips = speech_replies(seed, replies, long=barge) if real else None
|
|
655
|
+
greeting = 0
|
|
656
|
+
if case:
|
|
657
|
+
delays, short = [], speech_replies(seed)
|
|
658
|
+
clips, greeting = (
|
|
659
|
+
short + speech_replies(seed, long=True),
|
|
660
|
+
len(short) * (case.greeting == "long"),
|
|
661
|
+
)
|
|
662
|
+
plan = bargein.plan(seed) if barge else None
|
|
663
|
+
# case callers speak real speech too, whose pauses would end a turn mid-sentence
|
|
664
|
+
hangover = SPEECH_HANGOVER_NS if real or case else HANGOVER_NS
|
|
665
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
666
|
+
sock.bind(("0.0.0.0", sip_port))
|
|
667
|
+
streams: list[sip.Tcp] = [sip.Tcp(sip_port)] if (case or Behaviour()).tcp else []
|
|
668
|
+
if tls:
|
|
669
|
+
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
670
|
+
ctx.load_cert_chain(tls / "server.crt", tls / "server.key")
|
|
671
|
+
streams.append(sip.Tls(sip.TLS_PORT, server=ctx))
|
|
672
|
+
rtp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
673
|
+
rtp.bind(("0.0.0.0", rtp_port))
|
|
674
|
+
ready.touch()
|
|
675
|
+
|
|
676
|
+
def responder(pt: int, seq0: int) -> Responder:
|
|
677
|
+
return Responder(pt, seq0, delays, clips, hangover, plan)
|
|
678
|
+
|
|
679
|
+
call = Uas(sock, rtp, case or Behaviour(), responder, greeting)
|
|
680
|
+
sock.settimeout(0)
|
|
681
|
+
for t in streams:
|
|
682
|
+
t.settimeout(0)
|
|
683
|
+
while not call.done:
|
|
684
|
+
ready_socks = select.select(
|
|
685
|
+
[sock, *(s for t in streams for s in t.sockets())], [], [], 0.02
|
|
686
|
+
)
|
|
687
|
+
try:
|
|
688
|
+
if sock in ready_socks[0]:
|
|
689
|
+
data, addr = sock.recvfrom(65535)
|
|
690
|
+
call.handle(sip.parse(data), addr, sock)
|
|
691
|
+
except BlockingIOError:
|
|
692
|
+
pass
|
|
693
|
+
for t in streams: # each, even unwoken: a message may wait read already
|
|
694
|
+
try:
|
|
695
|
+
data, addr = t.recvfrom()
|
|
696
|
+
except TimeoutError:
|
|
697
|
+
continue
|
|
698
|
+
call.handle(sip.parse(data), addr, t)
|
|
699
|
+
call.tick()
|
|
700
|
+
call.stop()
|
|
701
|
+
r = call.responder
|
|
702
|
+
return call.meta | {
|
|
703
|
+
"seed": seed,
|
|
704
|
+
"turns": r.turns if r else [],
|
|
705
|
+
"digits": r.digits if r else [],
|
|
706
|
+
"send_errors": call.sender.send_errors if call.sender else [],
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def operator(ready: Path, sip_port: int = 5060) -> None:
|
|
711
|
+
"""The transfer target: takes any number of calls, each on its own RTP socket; rings for a
|
|
712
|
+
second, answers and says a few words, or is busy when the INVITE asks (BUSY). Runs until
|
|
713
|
+
killed; touches `ready` once listening."""
|
|
714
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
715
|
+
sock.bind(("0.0.0.0", sip_port))
|
|
716
|
+
sock.settimeout(0.02)
|
|
717
|
+
clips = speech_replies(1)[:2] # encoded per call: a greeting is all it says
|
|
718
|
+
calls: dict[str, Uas] = {}
|
|
719
|
+
ready.touch()
|
|
720
|
+
while True:
|
|
721
|
+
try:
|
|
722
|
+
data, addr = sock.recvfrom(65535)
|
|
723
|
+
except TimeoutError:
|
|
724
|
+
pass
|
|
725
|
+
else:
|
|
726
|
+
msg = sip.parse(data)
|
|
727
|
+
call_id = msg.get("call-id")
|
|
728
|
+
if call_id not in calls and msg.method == "INVITE" and not sip.tag(msg.get("to")):
|
|
729
|
+
busy = msg.get(BUSY[0]).lower() == BUSY[1]
|
|
730
|
+
case = Behaviour(busy=True) if busy else Behaviour(ring_s=1.0, greet_s=0.5)
|
|
731
|
+
rtp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
732
|
+
rtp.bind(("0.0.0.0", 0))
|
|
733
|
+
calls[call_id] = Uas(
|
|
734
|
+
sock, rtp, case, lambda pt, seq0: Responder(pt, seq0, [], clips), 0
|
|
735
|
+
)
|
|
736
|
+
print(f"operator: {call_id} {'busy' if busy else 'answers'}", flush=True)
|
|
737
|
+
if call := calls.get(call_id):
|
|
738
|
+
call.handle(msg, addr)
|
|
739
|
+
elif msg.method not in ("", "ACK"):
|
|
740
|
+
sock.sendto(sip.response(msg, 481, "Call Does Not Exist").render(), addr)
|
|
741
|
+
for call_id, call in list(calls.items()):
|
|
742
|
+
call.tick()
|
|
743
|
+
if call.done:
|
|
744
|
+
call.stop()
|
|
745
|
+
call.rtp.close()
|
|
746
|
+
del calls[call_id]
|