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/detect.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Speech detectors for received audio: [start, end) sample spans of detected speech.
|
|
2
|
+
|
|
3
|
+
gate |sample| >= 1000 on the 16-bit scale (the calibration's rule); gaps under 20 ms merged
|
|
4
|
+
rms every 1 ms, the RMS of the 10 ms around it, mean removed, above 0.01 of full scale:
|
|
5
|
+
the onset rule of Coval's TTS time-to-first-audio benchmark (coval-ai/benchmarks,
|
|
6
|
+
runner/src/coval_bench/metrics/ttfa.py), applied to both edges of every span
|
|
7
|
+
webrtcvad WebRTC VAD, aggressiveness 3, 10 ms frames
|
|
8
|
+
silero Silero VAD 6.2.3 (ONNX) at 8 kHz, 32 ms windows; speech from p >= 0.5 until p < 0.35
|
|
9
|
+
silero16 the same on the audio upsampled to 16 kHz. Fed a whole call, the 8 kHz mode answers
|
|
10
|
+
speech that follows earlier speech and a long silence tens of ms late, sometimes not
|
|
11
|
+
at all; the 16 kHz mode on the same audio does not.
|
|
12
|
+
|
|
13
|
+
All but gate run raw: no minimum durations and no padding, so their bias shows. They need the
|
|
14
|
+
`vad` extra (webrtcvad-wheels, onnxruntime, numpy); the Silero model is fetched once from
|
|
15
|
+
its PyPI wheel, checked against a pinned hash and cached.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import io
|
|
20
|
+
import os
|
|
21
|
+
import urllib.request
|
|
22
|
+
import zipfile
|
|
23
|
+
from array import array
|
|
24
|
+
from collections.abc import Callable, Iterable
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from rtpbench.voice import GATE, RATE
|
|
29
|
+
|
|
30
|
+
DETECTORS = ("gate", "rms", "webrtcvad", "silero", "silero16")
|
|
31
|
+
GATE_GAP = 160
|
|
32
|
+
RMS_FRAME, RMS_HOP, RMS_LEVEL = 80, 8, 0.01
|
|
33
|
+
WEBRTC_MODE = 3
|
|
34
|
+
WEBRTC_FRAME = 80
|
|
35
|
+
SILERO_WINDOW = 256
|
|
36
|
+
SILERO_ON, SILERO_OFF = 0.5, 0.35
|
|
37
|
+
SILERO_WHEEL = (
|
|
38
|
+
"https://files.pythonhosted.org/packages/84/ef/"
|
|
39
|
+
"9099037ed6f180ea33220178df4107112c0ce2bf5fb4d6f6ab19db2844ed/"
|
|
40
|
+
"silero_vad-6.2.3-py3-none-any.whl",
|
|
41
|
+
"7b7f5436cfcb02fae583a05b512ea96467fd449fe54cb49a5e4f06c51a1e43b8",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _spans(flags: Iterable[bool], size: int, gap: int = 0) -> list[tuple[int, int]]:
|
|
46
|
+
"""Runs of True in per-unit flags as sample spans, merging gaps of at most `gap` samples."""
|
|
47
|
+
out: list[tuple[int, int]] = []
|
|
48
|
+
for i, on in enumerate(flags):
|
|
49
|
+
if not on:
|
|
50
|
+
continue
|
|
51
|
+
start, end = i * size, (i + 1) * size
|
|
52
|
+
if out and start - out[-1][1] <= gap:
|
|
53
|
+
out[-1] = (out[-1][0], end)
|
|
54
|
+
else:
|
|
55
|
+
out.append((start, end))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def gate(pcm: list[int]) -> list[tuple[int, int]]:
|
|
60
|
+
return _spans((abs(s) >= GATE for s in pcm), 1, GATE_GAP)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def rms(pcm: list[int]) -> list[tuple[int, int]]:
|
|
64
|
+
import numpy as np
|
|
65
|
+
|
|
66
|
+
if not pcm:
|
|
67
|
+
return [] # edge padding needs a sample
|
|
68
|
+
x = np.pad(np.asarray(pcm, dtype=np.float64) / 32768, RMS_FRAME // 2, mode="edge")
|
|
69
|
+
s1, s2 = (np.concatenate([[0], np.cumsum(v)]) for v in (x, x * x))
|
|
70
|
+
at = np.arange(0, len(x) - RMS_FRAME + 1, RMS_HOP)
|
|
71
|
+
mean = (s1[at + RMS_FRAME] - s1[at]) / RMS_FRAME
|
|
72
|
+
power = (s2[at + RMS_FRAME] - s2[at]) / RMS_FRAME - mean * mean
|
|
73
|
+
return _spans((power > RMS_LEVEL**2).tolist(), RMS_HOP)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def webrtc(pcm: list[int]) -> list[tuple[int, int]]:
|
|
77
|
+
import webrtcvad
|
|
78
|
+
|
|
79
|
+
vad = webrtcvad.Vad(WEBRTC_MODE)
|
|
80
|
+
n = len(pcm) // WEBRTC_FRAME
|
|
81
|
+
raw = array("h", pcm).tobytes() # native order; webrtcvad wants little-endian (x86-64, arm64)
|
|
82
|
+
step = 2 * WEBRTC_FRAME
|
|
83
|
+
return _spans(
|
|
84
|
+
(vad.is_speech(raw[i * step : (i + 1) * step], RATE) for i in range(n)), WEBRTC_FRAME
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _silero_model() -> bytes:
|
|
89
|
+
url, sha = SILERO_WHEEL
|
|
90
|
+
cache = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "rtpbench"
|
|
91
|
+
path = cache / f"silero_vad-{sha[:12]}.onnx"
|
|
92
|
+
if not path.exists():
|
|
93
|
+
with urllib.request.urlopen(url, timeout=60) as r:
|
|
94
|
+
wheel = r.read()
|
|
95
|
+
if hashlib.sha256(wheel).hexdigest() != sha:
|
|
96
|
+
raise RuntimeError(f"{url}: unexpected sha256")
|
|
97
|
+
with zipfile.ZipFile(io.BytesIO(wheel)) as z:
|
|
98
|
+
model = z.read("silero_vad/data/silero_vad.onnx")
|
|
99
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
path.write_bytes(model)
|
|
101
|
+
return path.read_bytes()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_session: Any = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _silero(pcm: list[int], rate: int) -> list[tuple[int, int]]:
|
|
108
|
+
"""As silero_vad.OnnxWrapper: 32 ms windows, each with 4 ms of context. At 16 kHz the input
|
|
109
|
+
is `pcm` upsampled 2x (zero-stuffing, then a 63-tap windowed-sinc low-pass at 4 kHz)."""
|
|
110
|
+
import numpy as np
|
|
111
|
+
import onnxruntime
|
|
112
|
+
|
|
113
|
+
global _session
|
|
114
|
+
if _session is None:
|
|
115
|
+
opts = onnxruntime.SessionOptions()
|
|
116
|
+
opts.inter_op_num_threads = opts.intra_op_num_threads = 1
|
|
117
|
+
_session = onnxruntime.InferenceSession(_silero_model(), sess_options=opts)
|
|
118
|
+
x = np.asarray(pcm, dtype=np.float32) / 32768
|
|
119
|
+
if rate == 16000:
|
|
120
|
+
up = np.zeros(2 * len(x), dtype=np.float32)
|
|
121
|
+
up[::2] = x
|
|
122
|
+
n = np.arange(63) - 31
|
|
123
|
+
x = np.convolve(up, (np.sinc(n / 2) * np.hamming(63)).astype(np.float32), "same")
|
|
124
|
+
win, ctx = SILERO_WINDOW * rate // RATE, 32 * rate // RATE
|
|
125
|
+
state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
126
|
+
context = np.zeros((1, ctx), dtype=np.float32)
|
|
127
|
+
sr = np.array(rate, dtype=np.int64)
|
|
128
|
+
flags, speaking = [], False
|
|
129
|
+
for i in range(len(x) // win):
|
|
130
|
+
chunk = np.concatenate([context, x[None, i * win : (i + 1) * win]], 1)
|
|
131
|
+
out, state = _session.run(None, {"input": chunk, "state": state, "sr": sr})
|
|
132
|
+
context = chunk[:, -ctx:]
|
|
133
|
+
p = float(out[0, 0])
|
|
134
|
+
speaking = p >= SILERO_ON or (speaking and p >= SILERO_OFF)
|
|
135
|
+
flags.append(speaking)
|
|
136
|
+
return _spans(flags, SILERO_WINDOW)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
DETECT: dict[str, Callable[[list[int]], list[tuple[int, int]]]] = {
|
|
140
|
+
"gate": gate,
|
|
141
|
+
"rms": rms,
|
|
142
|
+
"webrtcvad": webrtc,
|
|
143
|
+
"silero": lambda pcm: _silero(pcm, RATE),
|
|
144
|
+
"silero16": lambda pcm: _silero(pcm, 16000),
|
|
145
|
+
}
|
rtpbench/g711.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""G.711 µ-law (PT 0) and A-law (PT 8), after the Sun reference implementation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
PCMU, PCMA = 0, 8
|
|
7
|
+
NAMES = {PCMU: "PCMU", PCMA: "PCMA"}
|
|
8
|
+
SILENCE = {PCMU: 0xFF, PCMA: 0xD5}
|
|
9
|
+
|
|
10
|
+
_SEG_END = (0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _segment(value: int, ends: Iterable[int]) -> int:
|
|
14
|
+
for seg, end in enumerate(ends):
|
|
15
|
+
if value <= end:
|
|
16
|
+
return seg
|
|
17
|
+
return 8
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ulaw_encode(sample: int) -> int:
|
|
21
|
+
value = sample >> 2
|
|
22
|
+
mask = 0xFF
|
|
23
|
+
if value < 0:
|
|
24
|
+
value, mask = -value, 0x7F
|
|
25
|
+
value = min(value, 8159) + 0x21
|
|
26
|
+
seg = _segment(value, _SEG_END)
|
|
27
|
+
if seg >= 8:
|
|
28
|
+
return 0x7F ^ mask
|
|
29
|
+
return ((seg << 4) | ((value >> (seg + 1)) & 0xF)) ^ mask
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ulaw_decode(code: int) -> int:
|
|
33
|
+
code = ~code & 0xFF
|
|
34
|
+
t = (((code & 0xF) << 3) + 0x84) << ((code & 0x70) >> 4)
|
|
35
|
+
return 0x84 - t if code & 0x80 else t - 0x84
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def alaw_encode(sample: int) -> int:
|
|
39
|
+
value = sample >> 3
|
|
40
|
+
mask = 0xD5
|
|
41
|
+
if value < 0:
|
|
42
|
+
value, mask = -value - 1, 0x55
|
|
43
|
+
seg = _segment(value, (e >> 1 for e in _SEG_END))
|
|
44
|
+
if seg >= 8:
|
|
45
|
+
return 0x7F ^ mask
|
|
46
|
+
shift = 1 if seg < 2 else seg
|
|
47
|
+
return ((seg << 4) | ((value >> shift) & 0xF)) ^ mask
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def alaw_decode(code: int) -> int:
|
|
51
|
+
code ^= 0x55
|
|
52
|
+
t = (code & 0xF) << 4
|
|
53
|
+
seg = (code & 0x70) >> 4
|
|
54
|
+
if seg == 0:
|
|
55
|
+
t += 8
|
|
56
|
+
else:
|
|
57
|
+
t = (t + 0x108) << (seg - 1)
|
|
58
|
+
return t if code & 0x80 else -t
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_ENCODE = {PCMU: ulaw_encode, PCMA: alaw_encode}
|
|
62
|
+
_DECODE = {PCMU: ulaw_decode, PCMA: alaw_decode}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def encode(pt: int, samples: Iterable[int]) -> bytes:
|
|
66
|
+
enc = _ENCODE[pt]
|
|
67
|
+
return bytes(enc(s) for s in samples)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def decode(pt: int, payload: bytes) -> list[int]:
|
|
71
|
+
dec = _DECODE[pt]
|
|
72
|
+
return [dec(b) for b in payload]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def gate(pt: int, threshold: int) -> re.Pattern[bytes]:
|
|
76
|
+
"""A regex matching one encoded sample whose magnitude reaches `threshold`.
|
|
77
|
+
|
|
78
|
+
`search` on a payload gives the first active sample; on the reversed payload, the last.
|
|
79
|
+
"""
|
|
80
|
+
dec = _DECODE[pt]
|
|
81
|
+
codes = bytes(b for b in range(256) if abs(dec(b)) >= threshold)
|
|
82
|
+
return re.compile(b"[" + re.escape(codes) + b"]")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# The mock engine (src/rtpbench/realtime.py) where a target expects its AI engine, merged over
|
|
2
|
+
# ../compose.yaml and a target's compose file: `rtpbench target NAME --mock` points the target's
|
|
3
|
+
# engine variables at it. The target loses its egress network: on the mock it needs none, and
|
|
4
|
+
# cannot reach a paid engine. It logs to RTPBENCH_OUT/realtime.jsonl.
|
|
5
|
+
services:
|
|
6
|
+
target:
|
|
7
|
+
networks: !override [agentnet]
|
|
8
|
+
|
|
9
|
+
realtime:
|
|
10
|
+
image: ${RTPBENCH_IMAGE:?}
|
|
11
|
+
init: true
|
|
12
|
+
networks: [agentnet]
|
|
13
|
+
volumes: ["${RTPBENCH_OUT:?}:/out"]
|
|
14
|
+
command: python -m rtpbench realtime --out /out
|
|
15
|
+
healthcheck:
|
|
16
|
+
test: test -e /tmp/ready
|
|
17
|
+
interval: 1s
|
|
18
|
+
retries: 30
|
rtpbench/pcap.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Classic pcap reader (as written by tcpdump -w): IPv4 UDP datagrams and TCP segments with
|
|
2
|
+
data, with ns timestamps."""
|
|
3
|
+
|
|
4
|
+
import socket
|
|
5
|
+
import struct
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_MAGIC = {
|
|
10
|
+
b"\xd4\xc3\xb2\xa1": ("<", 1000),
|
|
11
|
+
b"\x4d\x3c\xb2\xa1": ("<", 1),
|
|
12
|
+
b"\xa1\xb2\xc3\xd4": (">", 1000),
|
|
13
|
+
b"\xa1\xb2\x3c\x4d": (">", 1),
|
|
14
|
+
}
|
|
15
|
+
_ETHERNET, _RAW, _SLL, _SLL2 = 1, 101, 113, 276
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Datagram:
|
|
20
|
+
t_ns: int
|
|
21
|
+
src: str
|
|
22
|
+
sport: int
|
|
23
|
+
dst: str
|
|
24
|
+
dport: int
|
|
25
|
+
payload: bytes
|
|
26
|
+
tcp: bool = False
|
|
27
|
+
seq: int = 0 # TCP sequence number of the payload's first byte
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ip_offset(link: int, frame: bytes) -> int | None:
|
|
31
|
+
if link == _ETHERNET:
|
|
32
|
+
off, ethertype = 14, struct.unpack_from("!H", frame, 12)[0]
|
|
33
|
+
while ethertype == 0x8100:
|
|
34
|
+
ethertype = struct.unpack_from("!H", frame, off + 2)[0]
|
|
35
|
+
off += 4
|
|
36
|
+
return off if ethertype == 0x0800 else None
|
|
37
|
+
if link == _SLL:
|
|
38
|
+
return 16 if struct.unpack_from("!H", frame, 14)[0] == 0x0800 else None
|
|
39
|
+
if link == _SLL2:
|
|
40
|
+
return 20 if struct.unpack_from("!H", frame, 0)[0] == 0x0800 else None
|
|
41
|
+
return 0 if link == _RAW else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read(path: Path, partial: bool = False) -> list[Datagram]:
|
|
45
|
+
"""Its TCP segments that carry data and its UDP datagrams; with `partial`, also a datagram
|
|
46
|
+
the snaplen cut short, with what the capture kept of it."""
|
|
47
|
+
data = path.read_bytes()
|
|
48
|
+
if len(data) < 24 or data[:4] not in _MAGIC:
|
|
49
|
+
raise ValueError(f"{path}: not a classic pcap file")
|
|
50
|
+
order, scale = _MAGIC[data[:4]]
|
|
51
|
+
link = struct.unpack_from(order + "I", data, 20)[0] & 0x0FFFFFFF
|
|
52
|
+
record = struct.Struct(order + "IIII")
|
|
53
|
+
out, pos = [], 24
|
|
54
|
+
while pos + 16 <= len(data):
|
|
55
|
+
sec, frac, incl, _ = record.unpack_from(data, pos)
|
|
56
|
+
frame = data[pos + 16 : pos + 16 + incl]
|
|
57
|
+
pos += 16 + incl
|
|
58
|
+
try:
|
|
59
|
+
ip = _ip_offset(link, frame)
|
|
60
|
+
except struct.error: # shorter than its link header says
|
|
61
|
+
continue
|
|
62
|
+
if ip is None or len(frame) < ip + 28 or frame[ip + 9] not in (6, 17):
|
|
63
|
+
continue
|
|
64
|
+
if struct.unpack_from("!H", frame, ip + 6)[0] & 0x3FFF: # fragment
|
|
65
|
+
continue
|
|
66
|
+
t = sec * 1_000_000_000 + frac * scale
|
|
67
|
+
src, dst = (
|
|
68
|
+
socket.inet_ntoa(frame[ip + 12 : ip + 16]),
|
|
69
|
+
socket.inet_ntoa(frame[ip + 16 : ip + 20]),
|
|
70
|
+
)
|
|
71
|
+
l4 = ip + 4 * (frame[ip] & 0x0F)
|
|
72
|
+
if frame[ip + 9] == 6: # TCP: segments that carry data
|
|
73
|
+
end = ip + struct.unpack_from("!H", frame, ip + 2)[0]
|
|
74
|
+
if len(frame) < l4 + 20 or len(frame) < end:
|
|
75
|
+
continue
|
|
76
|
+
sport, dport, seq = struct.unpack_from("!HHI", frame, l4)
|
|
77
|
+
body = frame[l4 + 4 * (frame[l4 + 12] >> 4) : end]
|
|
78
|
+
if body:
|
|
79
|
+
out.append(Datagram(t, src, sport, dst, dport, body, True, seq))
|
|
80
|
+
continue
|
|
81
|
+
if len(frame) < l4 + 8:
|
|
82
|
+
continue
|
|
83
|
+
sport, dport, length = struct.unpack_from("!HHH", frame, l4)
|
|
84
|
+
if length < 8 or (len(frame) < l4 + length and not partial): # cut by the snaplen
|
|
85
|
+
continue
|
|
86
|
+
out.append(Datagram(t, src, sport, dst, dport, frame[l4 + 8 : l4 + length]))
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def streams(grams: list[Datagram]) -> list[Datagram]:
|
|
91
|
+
"""The TCP segments of `grams` that add bytes to their flow's stream, in capture order,
|
|
92
|
+
each cut to the bytes it adds (a retransmission adds none)."""
|
|
93
|
+
out: list[Datagram] = []
|
|
94
|
+
nxt: dict[tuple[str, int, str, int], int] = {} # next sequence number per flow
|
|
95
|
+
for g in grams:
|
|
96
|
+
if not g.tcp:
|
|
97
|
+
continue
|
|
98
|
+
flow = (g.src, g.sport, g.dst, g.dport)
|
|
99
|
+
seen = (nxt.get(flow, g.seq) - g.seq) & 0xFFFFFFFF
|
|
100
|
+
if seen >= 1 << 31: # a gap: bytes lost before the capture saw them
|
|
101
|
+
seen = 0
|
|
102
|
+
if seen >= len(g.payload):
|
|
103
|
+
continue
|
|
104
|
+
nxt[flow] = (g.seq + len(g.payload)) & 0xFFFFFFFF
|
|
105
|
+
out.append(Datagram(g.t_ns, *flow, g.payload[seen:], True, g.seq + seen))
|
|
106
|
+
return out
|
rtpbench/progress.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""What a long run is doing and how far along it is: a live status bar on a terminal, a line per
|
|
2
|
+
event anywhere else (CI logs, redirects, pipes). Host side only: the captures that time the calls
|
|
3
|
+
are taken in the containers, and the bar redraws twice a second at most, from its own thread."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
from rich import progress as rp
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
QUIET_S = 30.0 # without a terminal, a turn gets a line only after this long without one
|
|
17
|
+
_TURN = re.compile(r"turn (\d+)/(\d+)") # what the caller prints as each turn starts
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def eta(elapsed: float, done: float, total: int) -> float | None:
|
|
21
|
+
"""Seconds left for `total` units at the rate of the `done` (fractional) done in `elapsed`
|
|
22
|
+
seconds; None before any is done."""
|
|
23
|
+
return elapsed * (total - done) / done if done > 0 else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hms(seconds: float) -> str:
|
|
27
|
+
s = round(seconds)
|
|
28
|
+
return f"{s // 3600}:{s // 60 % 60:02d}:{s % 60:02d}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Stopped(SystemExit):
|
|
32
|
+
"""A stop signal, told apart from any other SystemExit the run raises."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _stop(signum: int, _frame: object) -> None:
|
|
36
|
+
"""SIGINT and SIGTERM (systemd stop, a cancelled CI job, kill) unwind the run the same way,
|
|
37
|
+
so its `finally` blocks tear the stack down and hand the results back. A second signal
|
|
38
|
+
meanwhile would cut that short and leave containers and root-owned files behind."""
|
|
39
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
40
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
41
|
+
raise _Stopped(128 + signum)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Progress:
|
|
45
|
+
"""Units of work (calls) out of `plan`'s total: `unit` starts one, `turn` takes the caller's
|
|
46
|
+
output while it runs, `done` ends it; `phase` names what happens outside units. As a context
|
|
47
|
+
manager it draws the bar (on a terminal), says where a run failed, and turns SIGINT and
|
|
48
|
+
SIGTERM into a clean exit that runs the run's teardown."""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self.t0 = time.monotonic()
|
|
52
|
+
self.total = self.k = 0 # units planned, started
|
|
53
|
+
self.done_units = 0.0 # with the current one's turns as a fraction
|
|
54
|
+
self.label = "" # the current unit or phase
|
|
55
|
+
self.turns: tuple[int, int] | None = None # the current unit's turn, of how many
|
|
56
|
+
self.unit_t = self.first_t = self.said_t = 0.0
|
|
57
|
+
self.end_t: float | None = None # when the run should end, at the rate so far
|
|
58
|
+
console = Console(highlight=False)
|
|
59
|
+
# rich's own test honours TERM=dumb and TTY_INTERACTIVE; FORCE_COLOR into a file must
|
|
60
|
+
# still not redraw
|
|
61
|
+
self.bar = None
|
|
62
|
+
if console.is_interactive and sys.stdout.isatty():
|
|
63
|
+
self.bar = rp.Progress(
|
|
64
|
+
rp.SpinnerColumn(),
|
|
65
|
+
rp.TextColumn("{task.description}"),
|
|
66
|
+
rp.BarColumn(),
|
|
67
|
+
rp.MofNCompleteColumn(),
|
|
68
|
+
rp.TimeElapsedColumn(),
|
|
69
|
+
_Left(self),
|
|
70
|
+
console=console,
|
|
71
|
+
transient=True,
|
|
72
|
+
refresh_per_second=2,
|
|
73
|
+
)
|
|
74
|
+
self.task = self.bar.add_task("", total=None)
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> Progress:
|
|
77
|
+
self.was = [signal.signal(s, _stop) for s in (signal.SIGINT, signal.SIGTERM)]
|
|
78
|
+
if self.bar:
|
|
79
|
+
self.bar.start()
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(
|
|
83
|
+
self, exc: type[BaseException] | None, _v: BaseException | None, _t: TracebackType | None
|
|
84
|
+
) -> None:
|
|
85
|
+
took = hms(time.monotonic() - self.t0)
|
|
86
|
+
try:
|
|
87
|
+
if exc:
|
|
88
|
+
how = "interrupted" if issubclass(exc, KeyboardInterrupt | _Stopped) else "failed"
|
|
89
|
+
self._event(False, f"{how} while {self.label or 'starting'}", f"after {took}")
|
|
90
|
+
else:
|
|
91
|
+
self._event(True, "finished", f"in {took}")
|
|
92
|
+
if self.bar:
|
|
93
|
+
self.bar.stop()
|
|
94
|
+
finally:
|
|
95
|
+
for s, handler in zip((signal.SIGINT, signal.SIGTERM), self.was, strict=True):
|
|
96
|
+
signal.signal(s, handler)
|
|
97
|
+
|
|
98
|
+
def plan(self, total: int) -> None:
|
|
99
|
+
self.total = total
|
|
100
|
+
if self.bar:
|
|
101
|
+
self.bar.update(self.task, total=total)
|
|
102
|
+
|
|
103
|
+
def phase(self, text: str) -> None:
|
|
104
|
+
self.label, self.turns = text, None
|
|
105
|
+
self._say(text)
|
|
106
|
+
self._draw()
|
|
107
|
+
|
|
108
|
+
def unit(self, label: str) -> None:
|
|
109
|
+
self.k += 1
|
|
110
|
+
self.label, self.turns = label, None
|
|
111
|
+
self.unit_t = time.monotonic()
|
|
112
|
+
self.first_t = self.first_t or self.unit_t # the rate leaves out building and starting
|
|
113
|
+
self._say(f"{self._of()} {label}: calling")
|
|
114
|
+
self._draw()
|
|
115
|
+
|
|
116
|
+
def turn(self, line: str) -> None:
|
|
117
|
+
"""A line of the caller's output; those that say a turn started move the bar."""
|
|
118
|
+
if not (m := _TURN.fullmatch(line.strip())):
|
|
119
|
+
return
|
|
120
|
+
k, n = self.turns = int(m[1]), int(m[2])
|
|
121
|
+
self._estimate(self.k - 1 + (k - 1) / n)
|
|
122
|
+
if time.monotonic() - self.said_t >= QUIET_S:
|
|
123
|
+
left = self.left()
|
|
124
|
+
self._say(
|
|
125
|
+
f"{self._of()} {self.label}: turn {k} of {n}" + (f" ({left})" if left else "")
|
|
126
|
+
)
|
|
127
|
+
self._draw()
|
|
128
|
+
|
|
129
|
+
def done(self, text: str = "", ok: bool | None = None) -> None:
|
|
130
|
+
"""The unit ended with `text`; `ok`: whether that is what it should be (None: no
|
|
131
|
+
judgement)."""
|
|
132
|
+
text = text or (f"{self.turns[1]} turns" if self.turns else "done")
|
|
133
|
+
self._estimate(self.k)
|
|
134
|
+
if self.bar:
|
|
135
|
+
self._event(ok, f"{self.label} {text}", hms(time.monotonic() - self.unit_t))
|
|
136
|
+
else:
|
|
137
|
+
left = self.left()
|
|
138
|
+
line = f"{self._of()} {self.label}: {text}" + (f" ({left})" if left else "")
|
|
139
|
+
self._say(line if ok is not False else f"FAILED {line}")
|
|
140
|
+
self._draw()
|
|
141
|
+
|
|
142
|
+
def left(self) -> str:
|
|
143
|
+
if self.end_t is None or self.done_units >= self.total:
|
|
144
|
+
return ""
|
|
145
|
+
return f"about {hms(max(0.0, self.end_t - time.monotonic()))} left"
|
|
146
|
+
|
|
147
|
+
def _of(self) -> str:
|
|
148
|
+
return f"[{self.k}/{self.total}]"
|
|
149
|
+
|
|
150
|
+
def _estimate(self, done: float) -> None:
|
|
151
|
+
now = time.monotonic()
|
|
152
|
+
self.done_units = done
|
|
153
|
+
left = eta(now - self.first_t, done, self.total)
|
|
154
|
+
self.end_t = None if left is None else now + left # counts down between estimates
|
|
155
|
+
|
|
156
|
+
def _say(self, line: str) -> None:
|
|
157
|
+
"""A line of the log, without a terminal (on one the bar shows the same)."""
|
|
158
|
+
if not self.bar:
|
|
159
|
+
print(f"{hms(time.monotonic() - self.t0)} {line}", flush=True)
|
|
160
|
+
self.said_t = time.monotonic()
|
|
161
|
+
|
|
162
|
+
def _event(self, ok: bool | None, text: str, note: str) -> None:
|
|
163
|
+
"""A line that stays: above the bar on a terminal, else in the log."""
|
|
164
|
+
if not self.bar:
|
|
165
|
+
self._say(f"{text} {note}")
|
|
166
|
+
return
|
|
167
|
+
mark = {True: "[green]✓[/]", False: "[red]✗[/]", None: "[dim]·[/]"}[ok]
|
|
168
|
+
self.bar.console.print(f"{mark} {escape(text)} [dim]{escape(note)}[/]")
|
|
169
|
+
|
|
170
|
+
def _draw(self) -> None:
|
|
171
|
+
if self.bar:
|
|
172
|
+
turn = f" · turn {self.turns[0]} of {self.turns[1]}" if self.turns else ""
|
|
173
|
+
self.bar.update(self.task, description=self.label + turn, completed=self.done_units)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _Left(rp.ProgressColumn):
|
|
177
|
+
"""Time left, counting down between the estimates units and turns make."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, progress: Progress) -> None:
|
|
180
|
+
super().__init__()
|
|
181
|
+
self.progress = progress
|
|
182
|
+
|
|
183
|
+
def render(self, task: rp.Task) -> Text:
|
|
184
|
+
return Text(self.progress.left(), style="progress.remaining")
|