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