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.
Files changed (144) hide show
  1. rtpbench/.dockerignore +4 -0
  2. rtpbench/Dockerfile +12 -0
  3. rtpbench/__init__.py +6 -0
  4. rtpbench/__main__.py +3 -0
  5. rtpbench/agent.py +746 -0
  6. rtpbench/analyze.py +520 -0
  7. rtpbench/bargein.py +272 -0
  8. rtpbench/bench.py +1069 -0
  9. rtpbench/caller.py +781 -0
  10. rtpbench/capture.py +33 -0
  11. rtpbench/carrier/compose.yaml +68 -0
  12. rtpbench/carrier/opensips.cfg +226 -0
  13. rtpbench/carrier/tls.cfg +25 -0
  14. rtpbench/carrier/tls.yaml +9 -0
  15. rtpbench/cases.py +1989 -0
  16. rtpbench/check.py +369 -0
  17. rtpbench/cli.py +616 -0
  18. rtpbench/clock.py +174 -0
  19. rtpbench/compose.yaml +55 -0
  20. rtpbench/corpus/README.md +55 -0
  21. rtpbench/corpus/cough-c1-0.wav +0 -0
  22. rtpbench/corpus/cough-c1-1.wav +0 -0
  23. rtpbench/corpus/cough-c2-0.wav +0 -0
  24. rtpbench/corpus/cough-c2-1.wav +0 -0
  25. rtpbench/corpus/cough-man-0.wav +0 -0
  26. rtpbench/corpus/cough-man-1.wav +0 -0
  27. rtpbench/corpus/cough-short-0.wav +0 -0
  28. rtpbench/corpus/cough-short-1.wav +0 -0
  29. rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
  30. rtpbench/corpus/human-en-dr-0.wav +0 -0
  31. rtpbench/corpus/human-en-dr-1.wav +0 -0
  32. rtpbench/corpus/human-en-sc-0.wav +0 -0
  33. rtpbench/corpus/human-en-sc-1.wav +0 -0
  34. rtpbench/corpus/human-en-sc-2.wav +0 -0
  35. rtpbench/corpus/human-es-f012-0.wav +0 -0
  36. rtpbench/corpus/human-es-f012-1.wav +0 -0
  37. rtpbench/corpus/human-es-f012-2.wav +0 -0
  38. rtpbench/corpus/human-es-f016-0.wav +0 -0
  39. rtpbench/corpus/human-es-f016-1.wav +0 -0
  40. rtpbench/corpus/human-es-f016-2.wav +0 -0
  41. rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
  42. rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
  43. rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
  44. rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
  45. rtpbench/corpus/lines/en/prompt-00.wav +0 -0
  46. rtpbench/corpus/lines/en/prompt-01.wav +0 -0
  47. rtpbench/corpus/lines/en/prompt-02.wav +0 -0
  48. rtpbench/corpus/lines/en/prompt-03.wav +0 -0
  49. rtpbench/corpus/lines/en/prompt-04.wav +0 -0
  50. rtpbench/corpus/lines/en/prompt-05.wav +0 -0
  51. rtpbench/corpus/lines/en/prompt-06.wav +0 -0
  52. rtpbench/corpus/lines/en/prompt-07.wav +0 -0
  53. rtpbench/corpus/lines/en/prompt-08.wav +0 -0
  54. rtpbench/corpus/lines/en/prompt-09.wav +0 -0
  55. rtpbench/corpus/lines/en/prompt.wav +0 -0
  56. rtpbench/corpus/lines/en/transfer.wav +0 -0
  57. rtpbench/corpus/lines/en/turn-00.wav +0 -0
  58. rtpbench/corpus/lines/en/turn-01.wav +0 -0
  59. rtpbench/corpus/lines/en/turn-02.wav +0 -0
  60. rtpbench/corpus/lines/en/turn-03.wav +0 -0
  61. rtpbench/corpus/lines/en/turn-04.wav +0 -0
  62. rtpbench/corpus/lines/en/turn-05.wav +0 -0
  63. rtpbench/corpus/lines/en/turn-06.wav +0 -0
  64. rtpbench/corpus/lines/en/turn-07.wav +0 -0
  65. rtpbench/corpus/lines/en/turn-08.wav +0 -0
  66. rtpbench/corpus/lines/en/turn-09.wav +0 -0
  67. rtpbench/corpus/lines/en/turn-10.wav +0 -0
  68. rtpbench/corpus/lines/en/turn-11.wav +0 -0
  69. rtpbench/corpus/tts-ald-bc.wav +0 -0
  70. rtpbench/corpus/tts-ald-long.wav +0 -0
  71. rtpbench/corpus/tts-ald-s03.wav +0 -0
  72. rtpbench/corpus/tts-ald-s07.wav +0 -0
  73. rtpbench/corpus/tts-ald-s11.wav +0 -0
  74. rtpbench/corpus/tts-cori-bc.wav +0 -0
  75. rtpbench/corpus/tts-cori-long.wav +0 -0
  76. rtpbench/corpus/tts-cori-s01.wav +0 -0
  77. rtpbench/corpus/tts-cori-s05.wav +0 -0
  78. rtpbench/corpus/tts-cori-s09.wav +0 -0
  79. rtpbench/corpus/tts-davefx-bc.wav +0 -0
  80. rtpbench/corpus/tts-davefx-long.wav +0 -0
  81. rtpbench/corpus/tts-davefx-s00.wav +0 -0
  82. rtpbench/corpus/tts-davefx-s04.wav +0 -0
  83. rtpbench/corpus/tts-davefx-s08.wav +0 -0
  84. rtpbench/corpus/tts-libritts0-bc.wav +0 -0
  85. rtpbench/corpus/tts-libritts0-long.wav +0 -0
  86. rtpbench/corpus/tts-libritts0-s02.wav +0 -0
  87. rtpbench/corpus/tts-libritts0-s06.wav +0 -0
  88. rtpbench/corpus/tts-libritts0-s10.wav +0 -0
  89. rtpbench/corpus/tts-libritts3-bc.wav +0 -0
  90. rtpbench/corpus/tts-libritts3-long.wav +0 -0
  91. rtpbench/corpus/tts-libritts3-s03.wav +0 -0
  92. rtpbench/corpus/tts-libritts3-s07.wav +0 -0
  93. rtpbench/corpus/tts-libritts3-s11.wav +0 -0
  94. rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
  95. rtpbench/corpus/tts-ljspeech-long.wav +0 -0
  96. rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
  97. rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
  98. rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
  99. rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
  100. rtpbench/corpus/tts-sharvardf-long.wav +0 -0
  101. rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
  102. rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
  103. rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
  104. rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
  105. rtpbench/corpus/tts-sharvardm-long.wav +0 -0
  106. rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
  107. rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
  108. rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
  109. rtpbench/detect.py +145 -0
  110. rtpbench/g711.py +82 -0
  111. rtpbench/mock/compose.yaml +18 -0
  112. rtpbench/pcap.py +106 -0
  113. rtpbench/progress.py +184 -0
  114. rtpbench/realtime.py +1015 -0
  115. rtpbench/report.py +450 -0
  116. rtpbench/rtp.py +166 -0
  117. rtpbench/sip.py +653 -0
  118. rtpbench/speech.py +100 -0
  119. rtpbench/speechcal.py +367 -0
  120. rtpbench/srtp.py +191 -0
  121. rtpbench/targets/aivc/aivc.ini +25 -0
  122. rtpbench/targets/aivc/compose.yaml +72 -0
  123. rtpbench/targets/aivc/constraints.txt +52 -0
  124. rtpbench/targets/aivc/opensips.cfg +45 -0
  125. rtpbench/targets/ava/ai-agent.yaml +275 -0
  126. rtpbench/targets/ava/ari.conf +7 -0
  127. rtpbench/targets/ava/compose.yaml +128 -0
  128. rtpbench/targets/ava/constraints.txt +75 -0
  129. rtpbench/targets/ava/extensions.conf +10 -0
  130. rtpbench/targets/ava/http.conf +4 -0
  131. rtpbench/targets/ava/pjsip.conf +19 -0
  132. rtpbench/targets/ava/websocket_client.conf +15 -0
  133. rtpbench/targets/livekit/agent.py +111 -0
  134. rtpbench/targets/livekit/compose.yaml +86 -0
  135. rtpbench/targets/livekit/constraints.txt +72 -0
  136. rtpbench/tls.py +284 -0
  137. rtpbench/uri/advertise.yaml +7 -0
  138. rtpbench/uri/network.yaml +14 -0
  139. rtpbench/voice.py +18 -0
  140. rtpbench-0.3.0.dist-info/METADATA +123 -0
  141. rtpbench-0.3.0.dist-info/RECORD +144 -0
  142. rtpbench-0.3.0.dist-info/WHEEL +4 -0
  143. rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
  144. rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
