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/bench.py ADDED
@@ -0,0 +1,1069 @@
1
+ """`rtpbench calibrate`: reference agent behind a netem router, one call per profile and codec."""
2
+
3
+ import hashlib
4
+ import itertools
5
+ import json
6
+ import os
7
+ import platform
8
+ import secrets
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import textwrap
13
+ import time
14
+ from collections.abc import Callable, Iterator, Sequence
15
+ from contextlib import contextmanager
16
+ from datetime import UTC, datetime
17
+ from functools import partial
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any
20
+ from urllib.parse import urlsplit
21
+
22
+ from rtpbench import PACKAGE, ROOT, analyze, bargein, clock, speech, speechcal
23
+ from rtpbench.agent import SPEECH_DELAY_RANGE
24
+ from rtpbench.sip import TLS_PORT
25
+ from rtpbench.tls import certificates
26
+
27
+ if TYPE_CHECKING: # the CLI's help imports this module for its names; keep that import light
28
+ from rtpbench.progress import Progress
29
+
30
+ LINES = ROOT / "corpus" / "lines" / "en" # the caller's lines against a target
31
+ # compose targets, targets/<name>/compose.yaml: the SIP user to dial, and the variables that set
32
+ # its AI engine with their defaults (of a URL only its domain and query go into the conditions)
33
+ TARGETS = {
34
+ "aivc": (
35
+ "openai",
36
+ {
37
+ "OPENAI_URL": "wss://api.openai.com/v1/realtime?model=gpt-realtime-2.1",
38
+ "OPENAI_REASONING_EFFORT": "low",
39
+ },
40
+ ),
41
+ "livekit": (
42
+ "agent",
43
+ {"AZURE_OPENAI_ENDPOINT": "", "AZURE_OPENAI_DEPLOYMENT": "gpt-realtime-2.1"},
44
+ ),
45
+ "ava": (
46
+ "agent",
47
+ {
48
+ "OPENAI_BASE_URL": "wss://api.openai.com/v1/realtime",
49
+ "OPENAI_MODEL": "gpt-realtime-2.1",
50
+ "AVA_EGRESS_PACER": "true",
51
+ "AVA_TRANSPORT": "audiosocket",
52
+ },
53
+ ),
54
+ }
55
+ # the same variables pointing at the mock engine (mock/compose.yaml), which takes any key
56
+ MOCK = {
57
+ "aivc": {"OPENAI_URL": "ws://realtime:8000/v1/realtime?model=mock", "OPENAI_API_KEY": "mock"},
58
+ "livekit": {
59
+ "AZURE_OPENAI_ENDPOINT": "http://realtime:8000",
60
+ "AZURE_OPENAI_API_KEY": "mock",
61
+ "AZURE_OPENAI_DEPLOYMENT": "mock",
62
+ },
63
+ "ava": {
64
+ "OPENAI_BASE_URL": "ws://realtime:8000/v1/realtime",
65
+ "OPENAI_MODEL": "mock",
66
+ "OPENAI_API_KEY": "mock",
67
+ },
68
+ }
69
+
70
+
71
+ # digest credentials the caller answers challenges with (and the carrier then demands)
72
+ CREDENTIALS = ("RTPBENCH_SIP_USER", "RTPBENCH_SIP_PASSWORD")
73
+ # the names the stack's test certificate is for: the services that may serve TLS
74
+ TLS_NAMES = ["agent", "caller", "carrier", "operator", "target"]
75
+
76
+
77
+ def is_uri(name: str) -> bool:
78
+ """`rtpbench target`'s NAME is an agent's SIP URI, not a bundled target."""
79
+ return name.split(":", 1)[0].lower() in ("sip", "sips")
80
+
81
+
82
+ # compose takes these as ${VAR-default}, so set but empty is a value; the rest, ${VAR:-default}
83
+ EMPTY_IS_A_VALUE = {"OPENAI_REASONING_EFFORT"}
84
+
85
+
86
+ def _engine(settings: dict[str, str]) -> dict[str, str]:
87
+ """A target's engine settings as the conditions record them, as compose resolves them: no
88
+ account names, no keys."""
89
+ out = {}
90
+ for var, default in settings.items():
91
+ value = os.environ.get(var, default)
92
+ if not value and var not in EMPTY_IS_A_VALUE:
93
+ value = default
94
+ if "://" in value:
95
+ url = urlsplit(value)
96
+ domain = ".".join((url.hostname or "").split(".")[-3:])
97
+ value = f"{domain}?{url.query}" if url.query else domain
98
+ out[var] = value
99
+ return out
100
+
101
+
102
+ # netem arguments per direction, applied to both router egress interfaces; SIP is exempt.
103
+ # delay, jitter, loss, mobile: synthetic stimuli for the calibrations (each impairment alone,
104
+ # then together). lte, lte-bad: what a mobile caller's path does, from sources:
105
+ # - lte: 1% loss, the VoLTE (QCI 1) packet loss target per radio link (3GPP TR 26.989 5.1.1.6.1),
106
+ # in bursts of 2 (mean run in 3GPP's VoLTE delay/error profiles 7-10, EVS-7c S4-141392
107
+ # Annex D); jitter sd 3 ms (commercial LTE in mobility, Elnashar et al., arXiv 1810.02968,
108
+ # fig. 6); 40 ms one way (LTE uplink p99, Nielsen et al., arXiv 1909.03875, fig. 4).
109
+ # - lte-bad: 3GPP delay/error profile 8 (VoLTE logs in challenging radio conditions, EVS-7c
110
+ # Annex D): 6.2% loss, mean run 2.1 (Gilbert p 3.2%, r 48%); delay above the floor mean 45 ms,
111
+ # sd 26, p95 90. Delays chosen so that what netem delivers matches (measured over 5000
112
+ # packets: mean 48, sd 23, p95 88).
113
+ # `rate` keeps packets in order, as a radio bearer delivers them (independent netem jitter of
114
+ # sd 26 ms reorders 30% of packets; the profile 8 trace 3%).
115
+ PROFILES = {
116
+ "clean": "",
117
+ "delay": "delay 40ms",
118
+ "jitter": "delay 40ms 10ms distribution normal",
119
+ "loss": "loss 3%",
120
+ "mobile": "delay 60ms 20ms distribution normal loss 3%",
121
+ "lte": "delay 40ms 3ms distribution normal loss gemodel 0.505% 50% rate 100mbit",
122
+ "lte-bad": "delay 36ms 30ms distribution normal loss gemodel 3.2% 48% rate 100mbit",
123
+ }
124
+
125
+
126
+ DOCKER = 28 # the oldest Docker server the stacks run on
127
+ NO_SERVER = (
128
+ "Is the Docker daemon running? On WSL2: is Docker Desktop running, with its WSL integration "
129
+ "on for this distro? (With either off, `docker` may say it is not installed.)"
130
+ )
131
+
132
+
133
+ def docker() -> None:
134
+ """Exit unless a Docker server of version DOCKER or later answers, before a run starts."""
135
+ cmd = ["docker", "version", "--format", "{{.Server.Version}}"]
136
+ try:
137
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
138
+ except FileNotFoundError:
139
+ raise SystemExit(f"rtpbench needs Docker {DOCKER} or later: no docker command") from None
140
+ except subprocess.TimeoutExpired:
141
+ raise SystemExit(
142
+ f"rtpbench needs Docker {DOCKER} or later, and no Docker server answered in 30 s.\n"
143
+ + NO_SERVER
144
+ ) from None
145
+ version = res.stdout.strip()
146
+ major = version.split(".")[0]
147
+ if res.returncode or not major.isdigit():
148
+ said = textwrap.indent((res.stdout + res.stderr).strip(), " ")
149
+ raise SystemExit(
150
+ f"rtpbench needs Docker {DOCKER} or later, and no Docker server answered:\n"
151
+ f"{said}\n{NO_SERVER}"
152
+ )
153
+ if int(major) < DOCKER:
154
+ raise SystemExit(f"rtpbench needs Docker {DOCKER} or later; the server is {version}")
155
+
156
+
157
+ GATEWAY_MODE = "com.docker.network.bridge.gateway_mode_ipv4"
158
+
159
+
160
+ def network_problem(name: str) -> str | None:
161
+ """Why the router cannot route into the Docker network `name`, if it cannot. Docker's
162
+ default gateway mode drops routed packets addressed to a container, and every call then
163
+ times out without a word."""
164
+ cmd = ["docker", "network", "inspect", name, "--format", "{{json .Options}}"]
165
+ try:
166
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
167
+ except subprocess.TimeoutExpired:
168
+ return f"no answer from Docker in 30 s about network {name!r}"
169
+ if res.returncode and "not found" in res.stderr:
170
+ return f"{name!r} is not a Docker network on this host (docker network ls)"
171
+ if res.returncode:
172
+ return f"docker network inspect {name} failed: {res.stderr.strip()}"
173
+ mode = (json.loads(res.stdout) or {}).get(GATEWAY_MODE)
174
+ if mode in ("nat-unprotected", "routed"):
175
+ return None
176
+ return (
177
+ f"network {name!r} drops packets routed into it (gateway mode {mode or 'default'!r}); "
178
+ f"create it with `docker network create -o {GATEWAY_MODE}=nat-unprotected {name}`, or in "
179
+ f"compose.yaml give it `driver_opts: {{ {GATEWAY_MODE}: nat-unprotected }}`"
180
+ )
181
+
182
+
183
+ # where the stacks' images are pulled from when missing here; else they are built
184
+ REGISTRY = os.environ.get("RTPBENCH_REGISTRY", "ghcr.io/jjaroztegi")
185
+
186
+
187
+ def _image_tag() -> str:
188
+ """`rtpbench:` and a hash of what the image holds, by the paths it holds them at: the same
189
+ from a checkout and from an installed rtpbench."""
190
+ files = {f"src/rtpbench/{f.name}": f for f in PACKAGE.glob("*.py")}
191
+ corpus = ROOT / "corpus"
192
+ files |= {f"corpus/{f.relative_to(corpus).as_posix()}": f for f in corpus.rglob("*.wav")}
193
+ h = hashlib.sha256((ROOT / "Dockerfile").read_bytes())
194
+ for name in sorted(files):
195
+ h.update(name.encode() + files[name].read_bytes())
196
+ return "rtpbench:" + h.hexdigest()[:12]
197
+
198
+
199
+ def _build_hash(build: dict[str, Any]) -> str:
200
+ """A hash of a compose `build` section as `config` resolves it, with each local context's
201
+ files (all of them: the build may copy any) in place of its path, so the same from any
202
+ checkout or installed rtpbench. A git context is its URL, which pins the commit."""
203
+ build = dict(build)
204
+ contexts = {"": build.pop("context", "."), **(build.pop("additional_contexts", None) or {})}
205
+ h = hashlib.sha256(json.dumps(build, sort_keys=True).encode())
206
+ for name, where in sorted(contexts.items()):
207
+ root = Path(where)
208
+ h.update(f"\0{name}\0".encode())
209
+ if not root.is_absolute(): # a git URL
210
+ h.update(where.encode())
211
+ continue
212
+ files = (f for f in root.rglob("*") if f.is_file() and "__pycache__" not in f.parts)
213
+ for f in sorted(files):
214
+ h.update(f"\0{f.relative_to(root).as_posix()}\0".encode() + f.read_bytes())
215
+ return h.hexdigest()[:12]
216
+
217
+
218
+ class Stack:
219
+ """caller | router | agent, plus a target's services when given (on the mock engine with
220
+ `mock`), and the carrier in front of the host `carrier` names (paths in their compose files
221
+ are relative to ROOT). With `uri`, caller | router | operator (and the
222
+ carrier), for an agent outside the stack: the router also joins the Docker network
223
+ `network`, and with `ports` (first-last) the host publishes that range on the router. With
224
+ `tls`, out/tls holds a test certificate for the stack's TLS servers, and ca.pem, what the
225
+ caller trusts: `ca`, or with no `uri` the stack's test CA; else the caller trusts the
226
+ system's certificates. With `srtp`, the caller offers SRTP with these suites."""
227
+
228
+ def __init__(
229
+ self,
230
+ out: Path,
231
+ target: str | None = None,
232
+ carrier: str | None = None,
233
+ mock: bool = False,
234
+ uri: bool = False,
235
+ network: str | None = None,
236
+ ports: str | None = None,
237
+ tls: bool = False,
238
+ ca: Path | None = None,
239
+ srtp: list[str] | None = None,
240
+ ) -> None:
241
+ self.project = f"rtpbench-{secrets.token_hex(3)}"
242
+ self.env = os.environ | {
243
+ "RTPBENCH_IMAGE": f"{REGISTRY}/{_image_tag()}",
244
+ "RTPBENCH_REGISTRY": REGISTRY,
245
+ "RTPBENCH_SRC": PACKAGE.relative_to(ROOT).as_posix(), # in the image's build context
246
+ "RTPBENCH_OUT": str(out),
247
+ }
248
+ self.files = [ROOT / "compose.yaml"]
249
+ self.services = ["router", "caller", "operator"] if uri else []
250
+ self.passed: list[str] = [] # variables passed to the caller's commands, by name only
251
+ if network:
252
+ self.files.append(ROOT / "uri" / "network.yaml")
253
+ self.env["RTPBENCH_NETWORK"] = network
254
+ if ports:
255
+ self.files.append(ROOT / "uri" / "advertise.yaml")
256
+ self.env["RTPBENCH_PORTS"] = ports
257
+ if target:
258
+ self.files.append(ROOT / "targets" / target / "compose.yaml")
259
+ if target and mock:
260
+ self.files.append(ROOT / "mock" / "compose.yaml")
261
+ self.env |= MOCK[target]
262
+ if carrier:
263
+ self.files.append(ROOT / "carrier" / "compose.yaml")
264
+ self.env["RTPBENCH_CARRIER_NEXT"] = carrier
265
+ if uri:
266
+ self.services += ["carrier", "carrier-media", "carrier-sip"]
267
+ # digest credentials: in front of a bundled agent the carrier demands them of the
268
+ # caller; in front of one at a URI it answers the agent's challenge with them
269
+ role = "UAC" if uri else "AUTH"
270
+ for k in ("USER", "PASSWORD"):
271
+ self.env[f"RTPBENCH_CARRIER_{role}_{k}"] = os.environ.get(f"RTPBENCH_SIP_{k}", "")
272
+ self.carrier = carrier is not None
273
+ self.built: dict[str, str] = {} # each image the stack builds, by service: builds()
274
+ if tls:
275
+ where = out / "tls"
276
+ certificates(where, TLS_NAMES)
277
+ if carrier: # which takes the caller's leg over TLS
278
+ self.files.append(ROOT / "carrier" / "tls.yaml")
279
+ if ca or not uri:
280
+ shutil.copyfile(ca or where / "stack-ca.pem", where / "ca.pem")
281
+ self.env["RTPBENCH_TLS"] = "/out/tls"
282
+ self.passed.append("RTPBENCH_TLS")
283
+ if srtp:
284
+ self.env["RTPBENCH_SRTP"] = ",".join(srtp)
285
+ self.passed.append("RTPBENCH_SRTP")
286
+
287
+ def resolve(self, *services: str) -> None:
288
+ """The caller reaches these services by name (SIP over TLS checks the name)."""
289
+ names = ",".join(f"{s}={self.ip(s)}" for s in services)
290
+ self.env["RTPBENCH_RESOLVE"] = names
291
+ self.passed.append("RTPBENCH_RESOLVE")
292
+
293
+ def compose(
294
+ self, *args: str, check: bool = True, lines: Callable[[str], None] | None = None
295
+ ) -> str:
296
+ """Its output; with `lines`, its output and errors together, each line also passed to
297
+ `lines` as it is printed."""
298
+ files = [a for f in self.files for a in ("-f", str(f))]
299
+ cmd = ["docker", "compose", "-p", self.project, *files, *args]
300
+ if lines is None:
301
+ res = subprocess.run(cmd, env=self.env, capture_output=True, text=True)
302
+ code, out, err = res.returncode, res.stdout, res.stderr
303
+ else:
304
+ out = ""
305
+ with subprocess.Popen(
306
+ cmd, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
307
+ ) as proc:
308
+ for line in proc.stdout or ():
309
+ lines(line)
310
+ out += line
311
+ code, err = proc.returncode, out
312
+ if check and code:
313
+ raise RuntimeError(f"{' '.join(args)} failed:\n{err}")
314
+ return out.strip()
315
+
316
+ def follow(self, path: Path) -> subprocess.Popen[bytes]:
317
+ """Every service's log into `path` as it is written, until `down` stops them: read only
318
+ at the end, a log the Docker daemon rotates (log-opts max-size) has lost its start."""
319
+ files = [a for f in self.files for a in ("-f", str(f))]
320
+ cmd = ["docker", "compose", "--ansi", "never", "-p", self.project, *files, "logs", "-f"]
321
+ with path.open("wb") as f:
322
+ return subprocess.Popen(
323
+ [*cmd, "--no-color", "--timestamps"],
324
+ env=self.env,
325
+ stdout=f,
326
+ stderr=subprocess.STDOUT,
327
+ )
328
+
329
+ def sh(self, service: str, cmd: str, check: bool = True) -> str:
330
+ return self.compose("exec", "-T", service, "sh", "-c", cmd, check=check)
331
+
332
+ def caller(self, *args: str, lines: Callable[[str], None] | None = None) -> str:
333
+ """`rtpbench args` in the caller container."""
334
+ # by name: their values (credentials among them) never appear in a command line
335
+ passed = [*self.passed, *(k for k in CREDENTIALS if self.env.get(k))]
336
+ env = [a for name in passed for a in ("-e", name)]
337
+ cmd = ["exec", "-T", *env, "caller", "python", "-m", "rtpbench", *args]
338
+ return self.compose(*cmd, lines=lines)
339
+
340
+ def builds(self) -> dict[str, str]:
341
+ """Each image the stack builds, by service, as it pulls or builds them: rtpbench's own
342
+ (RTPBENCH_IMAGE), and the rest with their pinned tag followed by `-` and their
343
+ _build_hash, which the compose files take from RTPBENCH_BUILD_<SERVICE>."""
344
+ for k in [k for k in self.env if k.startswith("RTPBENCH_BUILD_")]:
345
+ del self.env[k]
346
+ services = json.loads(self.compose("config", "--format", "json"))["services"]
347
+ for name, service in services.items():
348
+ if "build" not in service:
349
+ continue
350
+ image = service["image"]
351
+ if image != self.env["RTPBENCH_IMAGE"]:
352
+ suffix = f"-{_build_hash(service['build'])}"
353
+ self.env[f"RTPBENCH_BUILD_{name.upper().replace('-', '_')}"] = suffix
354
+ image += suffix
355
+ self.built[name] = image
356
+ return self.built
357
+
358
+ def up(self, phase: Callable[[str], None], profiles: Sequence[str] = ()) -> None:
359
+ """Pull or else build the images missing here and start the stack; with an impaired
360
+ profile among `profiles`, exit before the first call if the kernel lacks what `netem`
361
+ needs, not after the clean calls."""
362
+ phase("pulling or building images")
363
+ self.builds()
364
+ self.compose("create", "--quiet-pull", *self.services)
365
+ phase("starting the stack")
366
+ self.compose("up", "-d", "--wait", *self.services)
367
+ impaired = [p for p in profiles if PROFILES[p]]
368
+ if not impaired:
369
+ return
370
+ probe = (
371
+ "tc qdisc add dev lo root handle 1: prio"
372
+ " && tc qdisc add dev lo parent 1:3 handle 30: netem delay 1ms"
373
+ " && tc filter add dev lo parent 1: protocol ip prio 1 u32 match ip protocol 17 0xff"
374
+ " flowid 1:1; r=$?; tc qdisc del dev lo root 2>/dev/null; exit $r"
375
+ )
376
+ try:
377
+ self.sh("router", probe)
378
+ except RuntimeError as e:
379
+ said = str(e).partition("failed:\n")[2].strip()
380
+ raise SystemExit(
381
+ f"--profiles {','.join(impaired)} need the kernel's prio and netem qdiscs and u32 "
382
+ f"filter (sch_prio, sch_netem, cls_u32), and this kernel refused: {said}\n"
383
+ "Only the clean profile runs on this host."
384
+ ) from None
385
+
386
+ def down(self) -> None:
387
+ self.compose("down", "-t", "0", "-v", "--remove-orphans", check=False)
388
+ # the test certificate's key is no use to judging; the CAs stay with the results
389
+ (Path(self.env["RTPBENCH_OUT"]) / "tls" / "server.key").unlink(missing_ok=True)
390
+
391
+ def ip(self, service: str) -> str:
392
+ return self.sh("router", f"getent hosts {service}").split()[0]
393
+
394
+ def entry(self, service: str) -> str:
395
+ """Where the caller sends its calls for `service` (the agent, a target, the operator):
396
+ the service, or the carrier."""
397
+ return self.ip("carrier" if self.carrier else service)
398
+
399
+ @contextmanager
400
+ def tap(self, rel: str) -> Iterator[None]:
401
+ """With a carrier: capture both of its legs into `rel`/carrier.pcap meanwhile."""
402
+ if not self.carrier:
403
+ yield
404
+ return
405
+ pcap, err = f"/out/{rel}/carrier.pcap", f"/tmp/tap-{secrets.token_hex(3)}"
406
+ cmd = "tcpdump -i eth0 -n -U --immediate-mode -B 32768 --time-stamp-precision=nano"
407
+ pid = self.sh("carrier", f"{cmd} -w {pcap} udp or tcp >/dev/null 2>{err} & echo $!")
408
+ _wait_for(Path(self.env["RTPBENCH_OUT"]) / rel / "carrier.pcap", 10)
409
+ try:
410
+ yield
411
+ finally:
412
+ time.sleep(0.2)
413
+ self.sh(
414
+ "carrier", f"kill -INT {pid}; while kill -0 {pid} 2>/dev/null; do sleep 0.1; done"
415
+ )
416
+ stats = self.sh("carrier", f"cat {err}")
417
+ if "\n0 packets dropped by kernel" not in stats:
418
+ raise RuntimeError(f"tcpdump dropped packets on the carrier:\n{stats}")
419
+
420
+ def dev(self, ip: str) -> str:
421
+ """The router's interface towards `ip`."""
422
+ return self.sh("router", f"ip -o route get {ip}").split(" dev ")[1].split()[0]
423
+
424
+ def netem(self, args: str, peers: list[str], sip: tuple[int, ...] = (5060,)) -> None:
425
+ """netem on the router's way to each of `peers`; SIP to or from the ports `sip` exempt."""
426
+ for ip in peers:
427
+ dev = self.dev(ip)
428
+ self.sh("router", f"tc qdisc del dev {dev} root 2>/dev/null || true")
429
+ if not args:
430
+ continue
431
+ prio = f"root handle 1: prio bands 3 priomap {' '.join(['2'] * 16)}"
432
+ self.sh("router", f"tc qdisc add dev {dev} {prio}")
433
+ self.sh("router", f"tc qdisc add dev {dev} parent 1:3 handle 30: netem {args}")
434
+ # UDP, TCP
435
+ for proto, way, port in itertools.product((17, 6), ("sport", "dport"), sip):
436
+ match = (
437
+ f"u32 match ip protocol {proto} 0xff match ip {way} {port} 0xffff flowid 1:1"
438
+ )
439
+ self.sh("router", f"tc filter add dev {dev} parent 1: protocol ip prio 1 {match}")
440
+
441
+
442
+ def _wait_for(path: Path, timeout: float) -> None:
443
+ deadline = time.monotonic() + timeout
444
+ while not path.exists():
445
+ if time.monotonic() > deadline:
446
+ raise TimeoutError(f"{path} did not appear")
447
+ time.sleep(0.1)
448
+
449
+
450
+ def _host(cmd: str) -> str:
451
+ return subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=ROOT).stdout.strip()
452
+
453
+
454
+ def _commit() -> str:
455
+ """Counted from a release (results' wave tags would otherwise name the commit); installed,
456
+ the release itself: there is no checkout to describe."""
457
+ if ROOT == PACKAGE:
458
+ from importlib.metadata import version
459
+
460
+ return f"v{version('rtpbench')}"
461
+ return _host("git describe --always --dirty --match 'v[0-9]*'")
462
+
463
+
464
+ def conditions(
465
+ sh: Callable[[str], str],
466
+ seed: int,
467
+ tls: Path | None = None,
468
+ srtp: list[str] | None = None,
469
+ proxy_tls: Path | None = None,
470
+ ) -> dict[str, Any]:
471
+ """Versions and host; `sh` runs a command in the stack (where the capture runs); `tls`, the
472
+ results' tls/ directory when SIP goes over TLS; `srtp`, the SRTP suites offered;
473
+ `proxy_tls`, that directory when a transfer's proxy takes TLS (sips:)."""
474
+ where = tls or proxy_tls
475
+ ca = where / "ca.pem" if where else None
476
+ trust = (
477
+ f"ca sha256:{hashlib.sha256(ca.read_bytes()).hexdigest()}"
478
+ if ca and ca.exists()
479
+ else "system"
480
+ )
481
+ governor = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
482
+ return {
483
+ "date": datetime.now(UTC).isoformat(timespec="seconds"),
484
+ "commit": _commit(),
485
+ "image": _image_tag(), # the code the stacks ran, whatever the history
486
+ "kernel": sh("uname -r"),
487
+ "cpus": os.cpu_count(), # how many, not which model: published conditions name no hardware
488
+ "docker": _host("docker version --format '{{.Server.Version}}'"),
489
+ "python": sh("python3 -V"),
490
+ "tcpdump": sh("tcpdump --version 2>&1 | head -1"),
491
+ "host": platform.platform(),
492
+ "cpu_governor": governor.read_text().strip() if governor.exists() else "",
493
+ "load_start": _load(),
494
+ "seed": seed,
495
+ "sip_auth": all(os.environ.get(k) for k in CREDENTIALS), # whether, never what
496
+ "tls": tls is not None,
497
+ **({"proxy_tls": True} if proxy_tls else {}),
498
+ **({"tls_trust": trust} if where else {}),
499
+ "srtp": srtp, # the suites offered; the keys are in each call's SDP
500
+ "netem_per_direction": PROFILES,
501
+ }
502
+
503
+
504
+ def _load() -> list[float]:
505
+ """The host's load averages over 1, 5 and 15 minutes."""
506
+ return [float(x) for x in Path("/proc/loadavg").read_text().split()[:3]]
507
+
508
+
509
+ def _finish(conds: dict[str, Any], steps: list[dict[str, Any]]) -> dict[str, Any]:
510
+ """What the run saw while it ran: wall-clock steps (`clock.watch`) and the clock's rate
511
+ between them, and the load at its end."""
512
+ rate = round(clock.CLOCK.rate(), 1)
513
+ return conds | {"clock_steps": steps, "clock_rate_ppm": rate, "load_end": _load()}
514
+
515
+
516
+ def calibrate(
517
+ out: Path,
518
+ profiles: list[str],
519
+ turns: int | None,
520
+ seed: int,
521
+ codecs: list[str] | None = None,
522
+ real: bool = False,
523
+ replies: Path | None = None,
524
+ barge: bool = False,
525
+ carrier: bool = False,
526
+ tls: bool = False,
527
+ srtp: list[str] | None = None,
528
+ *,
529
+ progress: Progress,
530
+ ) -> str:
531
+ """Timing calibration; with `real` the speech-detection one (the agent replies with corpus
532
+ clips, or the WAVs in `replies`); with `barge` the barge-in one; with `carrier` through the
533
+ carrier; with `tls` SIP over TLS; with `srtp` SRTP. Each profile runs once per codec."""
534
+ out.mkdir(parents=True, exist_ok=True)
535
+ out = out.resolve()
536
+ agent_args, clips = [], None
537
+ if real:
538
+ if replies:
539
+ shutil.copytree(replies, out / "replies", dirs_exist_ok=True)
540
+ agent_args = ["--replies", "/out/replies"]
541
+ clips = {p.stem: speech.read_wav(p) for p in sorted((out / "replies").glob("*.wav"))}
542
+ else:
543
+ clips = {
544
+ n: pcm for n, pcm in speech.clips("tts-", "human-").items() if "-long" not in n
545
+ }
546
+ turns = turns or len(clips)
547
+ gap = max(map(len, clips.values())) / 8000 + SPEECH_DELAY_RANGE[1] + 1.0
548
+ agent_args += ["--speech"]
549
+ if barge:
550
+ agent_args = ["--barge-in"]
551
+ turns = turns or (60 if barge else 20)
552
+ runs = [(p, c) for p in profiles for c in codecs or [""]]
553
+ names = {(p, c): f"{p}-{c}" if c else p for p, c in runs}
554
+ progress.plan(len(runs))
555
+ stack = Stack(out, carrier="agent" if carrier else None, tls=tls, srtp=srtp)
556
+ try:
557
+ stack.up(progress.phase, profiles)
558
+ caller_ip, agent_ip = stack.ip("caller"), stack.entry("agent")
559
+ uri, sip, agent_args = _reference(stack, agent_ip, tls, agent_args)
560
+ conds = conditions(partial(stack.sh, "router"), seed, out / "tls" if tls else None, srtp)
561
+ conds |= {"turns_per_profile": turns}
562
+ conds |= {"codecs": codecs, "speech": real, "barge": barge, "carrier": carrier}
563
+ if replies:
564
+ conds["replies"] = sorted(clips or {})
565
+ conds["builds"] = stack.built
566
+ if carrier:
567
+ conds["images"] = stack.compose("images")
568
+ (out / "conditions.json").write_text(json.dumps(conds, indent=1))
569
+ with clock.watch() as steps:
570
+ for profile, codec in runs:
571
+ name = names[profile, codec]
572
+ run = out / name
573
+ run.mkdir(exist_ok=True)
574
+ progress.unit(name)
575
+ stack.netem(PROFILES[profile], [caller_ip, agent_ip], sip)
576
+ args = ["--out", f"/out/{name}", "--seed", str(seed)]
577
+ agent = ["python", "-m", "rtpbench", "agent", *args, *agent_args]
578
+ stack.compose("exec", "-d", "agent", *agent)
579
+ _wait_for(run / "ready", 120)
580
+ call = ["call", uri, "--turns", str(turns), *args]
581
+ if codec:
582
+ call += ["--codec", codec]
583
+ if real:
584
+ call += ["--speech", "--gap", f"{gap:.1f}"]
585
+ if barge:
586
+ call += ["--barge-in"]
587
+ with stack.tap(name):
588
+ stack.caller(*call, lines=progress.turn)
589
+ _wait_for(run / "agent.json", 30)
590
+ progress.done()
591
+ (out / "conditions.json").write_text(json.dumps(_finish(conds, steps), indent=1))
592
+ finally:
593
+ if hasattr(os, "getuid"): # tcpdump wrote as root; hand the results back
594
+ stack.sh("caller", f"chown -R {os.getuid()}:{os.getgid()} /out", check=False)
595
+ stack.down()
596
+ progress.phase("analysing")
597
+ dirs = {names[r]: [out / names[r]] for r in runs}
598
+ if barge:
599
+ summary = bargein.summarize(dirs)
600
+ else:
601
+ summary = speechcal.summarize(dirs, clips) if real else analyze.summarize(dirs)
602
+ summary += "\n" + clock.note(clock.steps(out)) + clock.rate_note(out)
603
+ (out / "summary.md").write_text(summary)
604
+ return summary
605
+
606
+
607
+ def cases(
608
+ out: Path,
609
+ names: list[str],
610
+ profiles: list[str],
611
+ runs: int,
612
+ seed: int,
613
+ carrier: bool = False,
614
+ tls: bool = False,
615
+ srtp: list[str] | None = None,
616
+ *,
617
+ progress: Progress,
618
+ ) -> tuple[str, bool]:
619
+ """Conformance cases against the reference agent: each behaviour of each case, in Docker
620
+ behind the netem router and, with `carrier`, the carrier, `runs` times per profile, with
621
+ `tls` over TLS, with `srtp` SRTP. Repeat k uses seed + k - 1. Returns the summary and
622
+ whether every case was proven in every repeat."""
623
+ from rtpbench import cases as cs
624
+
625
+ out.mkdir(parents=True, exist_ok=True)
626
+ out = out.resolve()
627
+ done: list[cs.Run] = []
628
+ progress.plan(len(profiles) * sum(len(cs.CASES[n].behaviours()) for n in names) * runs)
629
+ with clock.watch() as steps:
630
+ stack = Stack(out, carrier="agent" if carrier else None, tls=tls, srtp=srtp)
631
+ try:
632
+ stack.up(progress.phase, profiles)
633
+ caller_ip, agent_ip = stack.ip("caller"), stack.entry("agent")
634
+ proxy = stack.entry("operator") # the transferee's next hop for a transfer
635
+ uri, sip, agent_args = _reference(stack, agent_ip, tls, [])
636
+ conds = conditions(
637
+ partial(stack.sh, "router"), seed, out / "tls" if tls else None, srtp
638
+ )
639
+ conds |= {"profiles": profiles, "runs": runs, "carrier": carrier}
640
+ conds["builds"] = stack.built
641
+ if carrier:
642
+ conds["images"] = stack.compose("images")
643
+ for profile in profiles:
644
+ stack.netem(PROFILES[profile], [caller_ip, agent_ip], sip)
645
+ for k in range(1, runs + 1):
646
+ for name in names:
647
+ for behaviour in cs.CASES[name].behaviours():
648
+ rel = f"{profile}/{k:02d}/{name}-{behaviour}"
649
+ (out / rel).mkdir(parents=True, exist_ok=True)
650
+ progress.unit(f"{profile} {name}/{behaviour}{_repeat(k, runs)}")
651
+ args = ["--out", f"/out/{rel}", "--seed", str(seed + k - 1)]
652
+ agent = ["agent", *args, "--case", name, "--behaviour", behaviour]
653
+ agent += agent_args
654
+ stack.compose("exec", "-d", "agent", "python", "-m", "rtpbench", *agent)
655
+ _wait_for(out / rel / "ready", 120)
656
+ call = ["case", name, uri, "--proxy", proxy]
657
+ with stack.tap(rel):
658
+ stack.caller(*call, *args)
659
+ _wait_for(out / rel / "agent.json", 30)
660
+ done.append(cs.Run(profile, k, name, behaviour, rel))
661
+ _verdict(progress, out / rel, profile, behaviour)
662
+ finally:
663
+ if hasattr(os, "getuid"): # tcpdump wrote as root; hand the results back
664
+ stack.sh("caller", f"chown -R {os.getuid()}:{os.getgid()} /out", check=False)
665
+ stack.down()
666
+ (out / "conditions.json").write_text(json.dumps(_finish(conds, steps), indent=1))
667
+ progress.phase("judging")
668
+ return judge(out, done)
669
+
670
+
671
+ def _reference(
672
+ stack: Stack, agent_ip: str, tls: bool, agent_args: list[str]
673
+ ) -> tuple[str, tuple[int, ...], list[str]]:
674
+ """The reference agent's URI, its SIP ports (exempt from netem) and its arguments: over TLS
675
+ by name, as its certificate has it (the carrier's, through the carrier, which takes TLS on
676
+ the caller's leg only)."""
677
+ if not tls:
678
+ return f"sip:agent@{agent_ip}", (5060,), agent_args
679
+ if stack.carrier:
680
+ stack.resolve("carrier")
681
+ return "sips:agent@carrier", (5060, TLS_PORT), agent_args
682
+ stack.resolve("agent")
683
+ return "sips:agent@agent", (5060, TLS_PORT), [*agent_args, "--tls", "/out/tls"]
684
+
685
+
686
+ def _repeat(k: int, runs: int) -> str:
687
+ return f" (repeat {k} of {runs})" if runs > 1 else ""
688
+
689
+
690
+ def _verdict(progress: Progress, run: Path, profile: str, behaviour: str | None) -> None:
691
+ """End the case's unit with its verdict, judged now so a wrong one shows as it happens (the
692
+ summary reuses it)."""
693
+ from rtpbench import cases as cs
694
+
695
+ try:
696
+ r = cs.judge(run, profile)
697
+ except Exception as e: # the summary judges it again, and raises
698
+ progress.done(f"not judged: {e}", False)
699
+ return
700
+ failed = ", ".join(c.name for c in r.checks if not c.ok)
701
+ want = cs.expected(behaviour)
702
+ if want is None: # a real target: report what failed
703
+ ok = {"pass": True, "fail": False}.get(r.verdict)
704
+ progress.done(r.verdict + (f" ({failed})" if failed else ""), ok)
705
+ elif r.verdict == want:
706
+ progress.done(f"{r.verdict} as expected", True)
707
+ else:
708
+ progress.done(f"{r.verdict}, expected {want}" + (f" ({failed})" if failed else ""), False)
709
+
710
+
711
+ def check_lines(lines: Path, names: list[str]) -> list[str]:
712
+ """What keeps `lines` from serving the calls in `names`: each line they need (as in
713
+ corpus/lines/en) missing, not 8 kHz mono 16-bit, or without speech the labeller can find."""
714
+ from rtpbench import cases as cs
715
+
716
+ need = {
717
+ "prompt.wav": any(n in cs.CASES for n in names),
718
+ "transfer.wav": any(n in ("refer", "refer-fail") for n in names),
719
+ "turn-*.wav": "latency" in names,
720
+ "prompt-*.wav": "barge-in" in names,
721
+ "interrupt-*.wav": "barge-in" in names,
722
+ }
723
+ problems = []
724
+ for pattern in (p for p, needed in need.items() if needed):
725
+ if not (files := sorted(lines.glob(pattern))):
726
+ problems.append(f"no {pattern}")
727
+ for f in files:
728
+ try:
729
+ pcm = speech.read_wav(f)
730
+ except ValueError:
731
+ problems.append(f"{f.name}: not 8 kHz mono 16-bit PCM")
732
+ continue
733
+ except OSError as e: # a directory, or a file this user may not read
734
+ problems.append(f"{f.name}: {e.strerror}")
735
+ continue
736
+ try:
737
+ if not speech.label(pcm):
738
+ raise ValueError("no speech")
739
+ except ValueError as e:
740
+ problems.append(f"{f.name}: {e}")
741
+ return problems
742
+
743
+
744
+ def _reach(
745
+ stack: Stack, caller_ip: str, host: str, network: str | None, advertise: str | None
746
+ ) -> tuple[str, str | None]:
747
+ """Set the router up for an agent outside the stack, at `host`: replies come back through
748
+ it (so netem holds both ways, not only out) and, when the caller advertises an address
749
+ (`advertise`, or with `network` the router's own there), it forwards the caller's ports.
750
+ Returns the agent's address as the router resolves it and the advertised HOST:PORT."""
751
+ from rtpbench.caller import ADVERTISE_PORT, ADVERTISE_PORTS
752
+
753
+ ip = _resolve(stack, host)
754
+ stack.env["RTPBENCH_RESOLVE"] = f"{host}={ip}"
755
+ stack.passed.append("RTPBENCH_RESOLVE")
756
+ callnet = stack.dev(caller_ip)
757
+ stack.sh("router", f"iptables -t nat -A POSTROUTING ! -o {callnet} -j MASQUERADE")
758
+ if network and not advertise:
759
+ route = stack.sh("router", f"ip -o route get {ip}").split()
760
+ advertise = f"{route[route.index('src') + 1]}:{ADVERTISE_PORT}"
761
+ if advertise:
762
+ first = int(advertise.rpartition(":")[2])
763
+ span = f"{first}:{first + ADVERTISE_PORTS - 1}"
764
+ dnat = f"-j DNAT --to-destination {caller_ip}"
765
+ for proto in ("udp", "tcp"):
766
+ match = f"! -i {callnet} -p {proto} --dport {span}"
767
+ stack.sh("router", f"iptables -t nat -A PREROUTING {match} {dnat}")
768
+ stack.env["RTPBENCH_ADVERTISE"] = advertise
769
+ stack.passed.append("RTPBENCH_ADVERTISE")
770
+ return ip, advertise
771
+
772
+
773
+ def _carry(stack: Stack, caller_ip: str, host: str) -> str:
774
+ """Set the carrier up for an agent outside the stack, at `host`: the caller's leg through
775
+ the router, the agent's through Docker's gateway, which keeps it out of netem as with a
776
+ bundled target. Returns the carrier's address, where the caller sends its calls."""
777
+ stack.env["RTPBENCH_RESOLVE"] = f"{host}={_resolve(stack, host)}" # refused here if not
778
+ stack.passed.append("RTPBENCH_RESOLVE")
779
+ net = json.loads(_host(f"docker network inspect {stack.project}_agentnet"))
780
+ gateway = net[0]["IPAM"]["Config"][0]["Gateway"]
781
+ router = stack.sh("carrier", "getent hosts router").split()[0]
782
+ stack.sh("carrier", f"ip route replace default via {gateway}")
783
+ stack.sh("carrier", f"ip route replace {caller_ip} via {router}")
784
+ return stack.ip("carrier")
785
+
786
+
787
+ def _resolve(stack: Stack, host: str) -> str:
788
+ """`host`'s address as the router finds it: a name on the agent's Docker network resolves
789
+ only there."""
790
+ found = stack.sh("router", f"getent ahostsv4 {shlex.quote(host)}", check=False).split()
791
+ if not found:
792
+ raise RuntimeError(f"{host} does not resolve from the stack")
793
+ return found[0]
794
+
795
+
796
+ def target(
797
+ out: Path,
798
+ name: str,
799
+ names: list[str],
800
+ profiles: list[str],
801
+ runs: int,
802
+ seed: int,
803
+ carrier: bool = False,
804
+ mock: bool = False,
805
+ lines: Path | None = None,
806
+ proxy: str | None = None,
807
+ network: str | None = None,
808
+ advertise: str | None = None,
809
+ ca: Path | None = None,
810
+ srtp: list[str] | None = None,
811
+ tls: bool = False,
812
+ *,
813
+ progress: Progress,
814
+ ) -> str:
815
+ """The cases, a turn-latency call and a barge-in call (those of them in `names`) against
816
+ the compose target `name` (on the mock engine with `mock`) or the agent at the SIP URI
817
+ `name`, placed from the caller container through the netem router (and, with `carrier`, the
818
+ carrier), `runs` times per profile (repeat k uses seed + k - 1); returns the summary.
819
+
820
+ `lines`: the caller's lines instead of corpus/lines/en. For a URI: `proxy` is where a
821
+ transfer's INVITE goes (else the stack's operator); the router joins the Docker network
822
+ `network`; the caller advertises `advertise` (HOST:PORT, the host publishing PORT and the
823
+ next few to it), else with `network` its router's address there; over TLS (a sips: URI or
824
+ proxy, or transport=tls) it trusts the certificates in `ca`, else the system's; with `srtp`
825
+ the caller offers SRTP with these suites. A compose target takes `tls` and `srtp` with
826
+ `carrier` only, which takes them on the caller's leg."""
827
+ from rtpbench import cases as cs
828
+ from rtpbench import realtime
829
+ from rtpbench.caller import ADVERTISE_PORTS, parse_uri
830
+ from rtpbench.sip import secure, transport
831
+
832
+ out.mkdir(parents=True, exist_ok=True)
833
+ out = out.resolve()
834
+ said = LINES
835
+ if lines:
836
+ said = out / "lines"
837
+ shutil.copytree(lines, said, dirs_exist_ok=True)
838
+
839
+ def inside(*paths: Path) -> list[str]: # as the caller container sees them
840
+ return [
841
+ f"/out/{p.relative_to(out).as_posix()}"
842
+ if p.is_relative_to(out)
843
+ else f"/app/{p.relative_to(ROOT).as_posix()}"
844
+ for p in paths
845
+ ]
846
+
847
+ def wavs(pattern: str) -> list[str]:
848
+ return inside(*sorted(said.glob(pattern)))
849
+
850
+ done: list[cs.Run] = []
851
+ with clock.watch() as steps:
852
+ tls = secure(name) if is_uri(name) else tls
853
+ if is_uri(name):
854
+ host, port = parse_uri(name)
855
+ ports = None
856
+ if advertise: # the host publishes the range
857
+ first = int(advertise.rpartition(":")[2])
858
+ ports = f"{first}-{first + ADVERTISE_PORTS - 1}"
859
+ proxy_tls = bool(proxy and secure(proxy))
860
+ trust = tls or proxy_tls # the caller's TLS setup, and --ca
861
+ next_hop = host if carrier else None
862
+ stack = Stack(
863
+ out,
864
+ None,
865
+ next_hop,
866
+ uri=True,
867
+ network=network,
868
+ ports=ports,
869
+ tls=trust,
870
+ ca=ca,
871
+ srtp=srtp,
872
+ )
873
+ if carrier:
874
+ stack.env["RTPBENCH_CARRIER_PORT"] = str(port)
875
+ stack.env["RTPBENCH_CARRIER_TRANSPORT"] = transport(name).lower()
876
+ else:
877
+ proxy_tls = False
878
+ stack = Stack(out, name, "target" if carrier else None, mock, tls=tls, srtp=srtp)
879
+ logs = None
880
+ try:
881
+ stack.up(progress.phase, profiles)
882
+ logs = stack.follow(out / "target.log")
883
+ caller_ip = stack.ip("caller")
884
+ where = out / "tls"
885
+ conds = conditions(
886
+ partial(stack.sh, "router"),
887
+ seed,
888
+ where if tls else None,
889
+ srtp,
890
+ where if proxy_tls else None,
891
+ )
892
+ conds |= {"profiles": profiles, "runs": runs, "target": name, "carrier": carrier}
893
+ conds["mock"] = mock
894
+ sip: tuple[int, ...] = (5060,) # SIP ports netem lets through
895
+ if is_uri(name) and carrier: # the carrier calls `name`'s user at its host and port
896
+ target_ip = _carry(stack, caller_ip, host)
897
+ user = name.partition(":")[2].partition("@")[0] if "@" in name else ""
898
+ uri = f"sip:{user}@{target_ip}" if user else f"sip:{target_ip}"
899
+ elif is_uri(name):
900
+ target_ip, advertise = _reach(stack, caller_ip, host, network, advertise)
901
+ uri, sip = name, tuple(dict.fromkeys((port, 5060)))
902
+ if is_uri(name):
903
+ if proxy: # where a transfer goes: by name (TLS checks it), as the router finds it
904
+ proxy_host = parse_uri(proxy)[0]
905
+ stack.env["RTPBENCH_RESOLVE"] += f",{proxy_host}={_resolve(stack, proxy_host)}"
906
+ else: # whatever the Refer-To names: a test never calls a real transferee
907
+ proxy = stack.sh("operator", "hostname -i").split()[0] # not the agent's
908
+ conds |= {"network": network, "advertise": advertise}
909
+ else:
910
+ user, settings = TARGETS[name]
911
+ target_ip, sip = stack.entry("target"), (5060, TLS_PORT) if tls else (5060,)
912
+ router_ip = stack.sh("target", "getent hosts router").split()[0]
913
+ stack.sh("target", f"ip route replace {caller_ip} via {router_ip}")
914
+ conds["engine"] = realtime.conditions() if mock else _engine(settings)
915
+ if mock and (own := {k: v for k, v in settings.items() if k not in MOCK[name]}):
916
+ conds["settings"] = _engine(own) # the target's own, such as AVA's pacer
917
+ uri, proxy = f"sip:{user}@{target_ip}", stack.entry("operator")
918
+ if tls: # through the carrier, by the name its certificate has
919
+ stack.resolve("carrier")
920
+ uri = f"sips:{user}@carrier"
921
+ if lines:
922
+ conds["lines"] = {
923
+ f.name: hashlib.sha256(f.read_bytes()).hexdigest()
924
+ for f in sorted(said.glob("*.wav"))
925
+ }
926
+ conds["images"] = stack.compose("images")
927
+ conds["builds"] = stack.built
928
+ transfer = ["--proxy", proxy] if proxy else []
929
+ turns = wavs("turn-*.wav")
930
+ latency = ["--turns", str(len(turns)), "--lead", "10", "--gap", "15", "--wav", *turns]
931
+ barge = ["--turns", "10", "--lead", "10", "--barge-in", "--wav", *wavs("prompt-*.wav")]
932
+ calls = {
933
+ **{c: ["case", c, uri, *transfer, "--lines", *inside(said)] for c in cs.CASES},
934
+ "latency": ["call", uri, *latency],
935
+ "barge-in": ["call", uri, *barge, "--interrupt", *wavs("interrupt-*.wav")],
936
+ }
937
+ calls = {tag: call for tag, call in calls.items() if tag in names}
938
+ progress.plan(len(profiles) * runs * len(calls))
939
+ for profile in profiles:
940
+ stack.netem(PROFILES[profile], [caller_ip, target_ip], sip)
941
+ for k in range(1, runs + 1):
942
+ for tag, call in calls.items():
943
+ rel = f"{profile}/{k:02d}/{tag}"
944
+ progress.unit(f"{profile} {tag}{_repeat(k, runs)}")
945
+ args = ["--out", f"/out/{rel}", "--seed", str(seed + k - 1)]
946
+ (out / rel).mkdir(parents=True, exist_ok=True)
947
+ try:
948
+ with stack.tap(rel):
949
+ stack.caller(*call, *args, lines=progress.turn)
950
+ except RuntimeError as e:
951
+ if tag in cs.CASES:
952
+ raise
953
+ # the target refused or broke a latency or barge-in call: keep its
954
+ # capture and the error, and go on; the summary lists it
955
+ (out / rel / "error.txt").write_text(str(e))
956
+ progress.done("did not complete", False)
957
+ continue
958
+ if tag in cs.CASES:
959
+ _verdict(progress, out / rel, profile, None)
960
+ else:
961
+ progress.done()
962
+ done += [
963
+ cs.Run(profile, k, c, None, f"{profile}/{k:02d}/{c}")
964
+ for c in names
965
+ if c in cs.CASES
966
+ ]
967
+ finally:
968
+ if hasattr(os, "getuid"): # tcpdump wrote as root; hand the results back
969
+ stack.sh("caller", f"chown -R {os.getuid()}:{os.getgid()} /out", check=False)
970
+ stack.down()
971
+ if logs: # ends with the containers
972
+ try:
973
+ logs.wait(timeout=30)
974
+ except subprocess.TimeoutExpired:
975
+ logs.kill()
976
+ # "<service> exited" lines come after a line erase; the log can run to GBs
977
+ log, tmp = out / "target.log", out / "target.log.tmp"
978
+ with log.open("rb") as src, tmp.open("wb") as dst:
979
+ dst.writelines(line.replace(b"\r\x1b[K", b"") for line in src)
980
+ tmp.replace(log)
981
+ if is_uri(name) and (out / "realtime.jsonl").exists(): # a mock logging here: the engine
982
+ conds["engine"] = realtime.conditions()
983
+ (out / "conditions.json").write_text(json.dumps(_finish(conds, steps), indent=1))
984
+ progress.phase("judging")
985
+ return judge(out, done)[0]
986
+
987
+
988
+ def _last_line(path: Path) -> str:
989
+ """The error's last line: the caller's exception, when it raised one."""
990
+ return (path.read_text().strip().splitlines() or [""])[-1]
991
+
992
+
993
+ def _secure_note(out: Path) -> str:
994
+ """How many calls went over each TLS version and cipher suite and each SRTP suite, if any
995
+ did, and how many captured SRTP packets no key in their call's SDP opened."""
996
+ from rtpbench import cases as cs
997
+
998
+ tls: dict[str, int] = {}
999
+ srtp: dict[str, int] = {}
1000
+ unopened = 0
1001
+ for path in sorted(out.glob("**/caller.json")):
1002
+ log = json.loads(path.read_text())
1003
+ if t := log.get("tls"):
1004
+ key = f"{t['version']} {t['cipher']}"
1005
+ tls[key] = tls.get(key, 0) + 1
1006
+ if "srtp" in log:
1007
+ key = log["srtp"] or "none"
1008
+ srtp[key] = srtp.get(key, 0) + 1
1009
+ unopened += cs.load(path.parent).unopened if "error" not in log else 0
1010
+ text = ""
1011
+ for title, seen in (("SIP over TLS", tls), ("SRTP", srtp)):
1012
+ if seen:
1013
+ text += f"\n{title}: " + ", ".join(f"{k} ({n} calls)" for k, n in seen.items()) + "\n"
1014
+ if srtp:
1015
+ text += f"SRTP packets that did not authenticate: {unopened}\n"
1016
+ return text
1017
+
1018
+
1019
+ def judge(out: Path, runs: list[Any] | None = None) -> tuple[str, bool]:
1020
+ """Verdicts for the case runs of a results directory (by default the ones its results.json
1021
+ lists), plus turn latency and barge-in pooled per profile if it has those calls:
1022
+ summary.md, results.json and report.html. Returns the summary and whether every reference
1023
+ case was proven in every repeat."""
1024
+ from rtpbench import cases as cs
1025
+ from rtpbench import realtime, report
1026
+
1027
+ if runs is None:
1028
+ recs = json.loads((out / "results.json").read_text())
1029
+ runs = [cs.Run(*(r[f] for f in cs.Run._fields)) for r in recs]
1030
+ summary, records, ok = cs.summarize(out, runs)
1031
+ summary += _secure_note(out)
1032
+ sections: list[tuple[str, str]] = []
1033
+ # the agent's stream in every call where it behaves as it should
1034
+ agent = [(r.profile, out / r.run) for r in runs if r.behaviour in (None, "good")]
1035
+ for sub, title, pooled in (
1036
+ ("latency", "Turn latency", speechcal.pooled),
1037
+ ("barge-in", "Barge-in", bargein.pooled),
1038
+ ):
1039
+ calls: dict[str, list[Path]] = {}
1040
+ for path in sorted(out.glob(f"*/*/{sub}/caller.json")):
1041
+ calls.setdefault(path.parts[-4], []).append(path.parent)
1042
+ agent.append((path.parts[-4], path.parent))
1043
+ broken = sorted(out.glob(f"*/*/{sub}/error.txt"))
1044
+ if calls or broken:
1045
+ text = pooled(calls, clock.steps(out)) if calls else ""
1046
+ if broken: # not pooled: no measurement, but not to be dropped silently either
1047
+ text += "\nDid not complete (see error.txt):\n\n" + "".join(
1048
+ f"- {p.parent.relative_to(out).as_posix()}: {_last_line(p)}\n" for p in broken
1049
+ )
1050
+ sections.append((title, text))
1051
+ summary += f"\n## {title}\n\n" + text
1052
+ if (out / "realtime.jsonl").exists() and (parts := realtime.decompose(out)):
1053
+ sections.append(("Turn latency by part", parts))
1054
+ summary += "\n## Turn latency by part\n\n" + parts
1055
+ target = json.loads((out / "conditions.json").read_text()).get("target", "")
1056
+ side = out / cs.AGENT_SIDE
1057
+ if paced := cs.pacing(agent, is_uri(target), side if side.exists() else None):
1058
+ sections.append(("RTP pacing", paced))
1059
+ summary += "\n## RTP pacing\n\n" + paced
1060
+ if (out / "realtime.jsonl").exists() and (heard := realtime.downlink(out)):
1061
+ sections.append(("Engine audio as the caller received it", heard))
1062
+ summary += "\n## Engine audio as the caller received it\n\n" + heard
1063
+ if (out / "realtime").is_dir() and (said := realtime.uplink(out)):
1064
+ sections.append(("Caller audio as the engine received it", said))
1065
+ summary += "\n## Caller audio as the engine received it\n\n" + said
1066
+ (out / "results.json").write_text(json.dumps(records, indent=1))
1067
+ (out / "summary.md").write_text(summary)
1068
+ (out / "report.html").write_text(report.render(out, sections))
1069
+ return summary, ok