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/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))
|