rtpbench/caller.py ADDED
@@ -0,0 +1,781 @@
1
+ """Caller: speaks fixed turns (synthetic or from WAV files) over a real SIP call, or, for
2
+ barge-in, reacts to the agent: prompt, wait for the reply, cut in, wait for quiet."""
3
+
4
+ import bisect
5
+ import os
6
+ import random
7
+ import secrets
8
+ import socket
9
+ import ssl
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from rtpbench import bargein, g711, sip, speech, srtp, tls
17
+ from rtpbench.clock import CLOCK
18
+ from rtpbench.rtp import FRAME, PTIME_NS, SAMPLE_NS, Frame, Packet, Receiver, Sender
19
+ from rtpbench.voice import GATE, RATE, voice
20
+
21
+ LEAD_SECONDS = 1.0
22
+ UTTERANCE_RANGE = (0.8, 1.6)
23
+ GAP_SECONDS = 2.8 # longest delay (1.3 s) + response (0.8 s) + room for netem
24
+ QUIET_NS = 3_000_000_000 # agent silence that ends its turn (a real agent answers a barge-in)
25
+ REPLY_TIMEOUT_NS = 15_000_000_000
26
+ TURN_TIMEOUT_NS = 60_000_000_000
27
+ # The caller binds its SIP and RTP sockets from the advertised port on (RTPBENCH_ADVERTISE,
28
+ # HOST:PORT), so the router can forward them: a transfer's two calls take four. ADVERTISE_PORT is
29
+ # the first when the router's own address on the agent's network is advertised.
30
+ ADVERTISE_PORT, ADVERTISE_PORTS = 40000, 8
31
+ NO_MEDIA = (
32
+ "an agent that sends its audio to the address in our offer, not back to where ours comes "
33
+ "from, needs that address reachable: see rtpbench target --network and --advertise"
34
+ )
35
+
36
+ Clip = tuple[str, list[int]]
37
+ Source = Callable[[int, int], bytes | Frame | None]
38
+ Labelled = tuple[str, list[int], list[tuple[int, int]]]
39
+
40
+
41
+ def speech_utterances(seed: int) -> list[Clip]:
42
+ """Every short corpus clip, in a seeded order unlike the agent's replies."""
43
+ clips = [(n, pcm) for n, pcm in speech.clips("tts-", "human-").items() if "-long" not in n]
44
+ random.Random(seed + 1).shuffle(clips)
45
+ return clips
46
+
47
+
48
+ def interjections(seed: int) -> dict[str, list[Clip]]:
49
+ """Corpus clips for each interjection kind, shuffled."""
50
+ out = {
51
+ "interrupt": speech_utterances(seed + 1),
52
+ "backchannel": [(n, p) for n, p in speech.clips("tts-").items() if n.endswith("-bc")],
53
+ "cough": list(speech.clips("cough-").items()),
54
+ }
55
+ for clips in out.values():
56
+ random.Random(seed).shuffle(clips)
57
+ return out
58
+
59
+
60
+ def script(
61
+ turns: int,
62
+ seed: int,
63
+ utterances: list[list[int]] | None = None,
64
+ lead: float = LEAD_SECONDS,
65
+ gap: float = GAP_SECONDS,
66
+ ) -> tuple[list[int], list[tuple[int, int]]]:
67
+ """PCM for the whole call, and the [start, end) sample span of each turn's utterance."""
68
+ rng = random.Random(seed)
69
+ pcm, spans = [0] * round(lead * RATE), []
70
+ for i in range(turns):
71
+ start = len(pcm)
72
+ pcm += (
73
+ utterances[i % len(utterances)]
74
+ if utterances
75
+ else voice(rng.uniform(*UTTERANCE_RANGE), f0=140)
76
+ )
77
+ spans.append((start, len(pcm)))
78
+ pcm += [0] * round(gap * RATE)
79
+ return pcm, spans
80
+
81
+
82
+ def _udp(port: int) -> socket.socket:
83
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
84
+ try:
85
+ sock.bind(("0.0.0.0", port))
86
+ except OSError:
87
+ sock.close()
88
+ raise
89
+ return sock
90
+
91
+
92
+ def _bind[S](make: Callable[[int], S], ports: range) -> S:
93
+ """A socket from `make` on the first of `ports` it can bind."""
94
+ for port in ports:
95
+ try:
96
+ return make(port)
97
+ except OSError:
98
+ continue
99
+ raise sip.SipError(f"no free port in {ports.start}-{ports.stop - 1}")
100
+
101
+
102
+ def parse_uri(uri: str) -> tuple[str, int]:
103
+ hostport = uri.split("@", 1)[-1].split(";", 1)[0]
104
+ scheme, sep, rest = hostport.partition(":")
105
+ if sep and scheme.lower() in ("sip", "sips"): # no user part; schemes ignore case
106
+ hostport = rest
107
+ host, _, port = hostport.partition(":")
108
+ return host, int(port or (sip.TLS_PORT if sip.secure(uri) else 5060))
109
+
110
+
111
+ def _tls(port: int, hostname: str) -> sip.Tls:
112
+ """SIP over TLS from `port`, checking `hostname`'s certificate against the system's trusted
113
+ certificates, or against RTPBENCH_TLS/ca.pem if there is one; with RTPBENCH_TLS/server.crt
114
+ (and .key) it also takes connections the far end opens. The contexts log their session
115
+ keys to SSLKEYLOGFILE, which judging needs to read the capture."""
116
+ where = Path(os.environ.get("RTPBENCH_TLS", "/nonexistent"))
117
+ ca = where / "ca.pem"
118
+ client = ssl.create_default_context(cafile=ca if ca.exists() else None)
119
+ client.minimum_version = ssl.TLSVersion.TLSv1_2
120
+ client.set_ciphers(tls.CIPHERS_12)
121
+ server = None
122
+ if (where / "server.crt").exists():
123
+ server = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
124
+ server.load_cert_chain(where / "server.crt", where / "server.key")
125
+ return sip.Tls(port, client, server, hostname)
126
+
127
+
128
+ class Session:
129
+ """One call: the SIP dialog and the RTP socket, from INVITE to BYE (sent to the URI's host,
130
+ or to `proxy`, a host[:port]; over UDP, or TCP with `tcp`).
131
+
132
+ The INVITE goes out at once; `wait` pumps SIP (retransmissions, answers to what the far end
133
+ sends) until a condition holds, so a script can act between messages while media flows.
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ uri: str,
139
+ codecs: tuple[int, ...],
140
+ headers: list[tuple[str, str]] | None = None,
141
+ dtmf: bool = False,
142
+ proxy: str | None = None,
143
+ tcp: bool = False,
144
+ ) -> None:
145
+ host, port = parse_uri(proxy or uri) # an outbound proxy takes it whatever its URI
146
+ # hosts as the router resolved them (RTPBENCH_RESOLVE, HOST=IP,...): a name on the
147
+ # agent's Docker network resolves only there, and netem needs the same address
148
+ names = os.environ.get("RTPBENCH_RESOLVE", "").split(",")
149
+ resolved = dict(n.split("=", 1) for n in names if "=" in n)
150
+ peer = (resolved.get(host, host), port)
151
+ if advertise := os.environ.get("RTPBENCH_ADVERTISE"):
152
+ local_ip, _, first = advertise.rpartition(":")
153
+ ports = range(int(first), int(first) + ADVERTISE_PORTS)
154
+ else:
155
+ probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
156
+ probe.connect(peer)
157
+ local_ip, ports = probe.getsockname()[0], range(1) # port 0: any
158
+ sock: socket.socket | sip.Tcp
159
+ hop = sip.transport(proxy or uri) # the next hop's
160
+ tcp = tcp or hop == "TCP"
161
+ if hop == "TLS":
162
+ sock = _bind(lambda p: _tls(p, host), ports)
163
+ else:
164
+ sock = _bind(sip.Tcp, ports) if tcp else _bind(_udp, ports)
165
+ self.rtp = _bind(_udp, ports)
166
+ self.dialog = sip.Dialog.uac(sock, peer, uri, local_ip)
167
+ self.origin = (secrets.randbelow(1 << 31), 0) # SDP session id, version
168
+ # SRTP (RTPBENCH_SRTP: the suites to offer, in order): our master key per suite, and
169
+ # once answered, the suite and the contexts we send and hear with
170
+ self.offered = [s for s in os.environ.get("RTPBENCH_SRTP", "").split(",") if s]
171
+ self.keys = srtp.master() if self.offered else {}
172
+ self.suite: str | None = None
173
+ self.tx: srtp.Context | None = None
174
+ self.rx: srtp.Context | None = None
175
+ self.srtp_error = ""
176
+ self.held = False
177
+ self.dtmf: int | None = None # telephone-event payload type, if the answer has it
178
+ port = self.rtp.getsockname()[1]
179
+ offer = sip.sdp(
180
+ local_ip, port, list(codecs), origin=self.origin, dtmf=dtmf, crypto=self._crypto()
181
+ )
182
+ self.headers = headers
183
+ user, password = (os.environ.get(f"RTPBENCH_SIP_{k}") for k in ("USER", "PASSWORD"))
184
+ self.credentials = (user, password) if user and password else None
185
+ self.invite = self.dialog.request("INVITE", offer, headers)
186
+ self.invites = {self.invite.branch: self.invite}
187
+ self.final: sip.Message | None = None # final response to the INVITE
188
+ self.responses: list[sip.Message] = []
189
+ self.requests: list[sip.Message] = [] # from the far end
190
+ # methods whose next request from the far end goes unanswered, as if lost; those it lost
191
+ self.lose: set[str] = set()
192
+ self.lost: list[sip.Message] = []
193
+ self.remote_bye = False
194
+ self.addr, self.pt = ("", 0), 0
195
+ self.seq0, self.ssrc = secrets.randbelow(1 << 16), secrets.randbelow(1 << 32)
196
+ self.media: tuple[Sender, Receiver] | None = None
197
+ try:
198
+ self.dialog.send(self.invite)
199
+ except OSError as e: # over TCP the caller may retry over UDP; over TLS it cannot
200
+ if hop == "TLS":
201
+ raise sip.SipError(f"TLS to {peer[0]}:{peer[1]}: {type(e).__name__}: {e}") from e
202
+ how = {
203
+ TimeoutError: "connection timed out",
204
+ ConnectionRefusedError: "connection refused",
205
+ }
206
+ if not tcp or type(e) not in how:
207
+ raise
208
+ raise ConnectionError(f"no TCP listener on port {peer[1]} ({how[type(e)]})") from e
209
+
210
+ def pump(self, timeout: float) -> None:
211
+ """Handle the next SIP message, if one comes within `timeout`."""
212
+ msg = self.dialog.recv(timeout)
213
+ if msg is None or msg.get("call-id") != self.dialog.call_id:
214
+ return
215
+ if not msg.status:
216
+ self._incoming(msg)
217
+ return
218
+ self.responses.append(msg)
219
+ if msg.cseq_method != "INVITE" or msg.status < 200:
220
+ return
221
+ initial = msg.branch == self.invite.branch
222
+ if msg.status >= 300:
223
+ if req := self.invites.get(msg.branch):
224
+ self.dialog.ack_failure(req, msg)
225
+ if initial and (
226
+ again := self._authorize(self.invite, msg, self.headers)
227
+ or self._interval(self.invite, msg)
228
+ ):
229
+ self.invite = again
230
+ elif initial:
231
+ self.final = msg
232
+ return
233
+ if not initial:
234
+ req = self.invites.get(msg.branch)
235
+ if req is not None and req.body and self.offered: # the answer to our re-offer
236
+ self._srtp(sip.parse_sdp(msg.body))
237
+ self.dialog.ack(msg, self._answer(msg) if req is not None and not req.body else b"")
238
+ return
239
+ self.dialog.confirm(msg) # every 2xx, retransmissions included
240
+ if self.final is None:
241
+ self.final = msg
242
+ answer = sip.parse_sdp(msg.body)
243
+ self.addr = (answer.ip, answer.port)
244
+ self.pt = next((p for p in answer.pts if p in g711.NAMES), -1) # -1: answer() ends it
245
+ self.dtmf = answer.dtmf
246
+ if self.offered:
247
+ try:
248
+ self._srtp(answer)
249
+ except sip.SipError as e: # answer() ends it
250
+ self.srtp_error = str(e)
251
+
252
+ def _crypto(self, offer: sip.Sdp | None = None) -> list[str] | None:
253
+ """a=crypto values for our next SDP, if we use SRTP: as an offer, every suite we offer
254
+ (the one in use, once there is one); as the answer to `offer`, its line for our suite."""
255
+ if not self.offered:
256
+ return None
257
+ if offer is None:
258
+ suites = [self.suite] if self.suite else self.offered
259
+ return [srtp.line(i + 1, s, self.keys[s]) for i, s in enumerate(suites)]
260
+ tag = self._srtp(offer)
261
+ assert self.suite is not None
262
+ return [srtp.line(tag, self.suite, self.keys[self.suite])]
263
+
264
+ def _srtp(self, sdp: sip.Sdp) -> int:
265
+ """Take the agent's key from its SDP for the suite in use (or the first we offered that
266
+ it gives): we hear with it from now on. Its tag; SipError if it gives none of ours."""
267
+ usable = []
268
+ for value in sdp.crypto:
269
+ try:
270
+ usable.append(srtp.parse(value))
271
+ except ValueError:
272
+ continue
273
+ wanted = [self.suite] if self.suite else self.offered
274
+ tag, suite, key = next((c for c in usable if c[1] in wanted), (0, "", b""))
275
+ if not suite:
276
+ given = ", ".join(sdp.crypto) or f"no a=crypto, {sdp.proto}"
277
+ raise sip.SipError(
278
+ f"the agent's SDP has no SRTP suite of {', '.join(wanted)} ({given})"
279
+ )
280
+ if self.tx is None:
281
+ self.suite, self.tx = suite, srtp.Context(suite, self.keys[suite])
282
+ if self.rx is None or self.rx.master != key:
283
+ self.rx = srtp.Context(suite, key, self.rx.state if self.rx else None)
284
+ if self.media:
285
+ self.media[1].srtp = self.rx
286
+ return tag
287
+
288
+ def _authorize(
289
+ self,
290
+ req: sip.Message,
291
+ resp: sip.Message,
292
+ headers: list[tuple[str, str]] | None,
293
+ content_type: str = "application/sdp",
294
+ ) -> sip.Message | None:
295
+ """Send `req` again (a new CSeq) with credentials for the challenge in `resp`, if it is
296
+ one (401, 407), we have credentials (RTPBENCH_SIP_USER, RTPBENCH_SIP_PASSWORD) and `req`
297
+ did not carry them already; the new request."""
298
+ if resp.status not in (401, 407) or self.credentials is None:
299
+ return None
300
+ if req.get("authorization") or req.get("proxy-authorization"):
301
+ return None # these credentials were refused
302
+ name = "Authorization" if resp.status == 401 else "Proxy-Authorization"
303
+ asked = resp.all("www-authenticate" if resp.status == 401 else "proxy-authenticate")
304
+ value = sip.digest(asked, req.method, req.first.split()[1], *self.credentials)
305
+ hdrs = [*(headers or []), (name, value)]
306
+ again = self.dialog.request(req.method, req.body, hdrs, content_type)
307
+ if again.method == "INVITE":
308
+ self.invites[again.branch] = again
309
+ self.dialog.send(again)
310
+ return again
311
+
312
+ def _interval(self, req: sip.Message, resp: sip.Message) -> sip.Message | None:
313
+ """Send the INVITE `req` again (a new CSeq) asking for the session interval the agent's
314
+ 422 names (Min-SE), if it names one above what `req` asked (RFC 4028 6); the new
315
+ request."""
316
+ least, asked = resp.get("min-se").split(";")[0].strip(), req.get("session-expires")
317
+ number = asked.split(";")[0].strip()
318
+ if resp.status != 422 or not least.isdigit() or not number.isdigit():
319
+ return None
320
+ if int(least) <= int(number):
321
+ return None
322
+ kept = [
323
+ (k, v) for k, v in self.headers or [] if k.lower() not in ("session-expires", "min-se")
324
+ ]
325
+ self.headers = [*kept, ("Session-Expires", least + asked[len(number) :]), ("Min-SE", least)]
326
+ again = self.dialog.request("INVITE", req.body, self.headers)
327
+ self.invites[again.branch] = again
328
+ self.dialog.send(again)
329
+ return again
330
+
331
+ def _answer(self, ok: sip.Message) -> bytes:
332
+ """Answer to the offer in a 2xx to a re-INVITE without one (RFC 3261 14.1): the call's
333
+ codec if offered (else the first G.711 offered), the address it gives from now on."""
334
+ offer = sip.parse_sdp(ok.body)
335
+ if not offer.port:
336
+ return b"" # no offer in it: the agent got it wrong; the ACK has nothing to answer
337
+ if self.pt not in offer.pts:
338
+ self.pt = next((p for p in offer.pts if p in g711.NAMES), self.pt)
339
+ self.addr = (offer.ip, offer.port)
340
+ if self.media:
341
+ self.media[0].addr = self.addr
342
+ port = self.rtp.getsockname()[1]
343
+ direction = sip.ANSWER[offer.direction]
344
+ crypto = self._crypto(offer)
345
+ return sip.sdp(self.dialog.local_ip, port, [self.pt], direction, self.origin, crypto=crypto)
346
+
347
+ def _incoming(self, req: sip.Message) -> None:
348
+ if req.method in self.lose:
349
+ self.lose.discard(req.method)
350
+ self.lost.append(req)
351
+ return
352
+ self.requests.append(req)
353
+ if req.method == "ACK":
354
+ if self.offered and req.body: # the answer to our offer in a 2xx: its key
355
+ self._srtp(sip.parse_sdp(req.body))
356
+ return
357
+ if req.method == "INVITE": # a refresh, or a change we take as it comes
358
+ port = self.rtp.getsockname()[1]
359
+ try:
360
+ crypto = self._crypto(sip.parse_sdp(req.body) if req.body else None)
361
+ except sip.SipError: # SRTP without the suite in use: the session stays as it is
362
+ self.dialog.reply(req, 488, "Not Acceptable Here")
363
+ return
364
+ sdp = sip.sdp(self.dialog.local_ip, port, [self.pt], origin=self.origin, crypto=crypto)
365
+ self.dialog.reply(req, 200, "OK", sdp)
366
+ return
367
+ self.dialog.reply(req, *((202, "Accepted") if req.method == "REFER" else ()))
368
+ self.remote_bye |= req.method == "BYE"
369
+
370
+ def wait(self, cond: Callable[[], bool], timeout: float) -> bool:
371
+ """Pump SIP until `cond()` or `timeout` seconds; whether `cond()` held."""
372
+ deadline = time.monotonic() + timeout
373
+ while not cond():
374
+ if (left := deadline - time.monotonic()) <= 0:
375
+ return False
376
+ self.pump(min(left, 0.05))
377
+ return True
378
+
379
+ @property
380
+ def ringing(self) -> bool:
381
+ """A provisional response to the INVITE arrived (so it may be cancelled)."""
382
+ return any(r.branch == self.invite.branch and r.status < 200 for r in self.responses)
383
+
384
+ def cancel(self) -> None:
385
+ self.dialog.cancel(self.invite)
386
+
387
+ def answer(self, timeout: float = 32.0) -> sip.Message:
388
+ """The 2xx to the INVITE (ACKed); raises on a failure, no answer, or an answer without
389
+ G.711 (after hanging up)."""
390
+ self.wait(lambda: self.final is not None, timeout)
391
+ if self.final is None:
392
+ raise sip.SipError("INVITE timed out")
393
+ if self.final.status >= 300:
394
+ raise sip.SipError(self.final.first)
395
+ if self.srtp_error:
396
+ self.request("BYE")
397
+ raise sip.SipError(self.srtp_error)
398
+ if self.pt < 0:
399
+ self.request("BYE")
400
+ pts = " ".join(map(str, sip.parse_sdp(self.final.body).pts))
401
+ raise sip.SipError(f"answer has no G.711 format (payload types {pts or 'none'})")
402
+ return self.final
403
+
404
+ def request(
405
+ self,
406
+ method: str,
407
+ body: bytes = b"",
408
+ headers: list[tuple[str, str]] | None = None,
409
+ content_type: str = "application/sdp",
410
+ ) -> sip.Message | None:
411
+ """Send an in-dialog request; its final response, or None after timer F (32 s)."""
412
+ req = self.dialog.request(method, body, headers, content_type)
413
+ if method == "INVITE":
414
+ self.invites[req.branch] = req
415
+ self.dialog.send(req)
416
+
417
+ def final() -> sip.Message | None:
418
+ return next(
419
+ (r for r in self.responses if r.branch == req.branch and r.status >= 200), None
420
+ )
421
+
422
+ self.wait(lambda: final() is not None, 64 * sip.T1)
423
+ if (resp := final()) is not None and (
424
+ again := self._authorize(req, resp, headers, content_type)
425
+ ):
426
+ req = again
427
+ self.wait(lambda: final() is not None, 64 * sip.T1)
428
+ return final()
429
+
430
+ def reinvite(
431
+ self,
432
+ direction: str = "sendrecv",
433
+ headers: list[tuple[str, str]] | None = None,
434
+ conn: str | None = None,
435
+ offer: bool = True,
436
+ pt: int | None = None,
437
+ ) -> sip.Message | None:
438
+ """Offer the call's codec again, `direction` (sendonly, inactive: hold; sendrecv
439
+ unchanged: a refresh, same SDP version; "": no attribute); `conn` 0.0.0.0 holds the RFC
440
+ 2543 way; `pt` offers that codec alone instead (a codec change: the media switches to it
441
+ on a 2xx); `offer` False sends no SDP (the 2xx offers, the ACK answers). The response."""
442
+ hold = direction not in ("sendrecv", "") or conn == "0.0.0.0"
443
+ if hold or self.held or pt is not None:
444
+ self.origin = (self.origin[0], self.origin[1] + 1)
445
+ self.held = hold
446
+ port = self.rtp.getsockname()[1]
447
+ codec = self.pt if pt is None else pt
448
+ sdp = sip.sdp(
449
+ self.dialog.local_ip,
450
+ port,
451
+ [codec],
452
+ direction,
453
+ self.origin,
454
+ conn=conn,
455
+ crypto=self._crypto(),
456
+ )
457
+ resp = self.request("INVITE", sdp if offer else b"", headers)
458
+ if pt is not None and resp is not None and resp.status < 300:
459
+ self.pt = pt
460
+ if self.media:
461
+ self.media[0].pt = pt
462
+ return resp
463
+
464
+ def start(self, source: Source, sink: Callable[[int, Packet], None]) -> None:
465
+ start = CLOCK.now() + PTIME_NS
466
+ self.media = (
467
+ Sender(self.rtp, self.addr, self.pt, source, start, self.seq0, self.ssrc, srtp=self.tx),
468
+ Receiver(self.rtp, sink, srtp=self.rx),
469
+ )
470
+ for t in self.media:
471
+ t.start()
472
+
473
+ def switch(self) -> None:
474
+ """The caller's audio becomes a new RTP source mid-call, as when a gateway upstream swaps
475
+ media (random SSRC, seq and timestamp origin, marker); `meta` describes the new one."""
476
+ assert self.media is not None
477
+ sender = self.media[0]
478
+ ssrc, seq = secrets.randbelow(1 << 32), secrets.randbelow(1 << 16)
479
+ sender.switch(ssrc, seq, secrets.randbelow(1 << 32))
480
+ self.wait(lambda: sender.switched is not None, 1.0)
481
+ assert sender.switched is not None
482
+ # sample k of the call (from tick 0) is still in the packet with seq seq0 + k // 160
483
+ self.ssrc, self.seq0 = ssrc, (seq - sender.switched) & 0xFFFF
484
+
485
+ def hangup(self) -> None:
486
+ """Stop sending, BYE unless the far end hung up, stop receiving."""
487
+ if self.media:
488
+ self.media[0].stop.set()
489
+ self.media[0].join()
490
+ if not self.remote_bye:
491
+ self.request("BYE")
492
+ if self.media:
493
+ self.media[1].stop.set()
494
+ self.media[1].join()
495
+
496
+ def run(
497
+ self,
498
+ source: Source,
499
+ sink: Callable[[int, Packet], None],
500
+ until: Callable[[int], bool],
501
+ ) -> bool:
502
+ """Media until `until(now_ns)`, then hang up. True if the far end hung up first."""
503
+ self.start(source, sink)
504
+ self.wait(lambda: self.remote_bye or until(CLOCK.now()), float("inf"))
505
+ self.hangup()
506
+ return self.remote_bye
507
+
508
+ def no_media(self, seconds: float) -> sip.SipError:
509
+ return sip.SipError(
510
+ f"no RTP from the agent in {seconds:.0f} s; our offer gave"
511
+ f" c=IN IP4 {self.dialog.local_ip}: {NO_MEDIA}"
512
+ )
513
+
514
+ def meta(self, seed: int, remote_bye: bool) -> dict[str, Any]:
515
+ return {"pt": self.pt, "ssrc": self.ssrc, "seq0": self.seq0, "seed": seed} | {
516
+ "remote_bye": remote_bye,
517
+ "call_id": self.dialog.call_id,
518
+ "rtp_port": self.rtp.getsockname()[1],
519
+ "transport": self.dialog.transport,
520
+ **({"srtp": self.suite} if self.offered else {}),
521
+ **({"tls": sock.negotiated()} if isinstance(sock := self.dialog.sock, sip.Tls) else {}),
522
+ "send_errors": self.media[0].send_errors if self.media else [],
523
+ }
524
+
525
+
526
+ def call(
527
+ uri: str,
528
+ turns: int = 20,
529
+ seed: int = 1,
530
+ utterances: list[Clip] | None = None,
531
+ lead: float = LEAD_SECONDS,
532
+ gap: float = GAP_SECONDS,
533
+ codecs: tuple[int, ...] = (g711.PCMU, g711.PCMA),
534
+ ) -> dict[str, Any]:
535
+ """Place the call; return where each turn's speech ended (packet and sample).
536
+
537
+ With utterances, each turn also carries its labelled speech spans as sample indices of the
538
+ stream (sample k is sample k % 160 of the packet with seq seq0 + k // 160).
539
+ """
540
+ labels = [(n, speech.label(u)) for n, u in utterances] if utterances else []
541
+ # before the call: encoding takes about a second, and the agent's first packets find no way
542
+ # back through a NAT until ours open it
543
+ pcm, spans = script(turns, seed, [u for _, u in utterances or []], lead, gap)
544
+ encoded = {pt: g711.encode(pt, pcm) for pt in g711.NAMES}
545
+ s = Session(uri, codecs)
546
+ s.answer()
547
+ audio = encoded[s.pt]
548
+ gate = g711.gate(s.pt, GATE)
549
+ silence = bytes([g711.SILENCE[s.pt]]) * FRAME
550
+ frames = [audio[i : i + FRAME].ljust(FRAME, silence[:1]) for i in range(0, len(audio), FRAME)]
551
+ last: list[dict[str, Any]] = []
552
+ for i, (start, end) in enumerate(spans): # last sample the gate calls active, as the agent sees
553
+ m = gate.search(audio[start:end][::-1])
554
+ assert m is not None
555
+ idx = end - 1 - m.start()
556
+ rec: dict[str, Any] = {"end_seq": (s.seq0 + idx // FRAME) & 0xFFFF, "end_off": idx % FRAME}
557
+ if labels:
558
+ clip, speech_spans = labels[i % len(labels)]
559
+ rec |= {"clip": clip, "speech": [[a + start, b + start] for a, b in speech_spans]}
560
+ last.append(rec)
561
+ tick0 = CLOCK.now() + PTIME_NS # as `Session.start` schedules it
562
+ hangup_ns = tick0 + len(frames) * PTIME_NS
563
+ starts = [tick0 + start * SAMPLE_NS for start, _ in spans]
564
+ # an agent that has not sent a packet by the end of the first turn's reply time never will
565
+ silent_ns = tick0 + (spans[0][1] if spans else 0) * SAMPLE_NS + REPLY_TIMEOUT_NS
566
+ announce = Turns(len(spans))
567
+ heard = threading.Event()
568
+
569
+ def until(now: int) -> bool:
570
+ announce(bisect.bisect_right(starts, now))
571
+ return (not heard.is_set() and now >= silent_ns) or now >= hangup_ns
572
+
573
+ remote_bye = s.run(
574
+ lambda n, _t: frames[n] if n < len(frames) else silence, lambda _a, _p: heard.set(), until
575
+ )
576
+ if not heard.is_set(): # by the deadline, or the agent hung up first
577
+ raise s.no_media((CLOCK.now() - tick0) / 1e9)
578
+ return s.meta(seed, remote_bye) | {"turns": last}
579
+
580
+
581
+ class Turns:
582
+ """Prints "turn k/n" as turn k starts, for the host's progress. Called from the SIP loop
583
+ that waits for the call to end, so off the threads that send and receive RTP."""
584
+
585
+ def __init__(self, n: int) -> None:
586
+ self.n, self.k = n, 0
587
+
588
+ def __call__(self, k: int) -> None:
589
+ if k > self.k:
590
+ self.k = k
591
+ print(f"turn {k}/{self.n}", flush=True)
592
+
593
+
594
+ class Media:
595
+ """Caller audio as an RTP source, one clip at a time, and the agent's audio as a sink: when
596
+ the gate last heard the agent, and each packet's first active sample."""
597
+
598
+ def __init__(self, pt: int) -> None:
599
+ self.pt = pt
600
+ self.gate = g711.gate(pt, GATE)
601
+ self.silence = bytes([g711.SILENCE[pt]]) * FRAME
602
+ self.lock = threading.Lock()
603
+ self.audio, self.apos = b"", 0
604
+ self.agent_last = 0
605
+
606
+ def on_packet(self, arrival: int, pkt: Packet) -> None:
607
+ if pkt.pt != self.pt or not (first := self.gate.search(pkt.payload)):
608
+ return
609
+ last = self.gate.search(pkt.payload[::-1])
610
+ assert last is not None
611
+ with self.lock:
612
+ end = arrival + (len(pkt.payload) - 1 - last.start()) * SAMPLE_NS
613
+ self.heard(arrival + first.start() * SAMPLE_NS, end)
614
+ self.agent_last = max(self.agent_last, end)
615
+
616
+ def recodec(self, pt: int) -> None:
617
+ """Speak and listen in another G.711 law from now on (a codec change)."""
618
+ with self.lock:
619
+ self.pt, self.gate = pt, g711.gate(pt, GATE)
620
+ self.silence = bytes([g711.SILENCE[pt]]) * FRAME
621
+
622
+ def heard(self, onset: int, end: int) -> None:
623
+ """A packet with agent speech from `onset` to `end` arrived (under the lock)."""
624
+
625
+ def step(self, n: int, tick: int) -> None:
626
+ """Nothing is playing at tick n: start a clip with `play`, or not (under the lock)."""
627
+
628
+ def finished(self, tick: int) -> None:
629
+ """The clip just ended (under the lock)."""
630
+
631
+ def idle(self, n: int) -> bytes | Frame | None:
632
+ """Tick n's payload when no clip plays: silence, None to send nothing, or a frame of
633
+ another payload type (under the lock)."""
634
+ return self.silence
635
+
636
+ def play(self, n: int, clip: tuple[str, bytes, list[tuple[int, int]]]) -> dict[str, Any]:
637
+ """Start `clip` (name, encoded audio, labelled spans) at tick n; its spans in the stream."""
638
+ name, audio, spans = clip
639
+ self.audio, self.apos = audio, 0
640
+ start = n * FRAME
641
+ return {"clip": name, "speech": [[a + start, b + start] for a, b in spans]}
642
+
643
+ def source(self, n: int, tick: int) -> bytes | Frame | None:
644
+ with self.lock:
645
+ if not self.audio:
646
+ self.step(n, tick)
647
+ if not self.audio:
648
+ return self.idle(n)
649
+ out = self.audio[self.apos : self.apos + FRAME].ljust(FRAME, self.silence[:1])
650
+ self.apos += FRAME
651
+ if self.apos >= len(self.audio):
652
+ self.audio = b""
653
+ self.finished(tick)
654
+ return out
655
+
656
+
657
+ class Conversation(Media):
658
+ """Barge-in script as an RTP source: each turn a prompt, then, once the agent's reply has
659
+ started, an interjection `at_s` later, then quiet before the next prompt."""
660
+
661
+ def __init__(
662
+ self,
663
+ pt: int,
664
+ script: list[tuple[Labelled, Labelled, dict[str, Any]]],
665
+ lead_ns: int,
666
+ ) -> None:
667
+ super().__init__(pt)
668
+ self.script = [
669
+ ((pn, g711.encode(pt, p), ps), (jn, g711.encode(pt, j), js), st)
670
+ for (pn, p, ps), (jn, j, js), st in script
671
+ ]
672
+ self.lead_ns = lead_ns
673
+ self.turns: list[dict[str, Any]] = []
674
+ self.state, self.turn = "lead", 0
675
+ self.t0: int | None = None
676
+ self.since = 0 # when the current state began
677
+ self.wait_until = 0
678
+ self.onset: int | None = None # first agent sample heard after the prompt
679
+ self.done = False
680
+
681
+ def heard(self, onset: int, end: int) -> None:
682
+ if self.state == "reply" and self.onset is None and onset > self.since:
683
+ self.onset = onset
684
+
685
+ def finished(self, tick: int) -> None:
686
+ self.state = "reply" if self.state == "prompt" else "quiet"
687
+ self.since, self.onset = tick, None
688
+
689
+ def step(self, n: int, tick: int) -> None:
690
+ if self.t0 is None:
691
+ self.t0 = tick
692
+ if self.done: # the sender keeps ticking until the call notices
693
+ return
694
+ _, interjection, step = self.script[self.turn]
695
+ if self.state == "lead" and tick - self.t0 >= self.lead_ns:
696
+ self._prompt(n, tick)
697
+ elif self.state == "reply" and self.onset is not None:
698
+ self.state, self.wait_until = "barge", self.onset + round(step["at_s"] * 1e9)
699
+ elif self.state == "reply" and tick - self.since > REPLY_TIMEOUT_NS:
700
+ self.state, self.since = "quiet", tick
701
+ elif self.state == "barge" and tick >= self.wait_until:
702
+ self.turns[-1] |= {"kind": step["kind"], "at_s": step["at_s"]} | self.play(
703
+ n, interjection
704
+ )
705
+ self.state = "interject"
706
+ elif self.state == "quiet" and (
707
+ tick - max(self.agent_last, self.since) >= QUIET_NS
708
+ or tick - self.turns[-1]["tick_ns"] > TURN_TIMEOUT_NS
709
+ ):
710
+ self.turn += 1
711
+ if self.turn == len(self.script):
712
+ self.done = True
713
+ else:
714
+ self._prompt(n, tick)
715
+
716
+ def _prompt(self, n: int, tick: int) -> None:
717
+ prompt, _, step = self.script[self.turn]
718
+ rec = self.play(n, prompt)
719
+ self.turns.append(
720
+ {
721
+ "prompt": rec["clip"],
722
+ "prompt_speech": rec["speech"],
723
+ "kind": step["kind"],
724
+ "tick_ns": tick,
725
+ }
726
+ )
727
+ self.state = "prompt"
728
+
729
+
730
+ def converse(
731
+ uri: str,
732
+ turns: int = 20,
733
+ seed: int = 1,
734
+ prompts: list[Clip] | None = None,
735
+ lead: float = LEAD_SECONDS,
736
+ codecs: tuple[int, ...] = (g711.PCMU, g711.PCMA),
737
+ interrupts: list[Clip] | None = None,
738
+ ) -> dict[str, Any]:
739
+ """Barge-in call: `turns` prompts (corpus clips by default), each followed by the planned
740
+ interjection (`interrupts` replace the corpus interruptions). Each turn logs the
741
+ interjection's labelled spans as stream sample indices."""
742
+ prompts = prompts or speech_utterances(seed)
743
+ pool = interjections(seed)
744
+ if interrupts:
745
+ pool["interrupt"] = interrupts
746
+ used = dict.fromkeys(pool, 0)
747
+ labels: dict[str, list[tuple[int, int]]] = {}
748
+
749
+ def labelled(clip: Clip) -> Labelled:
750
+ if clip[0] not in labels:
751
+ labels[clip[0]] = speech.label(clip[1])
752
+ return clip[0], clip[1], labels[clip[0]]
753
+
754
+ lines = []
755
+ for i, step in enumerate(bargein.plan(seed, turns)):
756
+ clips = pool[step["kind"]]
757
+ said = clips[used[step["kind"]] % len(clips)]
758
+ lines.append((labelled(prompts[i % len(prompts)]), labelled(said), step))
759
+ used[step["kind"]] += 1
760
+ convs = {pt: Conversation(pt, lines, round(lead * 1e9)) for pt in g711.NAMES} # as in `call`
761
+ s = Session(uri, codecs)
762
+ s.answer()
763
+ conv = convs[s.pt]
764
+ announce = Turns(turns)
765
+ # as in `call`: no packet by the end of the first prompt's reply time, none at all
766
+ t0 = CLOCK.now() + PTIME_NS
767
+ silent_ns = t0 + round(lead * 1e9) + len(lines[0][0][1]) * SAMPLE_NS + REPLY_TIMEOUT_NS
768
+ heard = threading.Event()
769
+
770
+ def until(now: int) -> bool:
771
+ announce(len(conv.turns))
772
+ return (not heard.is_set() and now >= silent_ns) or conv.done
773
+
774
+ def sink(arrival: int, pkt: Packet) -> None:
775
+ heard.set()
776
+ conv.on_packet(arrival, pkt)
777
+
778
+ remote_bye = s.run(conv.source, sink, until)
779
+ if not heard.is_set(): # by the deadline, or the agent hung up first
780
+ raise s.no_media((CLOCK.now() - t0) / 1e9)
781
+ return s.meta(seed, remote_bye) | {"turns": conv.turns}