rtpbench 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtpbench/.dockerignore +4 -0
- rtpbench/Dockerfile +12 -0
- rtpbench/__init__.py +6 -0
- rtpbench/__main__.py +3 -0
- rtpbench/agent.py +746 -0
- rtpbench/analyze.py +520 -0
- rtpbench/bargein.py +272 -0
- rtpbench/bench.py +1069 -0
- rtpbench/caller.py +781 -0
- rtpbench/capture.py +33 -0
- rtpbench/carrier/compose.yaml +68 -0
- rtpbench/carrier/opensips.cfg +226 -0
- rtpbench/carrier/tls.cfg +25 -0
- rtpbench/carrier/tls.yaml +9 -0
- rtpbench/cases.py +1989 -0
- rtpbench/check.py +369 -0
- rtpbench/cli.py +616 -0
- rtpbench/clock.py +174 -0
- rtpbench/compose.yaml +55 -0
- rtpbench/corpus/README.md +55 -0
- rtpbench/corpus/cough-c1-0.wav +0 -0
- rtpbench/corpus/cough-c1-1.wav +0 -0
- rtpbench/corpus/cough-c2-0.wav +0 -0
- rtpbench/corpus/cough-c2-1.wav +0 -0
- rtpbench/corpus/cough-man-0.wav +0 -0
- rtpbench/corpus/cough-man-1.wav +0 -0
- rtpbench/corpus/cough-short-0.wav +0 -0
- rtpbench/corpus/cough-short-1.wav +0 -0
- rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
- rtpbench/corpus/human-en-dr-0.wav +0 -0
- rtpbench/corpus/human-en-dr-1.wav +0 -0
- rtpbench/corpus/human-en-sc-0.wav +0 -0
- rtpbench/corpus/human-en-sc-1.wav +0 -0
- rtpbench/corpus/human-en-sc-2.wav +0 -0
- rtpbench/corpus/human-es-f012-0.wav +0 -0
- rtpbench/corpus/human-es-f012-1.wav +0 -0
- rtpbench/corpus/human-es-f012-2.wav +0 -0
- rtpbench/corpus/human-es-f016-0.wav +0 -0
- rtpbench/corpus/human-es-f016-1.wav +0 -0
- rtpbench/corpus/human-es-f016-2.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-00.wav +0 -0
- rtpbench/corpus/lines/en/prompt-01.wav +0 -0
- rtpbench/corpus/lines/en/prompt-02.wav +0 -0
- rtpbench/corpus/lines/en/prompt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-04.wav +0 -0
- rtpbench/corpus/lines/en/prompt-05.wav +0 -0
- rtpbench/corpus/lines/en/prompt-06.wav +0 -0
- rtpbench/corpus/lines/en/prompt-07.wav +0 -0
- rtpbench/corpus/lines/en/prompt-08.wav +0 -0
- rtpbench/corpus/lines/en/prompt-09.wav +0 -0
- rtpbench/corpus/lines/en/prompt.wav +0 -0
- rtpbench/corpus/lines/en/transfer.wav +0 -0
- rtpbench/corpus/lines/en/turn-00.wav +0 -0
- rtpbench/corpus/lines/en/turn-01.wav +0 -0
- rtpbench/corpus/lines/en/turn-02.wav +0 -0
- rtpbench/corpus/lines/en/turn-03.wav +0 -0
- rtpbench/corpus/lines/en/turn-04.wav +0 -0
- rtpbench/corpus/lines/en/turn-05.wav +0 -0
- rtpbench/corpus/lines/en/turn-06.wav +0 -0
- rtpbench/corpus/lines/en/turn-07.wav +0 -0
- rtpbench/corpus/lines/en/turn-08.wav +0 -0
- rtpbench/corpus/lines/en/turn-09.wav +0 -0
- rtpbench/corpus/lines/en/turn-10.wav +0 -0
- rtpbench/corpus/lines/en/turn-11.wav +0 -0
- rtpbench/corpus/tts-ald-bc.wav +0 -0
- rtpbench/corpus/tts-ald-long.wav +0 -0
- rtpbench/corpus/tts-ald-s03.wav +0 -0
- rtpbench/corpus/tts-ald-s07.wav +0 -0
- rtpbench/corpus/tts-ald-s11.wav +0 -0
- rtpbench/corpus/tts-cori-bc.wav +0 -0
- rtpbench/corpus/tts-cori-long.wav +0 -0
- rtpbench/corpus/tts-cori-s01.wav +0 -0
- rtpbench/corpus/tts-cori-s05.wav +0 -0
- rtpbench/corpus/tts-cori-s09.wav +0 -0
- rtpbench/corpus/tts-davefx-bc.wav +0 -0
- rtpbench/corpus/tts-davefx-long.wav +0 -0
- rtpbench/corpus/tts-davefx-s00.wav +0 -0
- rtpbench/corpus/tts-davefx-s04.wav +0 -0
- rtpbench/corpus/tts-davefx-s08.wav +0 -0
- rtpbench/corpus/tts-libritts0-bc.wav +0 -0
- rtpbench/corpus/tts-libritts0-long.wav +0 -0
- rtpbench/corpus/tts-libritts0-s02.wav +0 -0
- rtpbench/corpus/tts-libritts0-s06.wav +0 -0
- rtpbench/corpus/tts-libritts0-s10.wav +0 -0
- rtpbench/corpus/tts-libritts3-bc.wav +0 -0
- rtpbench/corpus/tts-libritts3-long.wav +0 -0
- rtpbench/corpus/tts-libritts3-s03.wav +0 -0
- rtpbench/corpus/tts-libritts3-s07.wav +0 -0
- rtpbench/corpus/tts-libritts3-s11.wav +0 -0
- rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
- rtpbench/corpus/tts-ljspeech-long.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
- rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardf-long.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
- rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardm-long.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
- rtpbench/detect.py +145 -0
- rtpbench/g711.py +82 -0
- rtpbench/mock/compose.yaml +18 -0
- rtpbench/pcap.py +106 -0
- rtpbench/progress.py +184 -0
- rtpbench/realtime.py +1015 -0
- rtpbench/report.py +450 -0
- rtpbench/rtp.py +166 -0
- rtpbench/sip.py +653 -0
- rtpbench/speech.py +100 -0
- rtpbench/speechcal.py +367 -0
- rtpbench/srtp.py +191 -0
- rtpbench/targets/aivc/aivc.ini +25 -0
- rtpbench/targets/aivc/compose.yaml +72 -0
- rtpbench/targets/aivc/constraints.txt +52 -0
- rtpbench/targets/aivc/opensips.cfg +45 -0
- rtpbench/targets/ava/ai-agent.yaml +275 -0
- rtpbench/targets/ava/ari.conf +7 -0
- rtpbench/targets/ava/compose.yaml +128 -0
- rtpbench/targets/ava/constraints.txt +75 -0
- rtpbench/targets/ava/extensions.conf +10 -0
- rtpbench/targets/ava/http.conf +4 -0
- rtpbench/targets/ava/pjsip.conf +19 -0
- rtpbench/targets/ava/websocket_client.conf +15 -0
- rtpbench/targets/livekit/agent.py +111 -0
- rtpbench/targets/livekit/compose.yaml +86 -0
- rtpbench/targets/livekit/constraints.txt +72 -0
- rtpbench/tls.py +284 -0
- rtpbench/uri/advertise.yaml +7 -0
- rtpbench/uri/network.yaml +14 -0
- rtpbench/voice.py +18 -0
- rtpbench-0.3.0.dist-info/METADATA +123 -0
- rtpbench-0.3.0.dist-info/RECORD +144 -0
- rtpbench-0.3.0.dist-info/WHEEL +4 -0
- rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
- rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
rtpbench/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
|