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/cli.py ADDED
@@ -0,0 +1,616 @@
1
+ """rtpbench: wire-level latency tests for voice agents over SIP/RTP."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import shutil
7
+ import textwrap
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from rtpbench import analyze, cases, pcap
12
+ from rtpbench.bench import PROFILES, TARGETS, docker, is_uri, network_problem
13
+ from rtpbench.caller import GAP_SECONDS, LEAD_SECONDS
14
+ from rtpbench.srtp import SUITES
15
+
16
+ CODECS = {"pcmu": 0, "pcma": 8}
17
+ CALLS = ("latency", "barge-in") # `target`'s calls besides the cases
18
+ LARGE_INVITE_STREAM = "large-invite: not over TCP or TLS (it checks the switch from UDP to TCP)"
19
+ TLS_HELP = "SIP over TLS to the agent (or the carrier), certified by the stack's test CA"
20
+ SRTP_HELP = (
21
+ "SRTP keyed in the SDP (SDES), offering these suites in this order (default: "
22
+ + ", ".join(SUITES)
23
+ + ")"
24
+ )
25
+ QUICK_START = """\
26
+ quick start, from a clone (Linux, Docker 28 or later, uv):
27
+ uv run rtpbench calibrate --profiles clean --turns 5 # how exactly this host times the wire
28
+ uv run --extra vad rtpbench cases # prove every case on the reference agent
29
+ uv run --extra vad rtpbench target livekit --mock # a bundled target, no AI account needed
30
+ uv run --extra vad rtpbench target sip:agent@host # your agent
31
+
32
+ Each run writes results/<timestamp>/. rtpbench COMMAND --help shows its options and valid
33
+ values; docs/ in the repository explains the measurements, error bars and cases.
34
+ case, operator, realtime, agent and call are run inside containers by the commands above.
35
+ """
36
+
37
+
38
+ class Help(argparse.RawDescriptionHelpFormatter):
39
+ """Descriptions and epilogs as written; option help wrapped without splitting names like
40
+ lte-bad at the hyphen."""
41
+
42
+ def _split_lines(self, text: str, width: int) -> list[str]:
43
+ return textwrap.wrap(text, width, break_on_hyphens=False)
44
+
45
+
46
+ def _target_args(args: argparse.Namespace) -> str | None:
47
+ """What is wrong with `rtpbench target`'s arguments, if anything."""
48
+ from rtpbench.caller import parse_uri
49
+
50
+ uri_only = {
51
+ "--proxy": args.proxy,
52
+ "--network": args.network,
53
+ "--advertise": args.advertise,
54
+ "--ca": args.ca,
55
+ }
56
+ if not is_uri(args.name):
57
+ if args.name not in TARGETS:
58
+ return f"NAME|URI: {args.name!r} is neither {', '.join(TARGETS)} nor a sip: URI"
59
+ if given := [k for k, v in uri_only.items() if v]:
60
+ return f"{', '.join(given)}: with a URI only"
61
+ if (args.tls or args.srtp) and not args.carrier:
62
+ return "--tls, --srtp: with a URI, or through the carrier (--carrier)"
63
+ if args.lines and args.mock:
64
+ return "--lines: not with --mock, which recognises only the bundled lines"
65
+ return None
66
+ if args.mock or args.tls:
67
+ return "--mock, --tls: with a bundled target only (a URI gives TLS as sips:)"
68
+ if args.carrier:
69
+ from rtpbench.sip import transport
70
+
71
+ # the carrier calls the agent from its own address; the agent answers where the
72
+ # carrier's packets come from
73
+ if transport(args.name) == "TLS" or args.srtp:
74
+ return "--carrier: the carrier calls the agent over UDP or TCP and RTP, not TLS or SRTP"
75
+ if args.network or args.advertise:
76
+ return "--carrier: not with --network or --advertise (the carrier calls the agent)"
77
+ if ":" in args.name.split(":", 1)[1].split("@")[0] and "@" in args.name:
78
+ return "the URI has a password: give credentials in the environment, not the URI"
79
+ host, _ = parse_uri(args.name)
80
+ if host == "localhost" or host.startswith("127."):
81
+ return f"{host}: the caller runs in Docker; give this host's own address, or --network"
82
+ if args.ca and not args.ca.is_file():
83
+ return f"--ca: {args.ca} is not a file"
84
+ if args.advertise:
85
+ from rtpbench.caller import ADVERTISE_PORTS
86
+
87
+ adv_host, _, adv_port = args.advertise.rpartition(":")
88
+ if not adv_host or not adv_port.isdigit():
89
+ return "--advertise: HOST:PORT"
90
+ if not 1 <= int(adv_port) <= 65536 - ADVERTISE_PORTS:
91
+ return f"--advertise: PORT 1-{65536 - ADVERTISE_PORTS}, the first of {ADVERTISE_PORTS}"
92
+ return None
93
+
94
+
95
+ def _only(only: str, carrier: bool, stream: bool, calls: tuple[str, ...] = ()) -> str | None:
96
+ """What is wrong with `--only`, if anything: a name that is neither a case nor in `calls`,
97
+ or a case that cannot run this way, which would otherwise pass without testing anything."""
98
+ names = only.split(",")
99
+ if unknown := [n for n in names if n not in cases.CASES and n not in calls]:
100
+ return f"--only: {', '.join(unknown)}: no such case"
101
+ if stream and "large-invite" in names:
102
+ return LARGE_INVITE_STREAM
103
+ for n in names:
104
+ route = cases.CASES[n].route if n in cases.CASES else ""
105
+ if route == "carrier" and not carrier:
106
+ return f"{n}: only with --carrier, which applies what it tests"
107
+ if route == "direct" and carrier:
108
+ return f"{n}: not with --carrier, which would answer for the agent"
109
+ if stream and n in cases.NOT_STREAM:
110
+ return f"{n}: not over TCP or TLS"
111
+ return None
112
+
113
+
114
+ def _suites(value: str | None) -> list[str] | None:
115
+ return value.split(",") if value else None
116
+
117
+
118
+ def main(argv: list[str] | None = None) -> None:
119
+ parser = argparse.ArgumentParser(
120
+ prog="rtpbench", description=__doc__, epilog=QUICK_START, formatter_class=Help
121
+ )
122
+ parser.add_argument("--version", action="store_true", help="print the version and exit")
123
+ # container commands get no help=, which keeps them out of the listing
124
+ sub = parser.add_subparsers(dest="cmd", title="commands", metavar="COMMAND")
125
+ profiles = f"comma-separated netem profiles: {', '.join(PROFILES)}"
126
+ seed = "seed of the run's random choices (default: %(default)s)"
127
+
128
+ p = sub.add_parser(
129
+ "calibrate",
130
+ help="measure this host's timing error against the reference agent (Docker)",
131
+ description="""\
132
+ Call the reference agent, which logs the truth, once per network profile and codec, and
133
+ compare what the wire shows with it: the error bars every other measurement carries.
134
+ Writes summary.md, conditions.json and each call's captures to --out.""",
135
+ epilog="""\
136
+ examples:
137
+ uv run rtpbench calibrate --profiles clean --turns 5 # about 1 minute
138
+ uv run rtpbench calibrate # every profile, 20 turns: 10 minutes
139
+ uv run --extra vad rtpbench calibrate --barge-in # interruptions: about 35 minutes
140
+ """,
141
+ formatter_class=Help,
142
+ )
143
+ p.add_argument("--out", type=Path, metavar="DIR", help="default: results/<timestamp>")
144
+ kind = p.add_mutually_exclusive_group()
145
+ kind.add_argument("--speech", action="store_true", help="speech detection on real speech")
146
+ kind.add_argument("--barge-in", action="store_true", help="barge-in stop time")
147
+ p.add_argument(
148
+ "--profiles",
149
+ metavar="PROFILE,...",
150
+ help=f"{profiles} (default: all; --speech, --barge-in: clean,mobile)",
151
+ )
152
+ p.add_argument(
153
+ "--codecs",
154
+ metavar="CODEC,...",
155
+ help=f"comma-separated, of {', '.join(CODECS)} (default: pcmu; --speech: pcmu,pcma;"
156
+ " --barge-in: pcma)",
157
+ )
158
+ p.add_argument(
159
+ "--replies", type=Path, metavar="DIR", help="with --speech: agent replies from these WAVs"
160
+ )
161
+ p.add_argument("--turns", type=int, help="default 20; --speech: one per reply; --barge-in: 60")
162
+ p.add_argument("--seed", type=int, default=1, help=seed)
163
+ p.add_argument("--carrier", action="store_true", help="call through the carrier (Docker)")
164
+ p.add_argument("--tls", action="store_true", help=TLS_HELP)
165
+ p.add_argument("--srtp", nargs="?", const=",".join(SUITES), metavar="SUITE,...", help=SRTP_HELP)
166
+
167
+ p = sub.add_parser(
168
+ "cases",
169
+ help="prove the conformance cases on the reference agent (Docker)",
170
+ description="""\
171
+ Run each case against the reference agent in Docker, behaving well and in each way it should
172
+ fail or decline, and check that every verdict comes out right; exit 1 if one does not. Writes
173
+ summary.md, results.json, report.html (a timeline of every call) and the captures to --out. To
174
+ judge an agent, use `rtpbench target`.""",
175
+ epilog="""\
176
+ examples:
177
+ uv run --extra vad rtpbench cases # every case: about 17 minutes
178
+ uv run --extra vad rtpbench cases --only hold,refer --profiles clean,lte --runs 3
179
+ """,
180
+ formatter_class=Help,
181
+ )
182
+ p.add_argument(
183
+ "--only",
184
+ metavar="CASE,...",
185
+ help="comma-separated cases (default: all; a few run only direct or only with --carrier): "
186
+ + ", ".join(cases.CASES),
187
+ )
188
+ p.add_argument(
189
+ "--profiles",
190
+ default="clean",
191
+ metavar="PROFILE,...",
192
+ help=f"{profiles} (default: clean)",
193
+ )
194
+ p.add_argument("--runs", type=int, default=1, help="repeats per profile (default: 1)")
195
+ p.add_argument("--out", type=Path, metavar="DIR", help="default: results/<timestamp>")
196
+ p.add_argument("--seed", type=int, default=1, help=seed)
197
+ p.add_argument("--carrier", action="store_true", help="call through the carrier (Docker)")
198
+ p.add_argument("--tls", action="store_true", help=TLS_HELP)
199
+ p.add_argument("--srtp", nargs="?", const=",".join(SUITES), metavar="SUITE,...", help=SRTP_HELP)
200
+
201
+ p = sub.add_parser(
202
+ "target",
203
+ help="cases, turn latency and barge-in against a bundled target or a SIP URI (Docker)",
204
+ description="""\
205
+ Run every case, a 12-turn latency call and a 10-turn barge-in call through the netem router
206
+ against a target from targets/ (brought up next to it) or the agent at a SIP URI. Writes
207
+ summary.md, results.json, report.html, target.log and the captures to --out. Without
208
+ --mock, a bundled target needs its AI engine's credentials: see targets/NAME/compose.yaml.""",
209
+ epilog="""\
210
+ examples:
211
+ uv run --extra vad rtpbench target livekit --mock # no AI account needed: 11 minutes
212
+ uv run --extra vad rtpbench target aivc --mock --only latency,barge-in --profiles clean,lte
213
+ uv run --extra vad rtpbench target sip:agent@203.0.113.7 --profiles clean,lte
214
+ uv run --extra vad rtpbench target sip:agent@agent --network myagent_default
215
+ uv run --extra vad rtpbench target 'sip:agent@agent.example.com;transport=tls'
216
+ """,
217
+ formatter_class=Help,
218
+ )
219
+ p.add_argument(
220
+ "name",
221
+ metavar="NAME|URI",
222
+ help=f"{', '.join(TARGETS)}, or the agent's URI: sip:user@host[:port], over TCP with"
223
+ " ;transport=tcp, over TLS with sips: or ;transport=tls",
224
+ )
225
+ p.add_argument(
226
+ "--only",
227
+ metavar="NAME,...",
228
+ help="comma-separated cases, latency, barge-in (default: all; a few cases run only direct"
229
+ " or only with --carrier): " + ", ".join([*cases.CASES, *CALLS]),
230
+ )
231
+ p.add_argument(
232
+ "--profiles", default="clean", metavar="PROFILE,...", help=f"{profiles} (default: clean)"
233
+ )
234
+ p.add_argument("--runs", type=int, default=1, help="repeats per profile (default: 1)")
235
+ p.add_argument("--out", type=Path, metavar="DIR", help="default: results/<timestamp>")
236
+ p.add_argument("--seed", type=int, default=1, help=seed)
237
+ p.add_argument("--carrier", action="store_true", help="call through the carrier (Docker)")
238
+ p.add_argument("--mock", action="store_true", help="the mock engine instead of the AI engine")
239
+ p.add_argument(
240
+ "--lines",
241
+ type=Path,
242
+ metavar="DIR",
243
+ help="the caller's lines, named as in corpus/lines/en (not with --mock)",
244
+ )
245
+ p.add_argument(
246
+ "--proxy", metavar="HOST", help="URI: send a transfer's INVITE here, not to the operator"
247
+ )
248
+ p.add_argument(
249
+ "--network",
250
+ metavar="NAME",
251
+ help="URI: the Docker network the agent is on, when it runs in Docker on this host",
252
+ )
253
+ p.add_argument(
254
+ "--advertise",
255
+ metavar="HOST:PORT",
256
+ help="URI: an address where the agent reaches this host; it publishes PORT and the next 7",
257
+ )
258
+ p.add_argument(
259
+ "--ca",
260
+ type=Path,
261
+ metavar="FILE",
262
+ help="URI or --proxy over TLS: trust the certificates in FILE (PEM), not the system's",
263
+ )
264
+ p.add_argument(
265
+ "--srtp",
266
+ nargs="?",
267
+ const=",".join(SUITES),
268
+ metavar="SUITE,...",
269
+ help="URI, or with --carrier: " + SRTP_HELP,
270
+ )
271
+ p.add_argument(
272
+ "--tls",
273
+ action="store_true",
274
+ help="with --carrier: SIP over TLS to the carrier (a URI says so itself: sips:)",
275
+ )
276
+
277
+ p = sub.add_parser("case", description="run one case's caller side (inside a container)")
278
+ p.add_argument("name")
279
+ p.add_argument("uri")
280
+ p.add_argument("--out", type=Path, required=True)
281
+ p.add_argument("--seed", type=int, default=1)
282
+ p.add_argument("--iface", default="eth0")
283
+ p.add_argument("--lines", type=Path, help="prompt.wav, transfer.wav to say")
284
+ p.add_argument("--proxy", help="send a transfer's INVITE here (default: the Refer-To host)")
285
+
286
+ p = sub.add_parser("operator", description="run the transfer target (inside a container)")
287
+ p.add_argument("--ready", type=Path, default=Path("/tmp/ready"))
288
+
289
+ p = sub.add_parser(
290
+ "realtime",
291
+ description="run the mock engine, a plain-ws OpenAI Realtime server: in the stack's "
292
+ "container, or on this host for an agent of your own",
293
+ )
294
+ p.add_argument("--out", type=Path, required=True)
295
+ p.add_argument("--port", type=int, help="ws port (default: 8000)")
296
+ p.add_argument("--ready", type=Path, default=Path("/tmp/ready"))
297
+
298
+ p = sub.add_parser(
299
+ "report",
300
+ help="judge a cases or target directory again; rewrite its report",
301
+ description="""\
302
+ Judge a finished cases or target run with this version's checks, without placing calls.
303
+ Rewrites summary.md, results.json and report.html in DIR.""",
304
+ epilog="""\
305
+ example:
306
+ uv run --extra vad rtpbench report results/20260101-120000
307
+ uv run --extra vad rtpbench report results/20260101-120000 --agent-pcap agent.pcap
308
+ """,
309
+ formatter_class=Help,
310
+ )
311
+ p.add_argument("out", type=Path, metavar="DIR", help="a cases or target results directory")
312
+ p.add_argument(
313
+ "--agent-pcap",
314
+ type=Path,
315
+ metavar="FILE",
316
+ help="a capture taken at the agent during the run (tcpdump -w), to split its own RTP "
317
+ "pacing from the path's; copied into DIR, where later reports find it",
318
+ )
319
+
320
+ p = sub.add_parser(
321
+ "check",
322
+ help="pass or fail a target run against a policy; exit 1 if it fails (CI)",
323
+ description="""\
324
+ Judge a finished target run against a policy, without placing calls: the cases that may fail
325
+ or decline, limits on turn latency and barge-in, and how far they may grow from an earlier run's
326
+ check.json. Writes check.md and check.json in DIR; exits 1 if the run fails the policy, 2 if
327
+ the policy, DIR or the previous check.json cannot be used. Without --policy, every case must
328
+ pass.""",
329
+ epilog="""\
330
+ example:
331
+ uv run --extra vad rtpbench check results/20260101-120000 --policy rtpbench.toml \\
332
+ --previous baseline/check.json
333
+ """,
334
+ formatter_class=Help,
335
+ )
336
+ p.add_argument("out", type=Path, metavar="DIR", help="a target results directory")
337
+ p.add_argument("--policy", type=Path, metavar="FILE", help="TOML; see docs/ci.md")
338
+ p.add_argument(
339
+ "--previous", type=Path, metavar="FILE", help="an earlier run's check.json to compare with"
340
+ )
341
+
342
+ p = sub.add_parser(
343
+ "analyze",
344
+ help="summarise calibration run directories already captured",
345
+ description="""\
346
+ Print the summary of calibrate's run directories, without placing calls. Directories with
347
+ the same name (other seeds) pool.""",
348
+ epilog="""\
349
+ example:
350
+ uv run --extra vad rtpbench analyze results/*/clean # every calibration's clean runs
351
+ uv run --extra vad rtpbench analyze --methods results/*/lte-pcmu # --speech runs only
352
+ """,
353
+ formatter_class=Help,
354
+ )
355
+ p.add_argument("runs", type=Path, nargs="+", metavar="DIR", help="e.g. results/*/clean")
356
+ p.add_argument(
357
+ "--methods",
358
+ action="store_true",
359
+ help="calibrate --speech runs: turn latency by common measurement methods against truth",
360
+ )
361
+
362
+ p = sub.add_parser(
363
+ "index",
364
+ help="write DIR/index.html, linking every result set under DIR",
365
+ description="""\
366
+ Write DIR/index.html: for each folder of result sets, every case's verdicts in each set, linked
367
+ to its first call's timeline, then each set's conditions and files; calibrations by their
368
+ summary. Places no calls.""",
369
+ epilog="""\
370
+ example:
371
+ uv run rtpbench index # results/index.html
372
+ """,
373
+ formatter_class=Help,
374
+ )
375
+ p.add_argument(
376
+ "dir", type=Path, nargs="?", default=Path("results"), metavar="DIR", help="default: results"
377
+ )
378
+ p.add_argument(
379
+ "--markdown",
380
+ metavar="URL",
381
+ help="link Markdown files to URL/<path under DIR>, where they render (default: as files)",
382
+ )
383
+
384
+ for name in ("agent", "call"):
385
+ p = sub.add_parser(name, description=f"run the {name} side (inside a container)")
386
+ p.add_argument("--speech", action="store_true", help="speak corpus clips")
387
+ p.add_argument("--barge-in", action="store_true", help="follow the barge-in script")
388
+ if name == "agent":
389
+ p.add_argument("--replies", type=Path, help="with --speech: reply with these WAVs")
390
+ p.add_argument("--case", help="behave as this conformance case needs")
391
+ p.add_argument("--behaviour", default="good", help="with --case: which behaviour")
392
+ p.add_argument("--tls", type=Path, help="SIP over TLS too, with server.crt here")
393
+ else:
394
+ p.add_argument("uri")
395
+ p.add_argument("--turns", type=int, default=20)
396
+ p.add_argument("--wav", type=Path, nargs="+", help="utterances, 8 kHz mono 16-bit")
397
+ p.add_argument("--interrupt", type=Path, nargs="+", help="with --barge-in: WAVs")
398
+ p.add_argument("--codec", choices=CODECS, help="offer only this codec")
399
+ p.add_argument(
400
+ "--lead", type=float, default=LEAD_SECONDS, help="silence before turn 1, s"
401
+ )
402
+ p.add_argument(
403
+ "--gap", type=float, default=GAP_SECONDS, help="silence after each turn, s"
404
+ )
405
+ p.add_argument("--out", type=Path, required=True)
406
+ p.add_argument("--seed", type=int, default=1)
407
+ p.add_argument("--iface", default="eth0")
408
+
409
+ args = parser.parse_args(argv)
410
+ if args.version:
411
+ from importlib.metadata import version # ~40 ms: not for every command
412
+
413
+ print(f"rtpbench {version('rtpbench')}")
414
+ return
415
+ if args.cmd is None:
416
+ parser.print_help()
417
+ raise SystemExit(2)
418
+ if unknown := [
419
+ s for s in (getattr(args, "srtp", None) or "").split(",") if s and s not in SUITES
420
+ ]:
421
+ parser.error(f"--srtp: {', '.join(unknown)}: not one of {', '.join(SUITES)}")
422
+ if args.cmd in ("calibrate", "cases", "target"):
423
+ from rtpbench.progress import Progress
424
+
425
+ progress = Progress()
426
+ if args.cmd == "calibrate":
427
+ from rtpbench.bench import calibrate
428
+
429
+ out = args.out or Path("results") / datetime.now().strftime("%Y%m%d-%H%M%S")
430
+ special = args.speech or args.barge_in
431
+ profiles = args.profiles or ("clean,mobile" if special else ",".join(PROFILES))
432
+ codecs = args.codecs or ("pcmu,pcma" if args.speech else "pcma" if args.barge_in else "")
433
+ docker()
434
+ with progress:
435
+ summary = calibrate(
436
+ out,
437
+ profiles.split(","),
438
+ args.turns,
439
+ args.seed,
440
+ codecs.split(",") if codecs else None,
441
+ args.speech,
442
+ args.replies,
443
+ args.barge_in,
444
+ args.carrier,
445
+ args.tls,
446
+ _suites(args.srtp),
447
+ progress=progress,
448
+ )
449
+ print(summary)
450
+ print(f"results in {out}")
451
+ elif args.cmd == "cases":
452
+ from rtpbench.bench import cases as run_cases
453
+
454
+ out = args.out or Path("results") / datetime.now().strftime("%Y%m%d-%H%M%S")
455
+ if args.only and (problem := _only(args.only, args.carrier, args.tls)):
456
+ parser.error(problem)
457
+ names = args.only.split(",") if args.only else cases.names(args.carrier, args.tls)
458
+ docker()
459
+ with progress:
460
+ summary, proven = run_cases(
461
+ out,
462
+ names,
463
+ args.profiles.split(","),
464
+ args.runs,
465
+ args.seed,
466
+ args.carrier,
467
+ args.tls,
468
+ _suites(args.srtp),
469
+ progress=progress,
470
+ )
471
+ print(summary)
472
+ print(f"results in {out}")
473
+ if not proven:
474
+ raise SystemExit(1)
475
+ elif args.cmd == "target":
476
+ from rtpbench.bench import check_lines, target
477
+
478
+ if problem := _target_args(args):
479
+ parser.error(problem)
480
+ out = args.out or Path("results") / datetime.now().strftime("%Y%m%d-%H%M%S")
481
+ from rtpbench.sip import transport
482
+
483
+ stream = transport(args.name) != "UDP" if is_uri(args.name) else args.tls
484
+ if args.only and (problem := _only(args.only, args.carrier, stream, CALLS)):
485
+ parser.error(problem)
486
+ names = args.only.split(",") if args.only else [*cases.names(args.carrier, stream), *CALLS]
487
+ if args.lines and (problems := check_lines(args.lines, names)):
488
+ parser.error(f"--lines {args.lines}:\n " + "\n ".join(problems))
489
+ profiles = args.profiles.split(",")
490
+ docker()
491
+ if args.network and (problem := network_problem(args.network)):
492
+ parser.error(f"--network: {problem}")
493
+ with progress:
494
+ summary = target(
495
+ out,
496
+ args.name,
497
+ names,
498
+ profiles,
499
+ args.runs,
500
+ args.seed,
501
+ args.carrier,
502
+ args.mock,
503
+ args.lines,
504
+ args.proxy,
505
+ args.network,
506
+ args.advertise,
507
+ args.ca,
508
+ _suites(args.srtp),
509
+ args.tls,
510
+ progress=progress,
511
+ )
512
+ print(summary)
513
+ print(f"results in {out}")
514
+ elif args.cmd == "index":
515
+ from rtpbench import report
516
+
517
+ (args.dir / "index.html").write_text(report.index(args.dir, args.markdown))
518
+ print(f"wrote {args.dir / 'index.html'}")
519
+ elif args.cmd == "operator":
520
+ from rtpbench.agent import operator
521
+
522
+ operator(args.ready)
523
+ elif args.cmd == "realtime":
524
+ from rtpbench import realtime
525
+
526
+ port = realtime.PORT if args.port is None else args.port
527
+ if not 1 <= port <= 65535:
528
+ parser.error("--port: 1-65535")
529
+ realtime.serve(args.out, port, args.ready)
530
+ elif args.cmd == "report":
531
+ from rtpbench.bench import judge
532
+
533
+ if args.agent_pcap:
534
+ side = args.out / cases.AGENT_SIDE
535
+ try:
536
+ pcap.read(args.agent_pcap) # refused here, not copied in to break later reports
537
+ except (OSError, ValueError) as e:
538
+ parser.error(f"--agent-pcap: {e} (tcpdump -w writes one)")
539
+ if not (side.exists() and side.samefile(args.agent_pcap)):
540
+ shutil.copyfile(args.agent_pcap, side)
541
+ print(judge(args.out)[0])
542
+ print(f"report in {args.out / 'report.html'}")
543
+ elif args.cmd == "check":
544
+ from rtpbench.check import InputError, check
545
+
546
+ try:
547
+ text, ok = check(args.out, args.policy, args.previous)
548
+ except InputError as e:
549
+ parser.error(str(e))
550
+ print(text)
551
+ if not ok:
552
+ raise SystemExit(1)
553
+ elif args.cmd == "analyze":
554
+ from rtpbench import bargein, speechcal
555
+
556
+ first = json.loads((args.runs[0] / "caller.json").read_text())["turns"][:1] or [{}]
557
+ summarize = speechcal.summarize if "speech" in first[0] else analyze.summarize
558
+ if "prompt" in first[0]:
559
+ summarize = bargein.summarize
560
+ if args.methods:
561
+ summarize = speechcal.methods
562
+ runs: dict[str, list[Path]] = {}
563
+ for run in args.runs:
564
+ runs.setdefault(run.name, []).append(run)
565
+ print(summarize(runs))
566
+ else:
567
+ from rtpbench.capture import capture
568
+
569
+ args.out.mkdir(parents=True, exist_ok=True)
570
+ if args.cmd != "agent": # the caller's TLS session keys, which judging decrypts with
571
+ os.environ["SSLKEYLOGFILE"] = str(args.out / "keys.log")
572
+ with capture(args.out / f"{'agent' if args.cmd == 'agent' else 'caller'}.pcap", args.iface):
573
+ if args.cmd == "agent":
574
+ from rtpbench.agent import serve
575
+
576
+ behaviour = None
577
+ if args.case:
578
+ from rtpbench.cases import CASES
579
+
580
+ behaviour = CASES[args.case].behaviours()[args.behaviour]
581
+ log = serve(
582
+ args.out / "ready",
583
+ seed=args.seed,
584
+ real=args.speech,
585
+ replies=args.replies,
586
+ barge=args.barge_in,
587
+ case=behaviour,
588
+ tls=args.tls,
589
+ )
590
+ if args.case:
591
+ log |= {"case": args.case, "behaviour": args.behaviour}
592
+ elif args.cmd == "case":
593
+ from rtpbench.cases import run
594
+
595
+ log = run(args.name, args.uri, args.seed, args.lines, args.proxy)
596
+ else:
597
+ from rtpbench import speech
598
+ from rtpbench.caller import call, converse, speech_utterances
599
+
600
+ utterances: list[tuple[str, list[int]]] | None = None
601
+ if args.wav:
602
+ utterances = [(w.stem, speech.read_wav(w)) for w in args.wav]
603
+ elif args.speech:
604
+ utterances = speech_utterances(args.seed)
605
+ codecs = (CODECS[args.codec],) if args.codec else tuple(CODECS.values())
606
+ if args.barge_in:
607
+ cut_in = [(w.stem, speech.read_wav(w)) for w in args.interrupt or []]
608
+ log = converse(
609
+ args.uri, args.turns, args.seed, utterances, args.lead, codecs, cut_in
610
+ )
611
+ else:
612
+ log = call(
613
+ args.uri, args.turns, args.seed, utterances, args.lead, args.gap, codecs
614
+ )
615
+ name = "agent.json" if args.cmd == "agent" else "caller.json"
616
+ (args.out / name).write_text(json.dumps(log, indent=1))