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