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/analyze.py ADDED
@@ -0,0 +1,520 @@
1
+ """Turn latency from a caller-side capture, by several methods, against ground truth.
2
+
3
+ Convention: sample k of a packet captured at time t sits at t + k/8000 s. Both ends use it, so
4
+ packetisation cancels and only network delay and method artefacts remain.
5
+
6
+ Methods (caller pcap only):
7
+ wire packets in RTP sequence order, each at its capture time, sample-accurate: the onset is
8
+ the first voiced sample by sequence, when its packet arrived (the earliest an in-order
9
+ player could play it)
10
+ packet as wire, but at packet granularity (first/last voiced packet)
11
+ rtp RTP timestamps anchored at the first packet to arrive, as a jitter-buffered recorder
12
+ concat payloads appended in arrival order, as a naive two-channel recorder does
13
+
14
+ Each hears a pause in the sender's RTP (silence suppression) as silence where its times show one
15
+ (`Stream.detect`): wire and packet every pause, rtp those the timestamps keep, concat none.
16
+
17
+ Truth uses event positions from the agent and caller logs (which packet and sample), times from
18
+ the caller pcap. The agent pcap splits truth - delay into agent scheduling error and network.
19
+ """
20
+
21
+ import bisect
22
+ import itertools
23
+ import json
24
+ import math
25
+ import re
26
+ import statistics
27
+ from collections import Counter
28
+ from collections.abc import Callable, Iterable
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from rtpbench import g711, pcap, rtp, sip, srtp
34
+ from rtpbench.voice import GATE
35
+
36
+ # wire timing's max abs error against the truth, clean, on the host the bars come from: the bar
37
+ # every wire timing in the cases carries (cases.WIRE)
38
+ WIRE_MS = 0.41
39
+
40
+ METHODS = ("wire", "packet", "rtp", "concat")
41
+ CALLER_GAP_NS = 1_000_000_000 # pauses inside one caller turn are shorter than this
42
+ AGENT_GAP_NS = 250_000_000
43
+ # a sender that stops this long and goes on with its stream has paused (silence suppression);
44
+ # lte-bad's jitter stops a stream for 0.18 s at most
45
+ PAUSE_NS = 1_000_000_000
46
+ S = rtp.SAMPLE_NS
47
+
48
+ Timed = list[tuple[int, rtp.Packet]]
49
+
50
+
51
+ @dataclass
52
+ class Capture:
53
+ up: Timed # caller -> agent
54
+ down: Timed # agent -> caller
55
+
56
+
57
+ def load(path: Path, port: int | None = None) -> Capture:
58
+ """The G.711 RTP of a capture at the caller, whose RTP port is `port` (caller.json's
59
+ rtp_port), or without one at the reference agent, whose answer's SDP gives its address."""
60
+ grams = pcap.read(path)
61
+ msgs = sip.captured(grams, path.with_name("keys.log"))
62
+ caller = next(src for _, src, data in msgs if data.startswith(b"INVITE "))
63
+ rings = srtp.keyrings([(src == caller, sip.parse(data).body) for _, src, data in msgs])
64
+ if port is not None:
65
+ me = (caller, port)
66
+ else:
67
+ # its first SDP (a 401 or a 183 may carry another body first)
68
+ sdps = (sip.parse_sdp(sip.parse(d).body) for _, src, d in msgs if src != caller)
69
+ sdp = next(s for s in sdps if s.port)
70
+ me = (sdp.ip, sdp.port)
71
+ cap = split(grams, me, rings, port is not None)
72
+ return Capture(*([x for x in s if x[1].pt in g711.NAMES] for s in (cap.up, cap.down)))
73
+
74
+
75
+ def split(
76
+ grams: list[pcap.Datagram],
77
+ me: tuple[str, int],
78
+ rings: tuple[srtp.Keyring, srtp.Keyring] | None,
79
+ caller: bool = True,
80
+ ) -> Capture:
81
+ """The RTP the capturing side sent from its own address `me` (IP, RTP port) and received
82
+ there, as up and down; it is the caller, or the agent if not `caller`. Both ends may use one
83
+ port number, so the address, not the port, says which way a packet went. SRTP opens with
84
+ `rings` (the caller's packets', the agent's)."""
85
+ up: Timed = []
86
+ down: Timed = []
87
+ for g in grams:
88
+ sent = (g.src, g.sport) == me
89
+ if g.tcp or not (sent or (g.dst, g.dport) == me):
90
+ continue
91
+ from_caller = sent == caller
92
+ data = rings[not from_caller].open(g.payload) if rings else g.payload
93
+ if data is not None and (p := rtp.parse(data)) is not None:
94
+ (up if from_caller else down).append((g.t_ns, p))
95
+ return Capture(up, down)
96
+
97
+
98
+ def signed(diff: int, bits: int) -> int:
99
+ """A difference of wrapping counters (RTP seq, timestamp) as a signed number."""
100
+ half = 1 << (bits - 1)
101
+ return ((diff + half) & ((1 << bits) - 1)) - half
102
+
103
+
104
+ def unwrap(packets: Timed) -> list[int]:
105
+ """Each packet's seq relative to the first to arrive, unwrapped in arrival order (negative
106
+ for packets the first one overtook)."""
107
+ ext, last, out = 0, packets[0][1].seq if packets else 0, []
108
+ for _, p in packets:
109
+ ext += signed(p.seq - last, 16)
110
+ last = p.seq
111
+ out.append(ext)
112
+ return out
113
+
114
+
115
+ def by_seq(packets: Timed) -> Timed:
116
+ """Sequence order (first copy of duplicates)."""
117
+ seen: dict[int, tuple[int, rtp.Packet]] = {}
118
+ for k, tp in zip(unwrap(packets), packets, strict=True):
119
+ seen.setdefault(k, tp)
120
+ return [seen[k] for k in sorted(seen)]
121
+
122
+
123
+ @dataclass
124
+ class Stream:
125
+ """Decoded audio as a method reads it, 160 samples per packet slot."""
126
+
127
+ seq: int # RTP seq of slot 0
128
+ pcm: list[int]
129
+ times: list[int | None] # time of each slot's sample 0; None for a lost packet
130
+
131
+ def index(self, seq0: int, k: int) -> int:
132
+ """Slot sample of sample k of a sender whose first packet had seq `seq0`."""
133
+ return k + rtp.FRAME * (((seq0 - self.seq + 0x8000) & 0xFFFF) - 0x8000)
134
+
135
+ def time(self, i: int, later: bool) -> int | None:
136
+ """Time of sample i. In a lost packet: the next arrived sample if `later`, else the
137
+ previous one (what a listener hears first / last)."""
138
+ k, off = divmod(i, rtp.FRAME)
139
+ if 0 <= k < len(self.times) and (t := self.times[k]) is not None:
140
+ return t + off * S
141
+ step = 1 if later else -1
142
+ while 0 <= (k := k + step) < len(self.times):
143
+ if (t := self.times[k]) is not None:
144
+ return t + (0 if later else rtp.FRAME - 1) * S
145
+ return None
146
+
147
+ def detect(self, find: Callable[[list[int]], list[tuple[int, int]]]) -> list[tuple[int, int]]:
148
+ """[start, end) samples of the speech `find` (a detector) hears here, each pause of the
149
+ sender's heard as a second of silence: PAUSE_NS or more on this stream's clock between
150
+ packets next by sequence (lost ones between at 20 ms each). Joined by sequence alone,
151
+ speech after a pause runs on from the speech before it, and its onset may go unheard.
152
+ Spans end at a pause. (Silence as long as the pause, which a clock that jumps can make
153
+ hours, gave webrtcvad the same spans.)"""
154
+ cuts, prev = [0], None # samples where a pause ends
155
+ for k, t in enumerate(self.times):
156
+ if t is None:
157
+ continue
158
+ if prev is not None and t - prev[1] >= PAUSE_NS + (k - prev[0] - 1) * rtp.PTIME_NS:
159
+ cuts.append(k * rtp.FRAME)
160
+ prev = k, t
161
+ ends = [*cuts[1:], len(self.pcm)]
162
+ pcm: list[int] = []
163
+ starts: list[int] = [] # where each stretch between pauses starts in `pcm`
164
+ for a, b in zip(cuts, ends, strict=True):
165
+ if starts:
166
+ pcm += [0] * (PAUSE_NS // S)
167
+ starts.append(len(pcm))
168
+ pcm += self.pcm[a:b]
169
+
170
+ def here(i: int) -> int: # sample i of `pcm` here; in a pause, the end of the one before
171
+ n = bisect.bisect_right(starts, i) - 1
172
+ return min(cuts[n] + i - starts[n], ends[n])
173
+
174
+ return [(a, b) for a, b in ((here(a), here(b)) for a, b in find(pcm)) if a < b]
175
+
176
+
177
+ def stream(packets: Timed, pt: int, method: str = "wire") -> Stream:
178
+ """In sequence order (lost packets as silence) or, for concat, in arrival order."""
179
+ if any(len(p.payload) != rtp.FRAME for _, p in packets):
180
+ raise ValueError("only 20 ms G.711 packets are supported")
181
+ t0, ts0 = packets[0][0], packets[0][1].ts
182
+ if method == "concat":
183
+ payloads = [p.payload for _, p in packets]
184
+ times: list[int | None] = [t0 + k * rtp.PTIME_NS for k in range(len(packets))]
185
+ seq = packets[0][1].seq
186
+ else:
187
+ seen: dict[int, tuple[int, rtp.Packet]] = {}
188
+ for k, tp in zip(unwrap(packets), packets, strict=True):
189
+ seen.setdefault(k, tp)
190
+ lo, hi = min(seen), max(seen)
191
+ silence = bytes([g711.SILENCE[pt]]) * rtp.FRAME
192
+ slots = [seen.get(k) for k in range(lo, hi + 1)]
193
+ payloads = [s[1].payload if s else silence for s in slots]
194
+ times = [
195
+ None if s is None else t0 + signed(s[1].ts - ts0, 32) * S if method == "rtp" else s[0]
196
+ for s in slots
197
+ ]
198
+ seq = (packets[0][1].seq + lo) & 0xFFFF
199
+ return Stream(seq, g711.decode(pt, b"".join(payloads)), times)
200
+
201
+
202
+ def view(packets: Timed, method: str) -> Iterable[tuple[int, bytes]]:
203
+ """(time of sample 0, payload) per packet, in the order the method reads them."""
204
+ if not packets:
205
+ return
206
+ t0, ts0 = packets[0][0], packets[0][1].ts
207
+ if method == "concat":
208
+ total = 0
209
+ for _, p in packets:
210
+ yield t0 + total * S, p.payload
211
+ total += len(p.payload)
212
+ return
213
+ for t, p in by_seq(packets):
214
+ yield (t0 + signed(p.ts - ts0, 32) * S if method == "rtp" else t), p.payload
215
+
216
+
217
+ def segments(
218
+ timed: Iterable[tuple[int, bytes]], gate: re.Pattern[bytes], samples: bool, gap_ns: int
219
+ ) -> list[list[int]]:
220
+ """[onset, end] of each burst of activity: onset of its first voiced packet, latest end."""
221
+ segs: list[list[int]] = []
222
+ for t, payload in timed:
223
+ if not (first := gate.search(payload)):
224
+ continue
225
+ on = end = t
226
+ if samples:
227
+ last = gate.search(payload[::-1])
228
+ assert last is not None
229
+ on, end = t + first.start() * S, t + (len(payload) - 1 - last.start()) * S
230
+ if segs and on - segs[-1][1] <= gap_ns:
231
+ segs[-1][1] = max(segs[-1][1], end)
232
+ else:
233
+ segs.append([on, end])
234
+ return segs
235
+
236
+
237
+ def latencies(cap: Capture, pt: int, method: str) -> list[int | None]:
238
+ """Per caller turn: first agent onset after the caller's end, minus that end (ns)."""
239
+ gate, samples = g711.gate(pt, GATE), method != "packet"
240
+ mode = "wire" if method == "packet" else method
241
+ ends = [s[1] for s in segments(view(cap.up, mode), gate, samples, CALLER_GAP_NS)]
242
+ onsets = [s[0] for s in segments(view(cap.down, mode), gate, samples, AGENT_GAP_NS)]
243
+ out: list[int | None] = []
244
+ for i, end in enumerate(ends):
245
+ nxt = ends[i + 1] if i + 1 < len(ends) else None
246
+ on = next((o for o in onsets if o > end and (nxt is None or o < nxt)), None)
247
+ out.append(None if on is None else on - end)
248
+ return out
249
+
250
+
251
+ def _at(packets: Timed, seq: int, off: int, later: bool = False) -> tuple[int, bool] | None:
252
+ """Capture time of sample `off` of packet `seq`; if lost and `later`, of the next one by seq."""
253
+ times = [t for t, p in packets if p.seq == seq]
254
+ if times:
255
+ return min(times) + off * S, False
256
+ if later:
257
+ after = [((p.seq - seq) & 0xFFFF, t) for t, p in packets if 0 < (p.seq - seq) & 0xFFFF < 50]
258
+ if after:
259
+ return min(after)[1], True
260
+ return None
261
+
262
+
263
+ def turns(run: Path) -> list[dict[str, Any]]:
264
+ """One record per caller turn: latency per method (ms) and, with an agent log, the truth."""
265
+ caller_log = json.loads((run / "caller.json").read_text())
266
+ caller = load(run / "caller.pcap")
267
+ pt = caller_log["pt"]
268
+ measured = {m: latencies(caller, pt, m) for m in METHODS}
269
+ agent_turns: list[dict[str, Any]] = []
270
+ if (run / "agent.json").exists():
271
+ agent_turns = json.loads((run / "agent.json").read_text())["turns"]
272
+ agent = load(run / "agent.pcap")
273
+ if len(agent_turns) != len(caller_log["turns"]):
274
+ raise ValueError(
275
+ f"{run}: agent answered {len(agent_turns)} turns, "
276
+ f"caller spoke {len(caller_log['turns'])}"
277
+ )
278
+ out = []
279
+ for i, c in enumerate(caller_log["turns"]):
280
+ rec: dict[str, Any] = {"turn": i}
281
+ for m in METHODS:
282
+ v = measured[m][i] if i < len(measured[m]) else None
283
+ rec[m + "_ms"] = None if v is None else v / 1e6
284
+ if agent_turns:
285
+ a = agent_turns[i]
286
+ rec["delay_ms"] = a["delay_ns"] / 1e6
287
+ end = _at(caller.up, c["end_seq"], c["end_off"])
288
+ onset = _at(caller.down, a["onset_seq"], a["onset_off"], later=True)
289
+ agent_end = _at(agent.up, a["end_seq"], a["end_off"])
290
+ agent_on = _at(agent.down, a["onset_seq"], a["onset_off"])
291
+ if end and onset and agent_end and agent_on:
292
+ rec |= {
293
+ "truth_ms": (onset[0] - end[0]) / 1e6,
294
+ "onset_lost": onset[1],
295
+ "sched_err_ms": (agent_on[0] - agent_end[0] - a["delay_ns"]) / 1e6,
296
+ "up_ms": (agent_end[0] - end[0]) / 1e6,
297
+ "down_ms": (onset[0] - agent_on[0]) / 1e6,
298
+ "stamp_ms": (agent_end[0] - a["end_ns"]) / 1e6, # tcpdump vs SO_TIMESTAMPNS
299
+ }
300
+ out.append(rec)
301
+ return out
302
+
303
+
304
+ def stream_stats(packets: Timed) -> dict[str, int]:
305
+ """Distinct packets received, packets the sender sent over that span, and late arrivals."""
306
+ if not packets:
307
+ return {"received": 0, "expected": 0, "reordered": 0}
308
+ ext = unwrap(packets)
309
+ lo = min(ext)
310
+ rel = [k - lo for k in ext]
311
+ reordered, top = 0, -1
312
+ for r in rel:
313
+ reordered += r < top
314
+ top = max(top, r)
315
+ return {"received": len(set(rel)), "expected": max(rel) + 1, "reordered": reordered}
316
+
317
+
318
+ def pacing(packets: Timed) -> tuple[float, float] | None:
319
+ """How evenly a sender's RTP arrived (one call, one direction, G.711): the median of the
320
+ RFC 3550 interarrival jitter estimate (A.8) as it runs, and the p99 of how much later than its
321
+ timestamp says each packet arrived, against the earliest before it (the jitter buffer a
322
+ receiver needs to play 99% of them in time), ms. A new SSRC, a pause of over a second (a
323
+ hold), or timestamps that jump a second or more away from the arrival times (a B2BUA such as
324
+ FreeSWITCH sends a few packets on its own clock, then the bridged leg's on the same SSRC)
325
+ starts afresh. None for fewer than 50 packets past the first of their stretch."""
326
+ jitter: list[float] = []
327
+ late: list[float] = []
328
+ j, t0, ts, least, prev = 0.0, 0, 0, 0, None
329
+ for t, p in packets:
330
+ if p.pt not in g711.NAMES:
331
+ continue
332
+ step = 0 if prev is None else signed(p.ts - prev[1].ts, 32)
333
+ if (
334
+ prev is None
335
+ or p.ssrc != prev[1].ssrc
336
+ or t - prev[0] > 1e9
337
+ or abs(t - prev[0] - step * S) > 1e9
338
+ ):
339
+ t0, ts, j, least = t, 0, 0.0, 0
340
+ else:
341
+ ts += step
342
+ j += (abs(t - prev[0] - step * S) / 1e6 - j) / 16
343
+ jitter.append(j)
344
+ transit = t - t0 - ts * S # against the stretch's first packet
345
+ least = min(least, transit)
346
+ late.append((transit - least) / 1e6)
347
+ prev = t, p
348
+ if len(jitter) < 50:
349
+ return None
350
+ return statistics.median(jitter), _pct(late, 0.99)
351
+
352
+
353
+ def path(pairs: list[tuple[int, int]]) -> tuple[float, float] | None:
354
+ """What the path between two captures added to a sender's pacing, from each packet's (sent,
355
+ arrived) ns, on two hosts' clocks: the p99 of how much later than the earliest each packet
356
+ took, ms, measured against a line through the quickest packet of each second, so that the
357
+ clocks' offset and rate difference drop out; and that line's slope, ppm. None for fewer
358
+ than 50 packets or under 2 s."""
359
+ if len(pairs) < 50:
360
+ return None
361
+ t0 = min(s for s, _ in pairs)
362
+ quickest: dict[int, tuple[int, int]] = {}
363
+ for s, a in pairs:
364
+ second, took = (s - t0) // 1_000_000_000, a - s
365
+ if second not in quickest or took < quickest[second][1]:
366
+ quickest[second] = (s - t0, took)
367
+ if len(quickest) < 2:
368
+ return None
369
+ xs, ys = [x for x, _ in quickest.values()], [y for _, y in quickest.values()]
370
+ line = statistics.linear_regression(xs, ys)
371
+ over = [a - s - line.slope * (s - t0) for s, a in pairs]
372
+ least = min(over)
373
+ return _pct([(v - least) / 1e6 for v in over], 0.99), line.slope * 1e6
374
+
375
+
376
+ def pauses(packets: Timed, lost_max: int) -> list[tuple[float, float, bool]]:
377
+ """Where a sender stopped sending and went on with the same stream (silence suppression):
378
+ a second or more between two packets of one SSRC that arrive in turn, at most `lost_max`
379
+ sequence numbers apart. Per pause: its length, how far the RTP clock fell behind it (the
380
+ pause less what the timestamps advanced; RFC 3551 4.1 has them advance by the pause, so
381
+ about 0), ms; and whether the packet after it carries the marker bit, as 4.1 asks."""
382
+ out = []
383
+ for (t0, a), (t1, b) in itertools.pairwise(p for p in packets if p[1].pt in g711.NAMES):
384
+ if (
385
+ a.ssrc == b.ssrc
386
+ and t1 - t0 >= PAUSE_NS
387
+ and 1 <= signed(b.seq - a.seq, 16) <= lost_max + 1
388
+ ):
389
+ gap = (t1 - t0) / 1e6
390
+ out.append((gap, gap - signed(b.ts - a.ts, 32) * S / 1e6, b.marker))
391
+ return out
392
+
393
+
394
+ def _pct(values: list[float], q: float) -> float:
395
+ """Nearest-rank percentile."""
396
+ s = sorted(values)
397
+ return s[max(0, math.ceil(q * len(s)) - 1)]
398
+
399
+
400
+ def _stats(values: list[float]) -> list[str]:
401
+ """Mean, p95 of magnitude, max magnitude."""
402
+ if not values:
403
+ return ["-"] * 3
404
+ mags = [abs(v) for v in values]
405
+ return [f"{sum(values) / len(values):+.2f}", f"{_pct(mags, 0.95):.2f}", f"{max(mags):.2f}"]
406
+
407
+
408
+ def _table(header: list[str], rows: list[list[str]]) -> str:
409
+ lines = [header, ["---"] * len(header), *rows]
410
+ return "\n".join("| " + " | ".join(cells) + " |" for cells in lines) + "\n"
411
+
412
+
413
+ def transit(path: Path) -> dict[str, list[float]]:
414
+ """RTP through a relay, from a capture of both its legs on one interface: each packet's time
415
+ from arriving to leaving (ms), for the caller's stream ("up") and the agent's ("down")."""
416
+ grams = pcap.read(path)
417
+ msgs = sip.captured(grams, path.with_name("keys.log"))
418
+ caller = next(src for _, src, data in msgs if data.startswith(b"INVITE "))
419
+ seen: dict[tuple[int, int, int], tuple[int, str]] = {}
420
+ out: dict[str, list[float]] = {"up": [], "down": []}
421
+ for g in grams:
422
+ if g.tcp or 5060 in (g.sport, g.dport) or (p := rtp.parse(g.payload)) is None:
423
+ continue
424
+ if (key := (p.ssrc, p.seq, p.ts)) in seen:
425
+ t, src = seen.pop(key)
426
+ out["up" if src == caller else "down"].append((g.t_ns - t) / 1e6)
427
+ else:
428
+ seen[key] = g.t_ns, g.src
429
+ return out
430
+
431
+
432
+ def summarize(runs: dict[str, list[Path]]) -> str:
433
+ """Latency per method; with an agent log, error per method and what truth - delay is made of;
434
+ with a carrier capture, the carrier's own transit time. The runs under one name (one per
435
+ seed) pool into one row."""
436
+ latency, errors, budget, relay = [], [], [], []
437
+ worst = (0.0, "") # wire's largest miss of the truth where the onset packet arrived, and run
438
+ for name, dirs in runs.items():
439
+ ways: dict[str, list[float]] = {}
440
+ recs = []
441
+ for run in dirs:
442
+ if (run / "carrier.pcap").exists():
443
+ for way, ms in transit(run / "carrier.pcap").items():
444
+ ways.setdefault(way, []).extend(ms)
445
+ part = turns(run)
446
+ (run / "turns.jsonl").write_text("".join(json.dumps(r) + "\n" for r in part))
447
+ recs += part
448
+ for way, ms in ways.items():
449
+ cells = (
450
+ [f"{_pct(ms, q):.3f}" for q in (0.5, 0.95)] + [f"{max(ms):.3f}"]
451
+ if ms
452
+ else ["-"] * 3
453
+ )
454
+ relay.append([name, way, str(len(ms)), *cells])
455
+ for m in METHODS:
456
+ v = [r[m + "_ms"] for r in recs if r[m + "_ms"] is not None]
457
+ cells = (
458
+ [f"{_pct(v, 0.5):.1f}", f"{_pct(v, 0.95):.1f}", f"{max(v):.1f}"] if v else ["-"] * 3
459
+ )
460
+ latency.append([name, m, f"{len(v)}/{len(recs)}", *cells])
461
+ ok = [r for r in recs if "truth_ms" in r]
462
+ if not ok:
463
+ continue
464
+ for m in METHODS:
465
+ got = [r for r in ok if r[m + "_ms"] is not None]
466
+ vs_delay = _stats([r[m + "_ms"] - r["delay_ms"] for r in got])
467
+ vs_truth = _stats([r[m + "_ms"] - r["truth_ms"] for r in got])
468
+ errors.append([name, m, f"{len(got)}/{len(recs)}", *vs_delay, *vs_truth])
469
+ for r in ok:
470
+ if r["wire_ms"] is not None and not r["onset_lost"]:
471
+ worst = max(worst, (abs(r["wire_ms"] - r["truth_ms"]), name))
472
+ up, down = Counter[str](), Counter[str]()
473
+ for run in dirs:
474
+ up.update(stream_stats(load(run / "agent.pcap").up))
475
+ down.update(stream_stats(load(run / "caller.pcap").down))
476
+ budget.append(
477
+ [
478
+ name,
479
+ *_stats([r["sched_err_ms"] for r in ok]),
480
+ *_stats([r["up_ms"] + r["down_ms"] for r in ok]),
481
+ f"{max(abs(r['stamp_ms']) for r in ok):.3f}",
482
+ f"{up['expected'] - up['received']}/{up['expected']}, {up['reordered']}",
483
+ f"{down['expected'] - down['received']}/{down['expected']}, {down['reordered']}",
484
+ str(sum(r["onset_lost"] for r in ok)),
485
+ ]
486
+ )
487
+ out = "Turn latency, ms\n\n" + _table(["run", "method", "n", "p50", "p95", "max"], latency)
488
+ if relay:
489
+ out += "\nCarrier transit (in to out on its interface), ms\n\n"
490
+ out += _table(["run", "stream", "packets", "p50", "p95", "max"], relay)
491
+ if not errors:
492
+ return out
493
+ stat = ["mean", "p95 abs", "max abs"]
494
+ miss, where = worst
495
+ warn = (
496
+ f"\nWire timing read up to {miss:.2f} ms off the truth ({where}), outside the "
497
+ f"±{WIRE_MS} ms bar the cases' wire timings carry: this host held packets on their way "
498
+ "between containers (docs/calibration.md, how the truth is known).\n"
499
+ if miss > WIRE_MS
500
+ else ""
501
+ )
502
+ return (
503
+ out
504
+ + "\nError, ms: measured - injected delay | measured - truth\n\n"
505
+ + _table(["run", "method", "n", *stat, *stat], errors)
506
+ + "\nTruth - delay = agent scheduling error + network (up + down), ms\n\n"
507
+ + _table(
508
+ [
509
+ "run",
510
+ *(f"agent {s}" for s in stat),
511
+ *(f"network {s}" for s in stat),
512
+ "max pcap vs SO_TIMESTAMPNS",
513
+ "uplink lost/sent, late",
514
+ "downlink lost/sent, late",
515
+ "onset pkt lost",
516
+ ],
517
+ budget,
518
+ )
519
+ + warn
520
+ )