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/cases.py ADDED
@@ -0,0 +1,1989 @@
1
+ """Conformance cases: scripted calls with a verdict and timings with error bars.
2
+
3
+ A case is a caller script (a `Session` driven step by step, with `Script` media), a check that
4
+ reads only the caller's capture and log, and reference-agent behaviours: one that should pass and
5
+ at least one that should fail. Against the reference agent each case runs once per behaviour,
6
+ which proves the case itself; against a real target it runs once.
7
+
8
+ Every timing carries the error bar of the way it is measured, from the calibrations. SIP and RTP
9
+ events are capture times (`rtpbench calibrate`: wire timing within 0.41 ms on a clean network);
10
+ agent speech onsets are webrtcvad's (`rtpbench calibrate --speech`, p5..p95 per codec).
11
+ """
12
+
13
+ import contextlib
14
+ import functools
15
+ import itertools
16
+ import json
17
+ import statistics
18
+ import struct
19
+ import time
20
+ from collections import Counter
21
+ from collections.abc import Callable
22
+ from dataclasses import asdict, dataclass, field
23
+ from functools import cached_property
24
+ from pathlib import Path
25
+ from typing import Any, NamedTuple
26
+
27
+ from rtpbench import analyze, clock, detect, g711, pcap, rtp, sip, speech, speechcal, srtp
28
+ from rtpbench.agent import BUSY, DIGITS, Behaviour
29
+ from rtpbench.caller import NO_MEDIA, Media, Session, speech_utterances
30
+ from rtpbench.clock import CLOCK
31
+
32
+ CODECS = (g711.PCMU, g711.PCMA)
33
+ QUIET_NS = 1_500_000_000 # agent silence that ends what it was saying
34
+ GREETING_WAIT_S = 10.0
35
+ GREETING_MAX_MS = 2000 # a caller left in silence longer than this starts saying "hello?"
36
+ REPLY_WAIT_S = 15.0
37
+ BYE_AFTER_NS = 1_000_000_000 # into the agent's speech
38
+ BYE_AUDIBLE_S = 3.0 # then wait this long for a moment the agent is audible
39
+ CANCEL_AFTER_S = 0.2 # after the first provisional response
40
+ FINALS_WAIT_S = 2.0 # after the INVITE's 2xx, for another final response
41
+ MEDIA_GRACE_MS = 500 # RTP sent before the agent handled a request may still be on its way
42
+ WATCH_S = 2.0 # listen after the call ends for media that should have stopped
43
+ AGENT_SIDE = "agent-side.pcap" # a target run's capture taken at the agent (report --agent-pcap)
44
+ SAME_PACKET_NS = 1_000_000 # copies closer than this in the agent's capture: one packet, twice
45
+ MATCHED_MIN = 0.9 # share of a call's packets the agent's capture must hold
46
+ HOLD_S = 3.0
47
+ RESUME_MS = 1000
48
+ CLOCK_MS = 40 # RTP clock vs arrival time, before vs after a pause (two packets)
49
+ FLOOR_PACKETS = 25 # either side of a pause: the least transit time over 0.5 s, past jitter
50
+ SEQ_LOST_MAX = 10 # packets lost in a row where a stream resumes, and it is still the next seq
51
+ SILENT_NS = 1_000_000_000 # agent RTP absent this long before a request: it had stopped by itself
52
+ KEY_MS = 100 # how long the caller holds a key
53
+ REFER_WAIT_S = 20.0 # after asking for a person
54
+ BYE_WAIT_S = 10.0 # after the transfer is reported done
55
+ # after leaving the agent's request unanswered: its timer F (RFC 3261 17.1.2.2), and time to hear
56
+ # what it does then
57
+ LOST_WAIT_S = 64 * sip.T1 + 5.0
58
+ SIPFRAG = "message/sipfrag;version=2.0"
59
+ SESSION_S = 90 # RFC 4028 minimum
60
+ TIMER = [("Supported", "timer"), ("Session-Expires", f"{SESSION_S};refresher=uac")]
61
+ NO_TIMER = "timers not supported (RFC 4028 9, optional; the caller may refresh alone)"
62
+ # no acceptable media in the offer (RFC 3261 21.4.26, 21.6.4)
63
+ DECLINE_OFFER = (488, 606)
64
+ # refusals of a legacy INFO (no Info Package, RFC 6086): Method Not Allowed, Unsupported Media
65
+ # Type, Bad Info Package, Not Implemented
66
+ DECLINE_INFO = (405, 415, 469, 501)
67
+ MEDIA_GAP_MS = 100 # agent RTP paused this long around a refresh: media was disturbed
68
+ LARGE = 1300 # bytes: a request larger than this MUST go over TCP (RFC 3261 18.1.1)
69
+ PADDING = 820 # bytes of header that take an INVITE (~560) past LARGE, under one 1500 MTU
70
+ PTIME_MS = rtp.FRAME / 8
71
+ DTMF_MAX_MS = 5000 # an agent that heard the digit says something within this
72
+ RETAKE_MAX_MS = 5000 # likewise an agent taking the call back after a failed transfer
73
+ REPLY_MAX_MS = 10000 # an agent that heard the question replies by then (AIVC CE slowest: 8.2 s)
74
+
75
+
76
+ class Bar(NamedTuple):
77
+ """measured - truth lies within [lo, hi] ms."""
78
+
79
+ lo: float
80
+ hi: float
81
+ source: str
82
+
83
+
84
+ LABEL_END = Bar(
85
+ -17.0, 17.0, "caller's labelled end; ±5 dB of label threshold, median (calibrate --speech)"
86
+ )
87
+ WIRE = Bar(
88
+ -analyze.WIRE_MS, analyze.WIRE_MS, "capture times; wire timing max abs error, clean (calibrate)"
89
+ )
90
+ # speech detection error per netem profile and codec, from the calibrations under that profile
91
+ # (seeds 1-5 pooled, 215 replies each); a profile without its own calibration (or a target
92
+ # outside the router) takes the clean bar. ONSET once the detector has heard speech in the
93
+ # stream (on A-law it then fires on the lead-in before a reply); ONSET_FIRST for a stream's
94
+ # first speech, a greeting say, which it reads later and without that early tail.
95
+ _SPEECH = "(calibrate --speech, seeds 1-5)"
96
+ ONSET = {
97
+ (profile, pt): Bar(lo, hi, f"webrtcvad onset, p5..p95, {g711.NAMES[pt]}, {profile} {_SPEECH}")
98
+ for profile, pt, lo, hi in (
99
+ ("clean", g711.PCMU, -3.2, 44.6),
100
+ ("clean", g711.PCMA, -133.0, 11.0),
101
+ ("lte", g711.PCMU, -3.6, 43.3),
102
+ ("lte", g711.PCMA, -131.0, 14.1),
103
+ ("lte-bad", g711.PCMU, -15.5, 60.9),
104
+ ("lte-bad", g711.PCMA, -141.5, 22.9),
105
+ )
106
+ }
107
+ ONSET_FIRST = {
108
+ (profile, pt): Bar(
109
+ lo, hi, f"webrtcvad first onset, p5..p95, {g711.NAMES[pt]}, {profile} {_SPEECH}"
110
+ )
111
+ for profile, pt, lo, hi in (
112
+ ("clean", g711.PCMU, 1.0, 63.6),
113
+ ("clean", g711.PCMA, 1.1, 64.9),
114
+ ("lte", g711.PCMU, -0.8, 67.6),
115
+ ("lte", g711.PCMA, -2.5, 67.7),
116
+ ("lte-bad", g711.PCMU, -11.4, 109.8),
117
+ ("lte-bad", g711.PCMA, -13.1, 102.9),
118
+ )
119
+ }
120
+ _BARGE = "(calibrate --barge-in, seeds 1-5)"
121
+ STOP = { # agent stop time on a hard cut, by the gate; seeds 1-5 pooled, 221 stops each
122
+ (profile, pt): Bar(lo, hi, f"gate stop time, p5..p95, {g711.NAMES[pt]}, {profile} {_BARGE}")
123
+ for profile, pt, lo, hi in (
124
+ ("clean", g711.PCMU, -84.8, 0.0),
125
+ ("clean", g711.PCMA, -84.9, 0.0),
126
+ ("lte", g711.PCMU, -81.9, 0.0),
127
+ ("lte", g711.PCMA, -77.6, 0.0),
128
+ ("lte-bad", g711.PCMU, -104.9, 0.0),
129
+ ("lte-bad", g711.PCMA, -100.7, 0.0),
130
+ )
131
+ }
132
+
133
+
134
+ def bar(bars: dict[tuple[str, int], Bar], profile: str, pt: int) -> Bar | None:
135
+ """The error bar for this profile and codec, or the clean one."""
136
+ return bars.get((profile, pt)) or bars.get(("clean", pt))
137
+
138
+
139
+ @dataclass
140
+ class Timing:
141
+ name: str
142
+ ms: float
143
+ bar: Bar
144
+ truth_ms: float | None = None # known when the reference agent logged it
145
+ span: tuple[int, int] | None = None # capture times it runs between
146
+
147
+ def error(self) -> str:
148
+ """measured - truth, flagged when outside the error bar."""
149
+ if self.truth_ms is None:
150
+ return ""
151
+ e = self.ms - self.truth_ms
152
+ return f"{e:+.1f}" + ("" if self.bar.lo <= e <= self.bar.hi else " (outside the bar)")
153
+
154
+ def __str__(self) -> str: # the true value lies within ms - hi .. ms - lo
155
+ return f"{self.ms:.1f} ms ({-self.bar.hi:+.1f}/{-self.bar.lo:+.1f})"
156
+
157
+
158
+ @dataclass
159
+ class Check:
160
+ name: str
161
+ ok: bool | None # None: the case could not get far enough to tell
162
+ detail: str = ""
163
+ span: tuple[int, int] | None = None # capture times a time limit it judges runs between
164
+ declined: bool = False # not ok, but a decline of an optional feature an RFC allows
165
+
166
+
167
+ @dataclass
168
+ class Result:
169
+ checks: list[Check] = field(default_factory=list)
170
+ timings: list[Timing] = field(default_factory=list)
171
+
172
+ @property
173
+ def verdict(self) -> str:
174
+ """fail over inconclusive (what a decline left unchecked may still be wrong) over
175
+ unsupported over pass."""
176
+ if any(c.ok is False and not c.declined for c in self.checks):
177
+ return "fail"
178
+ if any(c.ok is None for c in self.checks):
179
+ return "inconclusive"
180
+ return "unsupported" if any(c.declined for c in self.checks) else "pass"
181
+
182
+
183
+ class Script(Media):
184
+ """Caller media for a case: says the clips the script queues, silence otherwise, and keeps
185
+ the agent's speech bursts as the gate hears them (to pace the script, not to measure)."""
186
+
187
+ def __init__(self, pt: int, events: int | None = None) -> None:
188
+ super().__init__(pt)
189
+ self.events = events # telephone-event payload type
190
+ self.keys: list[str] = [] # digits to press
191
+ self.key: dict[str, int] | None = None # the digit being sent
192
+ self.pressed: list[dict[str, Any]] = []
193
+ self.queue: list[tuple[str, bytes, list[tuple[int, int]]]] = []
194
+ self.said: list[dict[str, Any]] = []
195
+ self.bursts: list[list[int]] = []
196
+ self.muted = False # send nothing (call inactive)
197
+
198
+ def press(self, digit: str) -> None:
199
+ with self.lock:
200
+ self.keys.append(digit)
201
+
202
+ def idle(self, n: int) -> bytes | rtp.Frame | None:
203
+ if self.key is None and self.keys and self.events is not None:
204
+ digit = self.keys.pop(0)
205
+ self.key = {"event": DIGITS.index(digit), "ts": n * rtp.FRAME, "dur": 0, "ends": 0}
206
+ self.pressed.append({"digit": digit, "seq_offset": n})
207
+ if (k := self.key) is None or self.events is None:
208
+ return None if self.muted else self.silence
209
+ # RFC 4733: one timestamp per event, duration so far, end bit on the last three
210
+ first = k["dur"] == 0
211
+ k["dur"] = min(k["dur"] + rtp.FRAME, KEY_MS * 8)
212
+ end = k["dur"] == KEY_MS * 8
213
+ k["ends"] += end
214
+ if k["ends"] == 3:
215
+ self.key = None
216
+ payload = struct.pack("!BBH", k["event"], 0x80 * end | 10, k["dur"])
217
+ return rtp.Frame(self.events, payload, k["ts"], first)
218
+
219
+ def heard(self, onset: int, end: int) -> None:
220
+ if self.bursts and onset - self.bursts[-1][1] <= analyze.AGENT_GAP_NS:
221
+ self.bursts[-1][1] = max(self.bursts[-1][1], end)
222
+ else:
223
+ self.bursts.append([onset, end])
224
+
225
+ def step(self, n: int, tick: int) -> None:
226
+ if self.queue:
227
+ self.said.append(self.play(n, self.queue.pop(0)) | {"tick_ns": tick})
228
+
229
+ def say(self, name: str, pcm: list[int]) -> None:
230
+ self.queue.append((name, g711.encode(self.pt, pcm), speech.label(pcm)))
231
+
232
+ def busy(self) -> bool:
233
+ with self.lock:
234
+ return bool(self.queue or self.audio or self.keys or self.key)
235
+
236
+ def speech_after(self, t_ns: int) -> int | None:
237
+ with self.lock:
238
+ return next((on for on, _ in self.bursts if on > t_ns), None)
239
+
240
+ def quiet(self, ns: int = QUIET_NS) -> bool:
241
+ with self.lock:
242
+ return CLOCK.now() - self.agent_last >= ns
243
+
244
+
245
+ Dial = Callable[..., Session]
246
+ Lines = dict[str, list[int]] # caller lines by name (prompt, transfer), 8 kHz PCM
247
+
248
+
249
+ def _line(lines: Lines, name: str, seed: int) -> tuple[str, list[int]]:
250
+ """The caller's own line, or else a corpus utterance."""
251
+ return (name, lines[name]) if name in lines else speech_utterances(seed)[0]
252
+
253
+
254
+ def greeting(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
255
+ """Answer, listen for the agent's first words and let it finish, hang up."""
256
+ s = dial()
257
+ s.answer()
258
+ m = Script(s.pt)
259
+ s.start(m.source, m.on_packet)
260
+ t0 = CLOCK.now()
261
+ if s.wait(lambda: s.remote_bye or m.speech_after(t0) is not None, GREETING_WAIT_S):
262
+ s.wait(lambda: s.remote_bye or m.quiet(), 30)
263
+ s.hangup()
264
+ return s.meta(seed, s.remote_bye) | {"said": m.said}
265
+
266
+
267
+ def _greeting(s: Session, m: Script) -> int | None:
268
+ """When the agent started its greeting, if it greets within GREETING_WAIT_S."""
269
+ t0 = CLOCK.now()
270
+ s.wait(lambda: s.remote_bye or m.speech_after(t0) is not None, GREETING_WAIT_S)
271
+ return m.speech_after(t0)
272
+
273
+
274
+ def _settle(s: Session, m: Script) -> None:
275
+ """Let the agent's greeting, if any, start and end: an agent may take a second or two to
276
+ start (one that opens its AI session after answering), so a fixed wait is not enough."""
277
+ _greeting(s, m)
278
+ s.wait(lambda: s.remote_bye or m.quiet(), 30)
279
+
280
+
281
+ def _reply(s: Session, m: Script, seed: int, lines: Lines) -> int | None:
282
+ """Ask a question; when the agent started its reply."""
283
+ m.say(*_line(lines, "prompt", seed))
284
+ s.wait(lambda: s.remote_bye or not m.busy(), 30)
285
+ t1 = CLOCK.now()
286
+ s.wait(lambda: s.remote_bye or m.speech_after(t1) is not None, REPLY_WAIT_S)
287
+ return m.speech_after(t1)
288
+
289
+
290
+ def bye(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
291
+ """Get the agent talking (its greeting, or else its reply to a question), BYE at least a
292
+ second into it while it is audible, then watch for media. A greeting that ends without such
293
+ a moment gives way to a question."""
294
+ s = dial()
295
+ s.answer()
296
+ m = Script(s.pt)
297
+ s.start(m.source, m.on_packet)
298
+
299
+ def into(onset: int) -> bool:
300
+ def audible() -> bool:
301
+ return CLOCK.now() >= onset + BYE_AFTER_NS and not m.quiet(analyze.AGENT_GAP_NS)
302
+
303
+ return s.wait(lambda: s.remote_bye or audible(), BYE_AFTER_NS / 1e9 + BYE_AUDIBLE_S)
304
+
305
+ greeted = _greeting(s, m)
306
+ if greeted is None or not into(greeted):
307
+ s.wait(lambda: s.remote_bye or m.quiet(), 30)
308
+ if (replied := _reply(s, m, seed, lines)) is not None:
309
+ into(replied)
310
+ s.hangup()
311
+ s.wait(lambda: False, WATCH_S)
312
+ return s.meta(seed, s.remote_bye) | {"said": m.said}
313
+
314
+
315
+ def cancel(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
316
+ """INVITE, CANCEL it once it rings, then watch for media."""
317
+ s = dial()
318
+ if s.wait(lambda: s.final is not None or s.ringing, 32) and s.final is None:
319
+ if not s.wait(lambda: s.final is not None, CANCEL_AFTER_S):
320
+ s.cancel()
321
+ s.wait(lambda: s.final is not None, 32)
322
+ if s.final is not None and s.final.status < 300: # answered after all: end it properly
323
+ s.hangup()
324
+ s.wait(lambda: False, WATCH_S)
325
+ return s.meta(seed, s.remote_bye)
326
+
327
+
328
+ def invite_retransmit(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
329
+ """INVITE, every response to it in the first T1 lost, so timer A sends it again at T1 (the
330
+ agent may still be setting the call up, or have answered and be resending its 2xx). Then
331
+ take the responses as they come, listen for more final ones and hang up."""
332
+ s = dial()
333
+ until = time.monotonic() + sip.T1 * (s.dialog.transport == "UDP") # TCP and TLS lose none
334
+ while (left := until - time.monotonic()) > 0: # lost: read, never handled
335
+ s.dialog.sock.settimeout(left)
336
+ with contextlib.suppress(TimeoutError):
337
+ s.dialog.sock.recvfrom(65535)
338
+ s.dialog.retransmit() # timer A, due
339
+ # a 2xx may still follow a failure (one agent's 503 to the copy, then its 200 to the INVITE)
340
+ answered = s.wait(lambda: any(_is_2xx_invite(r) for r in s.responses), 64 * sip.T1)
341
+ s.wait(lambda: False, FINALS_WAIT_S)
342
+ if answered:
343
+ s.hangup()
344
+ return s.meta(seed, s.remote_bye)
345
+
346
+
347
+ def hold(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
348
+ """After the greeting: hold (sendonly), resume, hold (inactive), resume; 3 s each."""
349
+ held: list[tuple[str, str | None]] = [("sendonly", None), ("inactive", None)]
350
+ return _hold(dial, seed, [o for h in held for o in (h, ("sendrecv", None))])
351
+
352
+
353
+ def hold_zero(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
354
+ """After the greeting: hold the RFC 2543 way (c=0.0.0.0, no direction attribute; RFC 3264
355
+ 8.4: an agent MUST be able to receive it, and then sends nothing), resume; 3 s each."""
356
+ return _hold(dial, seed, [("", "0.0.0.0"), ("sendrecv", None)])
357
+
358
+
359
+ def _hold(dial: Dial, seed: int, offers: list[tuple[str, str | None]]) -> dict[str, Any]:
360
+ s = dial()
361
+ s.answer()
362
+ m = Script(s.pt)
363
+ s.start(m.source, m.on_packet)
364
+ _settle(s, m)
365
+ for direction, conn in offers:
366
+ if s.remote_bye:
367
+ break
368
+ m.muted = direction == "inactive"
369
+ s.reinvite(direction, conn=conn)
370
+ s.wait(lambda: s.remote_bye, HOLD_S)
371
+ s.hangup()
372
+ return s.meta(seed, s.remote_bye) | {"said": m.said}
373
+
374
+
375
+ def _dtmf(dial: Dial, seed: int, lines: Lines, info: bool) -> dict[str, Any]:
376
+ """After the greeting, press 5 (RFC 4733 if negotiated, or SIP INFO) and listen."""
377
+ s = dial(dtmf=not info)
378
+ s.answer()
379
+ m = Script(s.pt, s.dtmf)
380
+ s.start(m.source, m.on_packet)
381
+ _settle(s, m)
382
+ t = CLOCK.now()
383
+ if info:
384
+ signal = f"Signal=5\r\nDuration={KEY_MS}\r\n".encode()
385
+ s.request("INFO", signal, content_type="application/dtmf-relay")
386
+ elif s.dtmf is not None:
387
+ m.press("5")
388
+ s.wait(lambda: s.remote_bye or not m.busy(), 5)
389
+ if s.wait(lambda: s.remote_bye or m.speech_after(t) is not None, DTMF_MAX_MS / 1000 + 3):
390
+ s.wait(lambda: s.remote_bye or m.quiet(), 30)
391
+ s.hangup()
392
+ return s.meta(seed, s.remote_bye) | {"pressed": m.pressed}
393
+
394
+
395
+ def dtmf_rfc4733(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
396
+ return _dtmf(dial, seed, lines, info=False)
397
+
398
+
399
+ def dtmf_info(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
400
+ return _dtmf(dial, seed, lines, info=True)
401
+
402
+
403
+ def refer(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
404
+ """After the greeting, ask for a person; be the transferee to its REFER: 202, NOTIFY
405
+ sipfrag 100 Trying, call the Refer-To target (the operator answers), NOTIFY its final
406
+ response; wait for the agent to hang up."""
407
+ return _refer(dial, seed, lines)
408
+
409
+
410
+ def refer_fail(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
411
+ """As `refer`, but the operator is busy, so the final NOTIFY reports 486 Busy Here. Then
412
+ listen for the agent taking the call back."""
413
+ return _refer(dial, seed, lines, [BUSY])
414
+
415
+
416
+ def refer_lost(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
417
+ """As `refer`, the agent's first REFER lost: unanswered, the transfer goes on with its copy."""
418
+ return _refer(dial, seed, lines, lose="REFER")
419
+
420
+
421
+ def bye_lost(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
422
+ """As `refer`, the agent's first BYE once the transfer is done lost: answered, its copy."""
423
+ return _refer(dial, seed, lines, lose="BYE")
424
+
425
+
426
+ def _refer(
427
+ dial: Dial,
428
+ seed: int,
429
+ lines: Lines,
430
+ headers: list[tuple[str, str]] | None = None,
431
+ lose: str = "",
432
+ ) -> dict[str, Any]:
433
+ s = dial()
434
+ if lose:
435
+ s.lose.add(lose)
436
+ s.answer()
437
+ m = Script(s.pt)
438
+ s.start(m.source, m.on_packet)
439
+ _settle(s, m)
440
+ m.say(*_line(lines, "transfer", seed))
441
+ s.wait(lambda: s.remote_bye or not m.busy(), 30)
442
+ transfer: dict[str, Any] = {}
443
+ if s.wait(lambda: _came(s, "REFER"), REFER_WAIT_S) and _recovered((s,), s, "REFER"):
444
+ ref = next(r for r in s.requests if r.method == "REFER")
445
+ event = [("Event", "refer")]
446
+ trying = [*event, ("Subscription-State", "active;expires=60")]
447
+ s.request("NOTIFY", b"SIP/2.0 100 Trying\r\n", trying, SIPFRAG)
448
+ # RFC 3515 2.4.2 / RFC 3892: the transferee INVITEs the Refer-To URI, Referred-By copied
449
+ by = [("Referred-By", v) for v in [ref.get("referred-by")] if v]
450
+ t = dial(to=sip.uri_of(ref.get("refer-to")), headers=[*by, *(headers or [])])
451
+ both = (s, t)
452
+ _pump(both, lambda: t.final is not None, 64 * sip.T1)
453
+ frag = t.final.first.removeprefix("SIP/2.0 ") if t.final else "408 Request Timeout"
454
+ transfer = {"uri": t.dialog.target, "call_id": t.dialog.call_id, "final": frag}
455
+ final = [*event, ("Subscription-State", "terminated;reason=noresource")] # RFC 5589
456
+ s.request("NOTIFY", f"SIP/2.0 {frag}\r\n".encode(), final, SIPFRAG)
457
+ if t.final is not None and t.final.status < 300:
458
+ tm = Media(t.pt)
459
+ t.start(tm.source, tm.on_packet)
460
+ _pump(both, lambda: _came(s, "BYE"), BYE_WAIT_S)
461
+ _recovered(both, s, "BYE")
462
+ if t.media:
463
+ t.hangup()
464
+ s.hangup()
465
+ return s.meta(seed, s.remote_bye) | {"said": m.said, "transfer": transfer}
466
+
467
+
468
+ def _came(s: Session, method: str) -> bool:
469
+ """The agent sent `s` a `method` request, taken or lost."""
470
+ return any(r.method == method for r in [*s.requests, *s.lost])
471
+
472
+
473
+ def _recovered(calls: tuple[Session, ...], s: Session, method: str) -> bool:
474
+ """Whether `s` took a `method` request from the agent: when it lost the first, after waiting
475
+ LOST_WAIT_S for a copy (or the agent's BYE)."""
476
+ if any(r.method == method for r in s.lost):
477
+ _pump(
478
+ calls, lambda: s.remote_bye or any(r.method == method for r in s.requests), LOST_WAIT_S
479
+ )
480
+ return any(r.method == method for r in s.requests)
481
+
482
+
483
+ def _pump(calls: tuple[Session, ...], cond: Callable[[], bool], timeout: float) -> bool:
484
+ """`Session.wait` over several calls at once."""
485
+ deadline = time.monotonic() + timeout
486
+ while not cond():
487
+ if time.monotonic() >= deadline:
488
+ return False
489
+ for c in calls:
490
+ c.pump(0.01)
491
+ return True
492
+
493
+
494
+ def _codec(dial: Dial, seed: int, lines: Lines, pt: int) -> dict[str, Any]:
495
+ """Offer one G.711 law only; listen to the agent's greeting (or its reply to a question)."""
496
+ s = dial(codecs=(pt,))
497
+ s.answer()
498
+ m = Script(s.pt)
499
+ s.start(m.source, m.on_packet)
500
+ if _greeting(s, m) is None:
501
+ _reply(s, m, seed, lines)
502
+ s.wait(lambda: s.remote_bye, 1.0)
503
+ s.hangup()
504
+ return s.meta(seed, s.remote_bye) | {"offered": [pt], "said": m.said}
505
+
506
+
507
+ def codec_pcmu(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
508
+ return _codec(dial, seed, lines, g711.PCMU)
509
+
510
+
511
+ def codec_pcma(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
512
+ return _codec(dial, seed, lines, g711.PCMA)
513
+
514
+
515
+ def timer(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
516
+ """Offer session timers (90 s, refresher=uac; after a 422, the agent's Min-SE), refresh once
517
+ at half time, then stop refreshing and wait for the agent to end the expired session."""
518
+ s = dial(headers=[*TIMER, ("Min-SE", str(SESSION_S))])
519
+ ok = s.answer()
520
+ m = Script(s.pt)
521
+ s.start(m.source, m.on_packet)
522
+ expires = ok.get("session-expires")
523
+ interval = int(expires.split(";")[0]) if expires else 0
524
+ if interval:
525
+ s.wait(lambda: s.remote_bye, interval / 2)
526
+ if not s.remote_bye:
527
+ s.reinvite(headers=[("Supported", "timer"), ("Session-Expires", expires)])
528
+ s.wait(lambda: s.remote_bye, interval + 5)
529
+ else:
530
+ s.wait(lambda: s.remote_bye, 2.0)
531
+ s.hangup()
532
+ return s.meta(seed, s.remote_bye)
533
+
534
+
535
+ def _carried(
536
+ dial: Dial,
537
+ seed: int,
538
+ lines: Lines,
539
+ mutation: str | None = None,
540
+ midway: Callable[[Session, Script], Any] | None = None,
541
+ ) -> dict[str, Any]:
542
+ """Through what a carrier sends: after the greeting (and `midway`, if given) ask a question
543
+ and listen for the reply. `mutation` asks the carrier for one, in an X-Mutation header it
544
+ removes."""
545
+ s = dial(headers=[("X-Mutation", mutation)] if mutation else None)
546
+ s.answer()
547
+ m = Script(s.pt)
548
+ s.start(m.source, m.on_packet)
549
+ _settle(s, m)
550
+ if midway and not s.remote_bye:
551
+ midway(s, m)
552
+ _reply(s, m, seed, lines)
553
+ s.wait(lambda: s.remote_bye, 1.0)
554
+ s.hangup()
555
+ return s.meta(seed, s.remote_bye) | {"said": m.said}
556
+
557
+
558
+ def large_invite(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
559
+ """An INVITE over 1300 bytes (a trunk's extra headers) goes over TCP, as RFC 3261 18.1.1
560
+ says it MUST; if the agent refuses the connection, the caller retries over UDP, as 18.1.1
561
+ says it SHOULD. Then as `_carried`: greeting, question, reply."""
562
+ padding = {"headers": [("X-Padding", "0" * PADDING)]}
563
+ try:
564
+ log = _carried(lambda **o: dial(**o | padding | {"tcp": True}), seed, lines)
565
+ refused = ""
566
+ except OSError as e: # connection refused (RST), or not even a SYN-ACK
567
+ refused = str(e)
568
+ log = _carried(lambda **o: dial(**o | padding), seed, lines)
569
+ return log | {"tcp_refused": refused}
570
+
571
+
572
+ def compact_headers(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
573
+ return _carried(dial, seed, lines, "compact-headers")
574
+
575
+
576
+ def sdp_ssrc(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
577
+ return _carried(dial, seed, lines, "sdp-ssrc")
578
+
579
+
580
+ def late_offer(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
581
+ return _carried(dial, seed, lines, "late-offer")
582
+
583
+
584
+ def ptime_30(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
585
+ return _carried(dial, seed, lines, "ptime-30")
586
+
587
+
588
+ def ssrc_change(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
589
+ """The caller's stream becomes a new RTP source before the question (the carrier relays
590
+ it as it comes)."""
591
+ return _carried(dial, seed, lines, midway=lambda s, m: s.switch())
592
+
593
+
594
+ def reinvite(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
595
+ """A session refresh before the question: re-INVITE with the same SDP (RFC 4028 sends one
596
+ every half interval; carriers typically every 15 min)."""
597
+ return _carried(dial, seed, lines, midway=lambda s, m: s.reinvite())
598
+
599
+
600
+ def reinvite_nosdp(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
601
+ """A re-INVITE without SDP before the question (RFC 3261 14.1: the 2xx offers, the ACK
602
+ answers), as PBXs send one to move or audit the media."""
603
+ return _carried(dial, seed, lines, midway=lambda s, m: s.reinvite(offer=False))
604
+
605
+
606
+ def codec_change(dial: Dial, seed: int, lines: Lines) -> dict[str, Any]:
607
+ """Before the question a re-INVITE offers the other G.711 law alone; on its 2xx the caller
608
+ speaks and listens in it (a leg moved to another gateway, a transfer to another trunk)."""
609
+
610
+ def change(s: Session, m: Script) -> None:
611
+ law = g711.PCMU if s.pt == g711.PCMA else g711.PCMA
612
+ resp = s.reinvite(pt=law)
613
+ if resp is not None and resp.status < 300:
614
+ m.recodec(law)
615
+
616
+ return _carried(dial, seed, lines, midway=change)
617
+
618
+
619
+ @dataclass
620
+ class Wire:
621
+ """One case call as the caller's capture saw it, plus the logs."""
622
+
623
+ log: dict[str, Any] # caller.json
624
+ agent: dict[str, Any] | None # agent.json, from the reference agent only
625
+ msgs: list[tuple[int, bool, sip.Message]] # capture time, sent by the caller, message
626
+ up: analyze.Timed # RTP the caller sent
627
+ down: analyze.Timed # RTP the caller received
628
+ profile: str = "clean" # netem profile, for the error bars
629
+ unopened: int = 0 # SRTP packets no key in the SDP authenticated
630
+ untransferable: str = "" # on the mock: the tools the target offered, none callable to transfer
631
+
632
+ def onset(self, t: int) -> Bar:
633
+ """Error bar of the agent's speech onset at capture time `t`."""
634
+ first = not any(a < t for a, _ in self.speech)
635
+ b = bar(ONSET_FIRST if first else ONSET, self.profile, self.log["pt"])
636
+ assert b is not None # every codec has a clean bar
637
+ return b
638
+
639
+ def find(
640
+ self, pred: Callable[[sip.Message], bool], sent: bool, after: int | None = None
641
+ ) -> tuple[int, sip.Message] | None:
642
+ """First message sent (or received) after `after` matching `pred`."""
643
+ return next(
644
+ (
645
+ (t, m)
646
+ for t, out, m in self.msgs
647
+ if out == sent and (after is None or t > after) and pred(m)
648
+ ),
649
+ None,
650
+ )
651
+
652
+ @cached_property
653
+ def audio(self) -> analyze.Stream | None:
654
+ pkts = [(t, p) for t, p in self.down if p.pt == self.log.get("pt")]
655
+ return analyze.stream(pkts, self.log["pt"]) if pkts else None
656
+
657
+ @cached_property
658
+ def speech(self) -> list[tuple[int, int]]:
659
+ """Agent speech (webrtcvad) as [onset, end] capture times."""
660
+ st = self.audio
661
+ if st is None:
662
+ return []
663
+ spans = [(st.time(a, True), st.time(b - 1, False)) for a, b in st.detect(detect.webrtc)]
664
+ return [(a, b) for a, b in spans if a is not None and b is not None]
665
+
666
+ def speech_after(self, t: int) -> int | None:
667
+ return next((a for a, _ in self.speech if a > t), None)
668
+
669
+ def said(self) -> tuple[int, int] | None:
670
+ """First and last labelled sample of the caller's last line, as captured (from the
671
+ stream it was said on)."""
672
+ pkts = [(t, p) for t, p in self.up if (p.pt, p.ssrc) == (self.log["pt"], self.log["ssrc"])]
673
+ if not pkts or not self.log.get("said"):
674
+ return None
675
+ st, s0 = analyze.stream(pkts, self.log["pt"]), self.log["seq0"]
676
+ spans = self.log["said"][-1]["speech"]
677
+ return speechcal._truth(st, [(st.index(s0, a), st.index(s0, b)) for a, b in spans])
678
+
679
+ def truth(self, kind: str) -> tuple[int, int] | None:
680
+ """Labelled first and last sample of the reference agent's `kind` reply, as captured;
681
+ None unless the stream arrived as the agent sent it (a carrier that rewrote its SSRC
682
+ may have renumbered it too)."""
683
+ turns = self.agent["turns"] if self.agent else []
684
+ turn = next((u for u in turns if u.get("kind") == kind), None)
685
+ if (st := self.audio) is None or turn is None or self.agent is None:
686
+ return None
687
+ if any(p.ssrc != self.agent["ssrc"] for _, p in self.down if p.pt == self.log["pt"]):
688
+ return None
689
+ a0 = self.agent["seq0"]
690
+ return speechcal._truth(st, [(st.index(a0, a), st.index(a0, b)) for a, b in turn["speech"]])
691
+
692
+
693
+ def load(run: Path, profile: str = "clean") -> Wire:
694
+ log = json.loads((run / "caller.json").read_text())
695
+ agent_path = run / "agent.json"
696
+ agent = json.loads(agent_path.read_text()) if agent_path.exists() else None
697
+ msgs: list[tuple[int, bool, sip.Message]] = []
698
+ me, port = "", log.get("rtp_port")
699
+ grams = pcap.read(run / "caller.pcap")
700
+ for t, src, data in sip.captured(grams, run / "keys.log"):
701
+ msg = sip.parse(data)
702
+ if msg.get("call-id") != log.get("call_id"):
703
+ continue
704
+ me = me or src # the caller sends the first message, its INVITE
705
+ msgs.append((t, src == me, msg))
706
+ # a request answered with a digest challenge or a 422 (and its 100, that response and the
707
+ # ACK, which share its branch) left the agent as it was: judge the sequence as it would be
708
+ # without credentials, or with the session interval the 422 asked for
709
+ challenged = {m.branch for _, _, m in msgs if m.status in (401, 407, 422)}
710
+ msgs = [x for x in msgs if x[2].branch not in challenged]
711
+ # SRTP: opened with the keys the captured SDP gave, each side's for its own packets
712
+ rings = srtp.keyrings([(sent, m.body) for _, sent, m in msgs])
713
+ cap = analyze.split(grams, (me, port), rings)
714
+ unopened = sum(r.failed for r in rings) if rings else 0
715
+ return Wire(log, agent, msgs, cap.up, cap.down, profile, unopened, _untransferable(run))
716
+
717
+
718
+ def _untransferable(run: Path) -> str:
719
+ """With the mock's log beside the run (`--mock`, or `realtime --out` the run's): the tools
720
+ the target offered the engine, if no session offered one the mock can call to transfer
721
+ (`realtime.Session.transfer_tool`); else ""."""
722
+ log = run.parents[2] / "realtime.jsonl" if len(run.parents) > 2 else None
723
+ if log is None or not log.exists():
724
+ return ""
725
+ sessions = [
726
+ r
727
+ for line in log.read_text().splitlines()
728
+ if (r := json.loads(line)).get("event") == "session"
729
+ ]
730
+ if not sessions or any(r.get("transfer", "") is not None for r in sessions):
731
+ return ""
732
+ return ", ".join(sorted({n for r in sessions for n in r.get("tools", [])})) or "none"
733
+
734
+
735
+ def _is_2xx_invite(m: sip.Message) -> bool:
736
+ return 200 <= m.status < 300 and m.cseq_method == "INVITE"
737
+
738
+
739
+ def check_greeting(w: Wire) -> Result:
740
+ r = Result()
741
+ ok = w.find(_is_2xx_invite, sent=False)
742
+ r.checks.append(Check("call answered", ok is not None))
743
+ if ok is None:
744
+ return r
745
+ onset = w.speech_after(ok[0])
746
+ r.checks.append(Check("agent speaks first", onset is not None))
747
+ if onset is None:
748
+ return r
749
+ ms = (onset - ok[0]) / 1e6
750
+ truth = w.truth("greeting")
751
+ truth_ms = None if truth is None else (truth[0] - ok[0]) / 1e6
752
+ span = (ok[0], onset)
753
+ r.timings.append(Timing("200 OK to agent speech", ms, w.onset(onset), truth_ms, span))
754
+ r.checks.append(
755
+ Check(f"agent speaks within {GREETING_MAX_MS} ms", ms <= GREETING_MAX_MS, span=span)
756
+ )
757
+ return r
758
+
759
+
760
+ def _final(method: str) -> Callable[[sip.Message], bool]:
761
+ return lambda m: m.status >= 200 and m.cseq_method == method
762
+
763
+
764
+ def check_bye(w: Wire) -> Result:
765
+ r = Result()
766
+ bye = w.find(lambda m: m.method == "BYE", sent=True)
767
+ if bye is None:
768
+ r.checks.append(Check("caller hung up", None, "the agent hung up first"))
769
+ return r
770
+ t = bye[0]
771
+ talking = any(a <= t <= b + analyze.AGENT_GAP_NS for a, b in w.speech)
772
+ r.checks.append(Check("agent was talking at the BYE", talking or None))
773
+ ok = w.find(_final("BYE"), sent=False)
774
+ r.checks.append(
775
+ Check("BYE answered 2xx", ok is not None and ok[1].status < 300, ok[1].first if ok else "")
776
+ )
777
+ if ok:
778
+ r.timings.append(Timing("BYE to its response", (ok[0] - t) / 1e6, WIRE, span=(t, ok[0])))
779
+ if w.down:
780
+ span = (t, w.down[-1][0])
781
+ ms = (span[1] - t) / 1e6
782
+ r.timings.append(Timing("BYE to the agent's last RTP", ms, WIRE, span=span))
783
+ stops = Check(
784
+ f"agent RTP stops within {MEDIA_GRACE_MS} ms", ms <= MEDIA_GRACE_MS, span=span
785
+ )
786
+ r.checks.append(stops)
787
+ return r
788
+
789
+
790
+ def check_cancel(w: Wire) -> Result:
791
+ r = Result()
792
+ cancel = w.find(lambda m: m.method == "CANCEL", sent=True)
793
+ if cancel is None:
794
+ ringing = w.find(lambda m: m.cseq_method == "INVITE" and m.status < 200, sent=False)
795
+ final = w.find(_final("INVITE"), sent=False)
796
+ detail = "no provisional response first"
797
+ if ringing and final:
798
+ ms = (final[0] - ringing[0]) / 1e6
799
+ detail = f"final response {ms:.0f} ms after the first provisional, before a CANCEL"
800
+ r.checks.append(Check("CANCEL sent while ringing", None, detail))
801
+ return r
802
+ t = cancel[0]
803
+ ok = w.find(_final("CANCEL"), sent=False)
804
+ r.checks.append(
805
+ Check(
806
+ "CANCEL answered 2xx", ok is not None and ok[1].status < 300, ok[1].first if ok else ""
807
+ )
808
+ )
809
+ final = w.find(_final("INVITE"), sent=False)
810
+ if final is None:
811
+ r.checks.append(Check("INVITE ended 487", False, "no final response"))
812
+ elif final[1].status < 300 and ok is not None and final[0] < ok[0]:
813
+ r.checks.append(Check("INVITE ended 487", None, "answered before the CANCEL arrived"))
814
+ else:
815
+ r.checks.append(Check("INVITE ended 487", final[1].status == 487, final[1].first))
816
+ r.timings.append(
817
+ Timing(
818
+ "CANCEL to the INVITE's final response",
819
+ (final[0] - t) / 1e6,
820
+ WIRE,
821
+ span=(t, final[0]),
822
+ )
823
+ )
824
+ late = [u for u, _ in w.down if u > t + MEDIA_GRACE_MS * 1_000_000]
825
+ span = (t, w.down[-1][0]) if w.down else None
826
+ r.checks.append(Check("no agent RTP after the CANCEL", not late, f"{len(late)} packets", span))
827
+ return r
828
+
829
+
830
+ def check_invite_retransmit(w: Wire) -> Result:
831
+ """The INVITE's copy, sent T1 after it, every response before it lost: while the agent sets
832
+ the call up (no final response yet), answered with a provisional or final response before
833
+ the next copy would go (RFC 3261 17.2.1: the most recent provisional one); after a 2xx, that
834
+ resent until ACKed (13.3.1.4) and the copy absorbed or answered with it (RFC 6026 7.1).
835
+ Either way there is one final response, one status and one To-tag, and nothing provisional
836
+ after it."""
837
+ r = Result()
838
+ ok = w.find(_is_2xx_invite, sent=False)
839
+ if (transport := w.log.get("transport", "UDP")) != "UDP":
840
+ r.checks.append(Check("INVITE copy sent", None, f"over {transport}, which loses nothing"))
841
+ return r
842
+ sent = [(t, m) for t, out, m in w.msgs if out and m.method == "INVITE"]
843
+ copy = next((x for x in sent[1:] if x[1].branch == sent[0][1].branch), None)
844
+ if copy is None:
845
+ r.checks.append(Check("call answered 2xx", ok is not None))
846
+ r.checks.append(Check("INVITE copy sent", None, "the call ended first"))
847
+ return r
848
+ t0, invite = sent[0]
849
+ if ok:
850
+ r.timings.append(Timing("INVITE to the 2xx", (ok[0] - t0) / 1e6, WIRE, span=(t0, ok[0])))
851
+ heard = w.find(_is_2xx_invite, sent=False, after=copy[0]) # the ones before were lost
852
+ detail = "" if heard or not ok else "only before the copy, when lost; never resent"
853
+ r.checks.append(Check("call answered 2xx", heard is not None, detail))
854
+ got = [(t, m) for t, out, m in w.msgs if not out and m.branch == invite.branch]
855
+ got = [(t, m) for t, m in got if m.cseq_method == "INVITE"]
856
+ finals = [(t, m) for t, m in got if m.status >= 200]
857
+ reply = next(((t, m) for t, m in got if t > copy[0]), None)
858
+ if reply:
859
+ ms = (reply[0] - copy[0]) / 1e6
860
+ r.timings.append(
861
+ Timing("INVITE copy to the agent's response", ms, WIRE, span=(copy[0], reply[0]))
862
+ )
863
+ if not finals or finals[0][0] > copy[0]: # still setting the call up
864
+ window = round(2 * sip.T1 * 1e9)
865
+ in_time = reply is not None and reply[0] - copy[0] <= window
866
+ detail = "nothing"
867
+ if reply:
868
+ detail = f"{reply[1].first.removeprefix('SIP/2.0 ')} after {ms:.0f} ms"
869
+ span = (copy[0], reply[0] if reply else copy[0] + window)
870
+ name = f"INVITE copy answered within {window // 1_000_000} ms"
871
+ r.checks.append(Check(name, in_time, detail, span))
872
+ if finals: # nothing provisional once final (17.2.1), as when a copy is taken for a new call
873
+ late = next((m for t, m in got if t > finals[0][0] and m.status < 200), None)
874
+ detail = late.first.removeprefix("SIP/2.0 ") if late else ""
875
+ r.checks.append(Check("no provisional response after the final", late is None, detail))
876
+ seen = {(m.status, sip.tag(m.get("to"))): m.first for _, m in finals}
877
+ each = [
878
+ first.removeprefix("SIP/2.0 ") + ("" if to_tag else " without a To-tag")
879
+ for (_, to_tag), first in seen.items()
880
+ ]
881
+ r.checks.append(Check("one final response to the INVITE", len(seen) <= 1, ", then ".join(each)))
882
+ return r
883
+
884
+
885
+ def _final_to(req: sip.Message) -> Callable[[sip.Message], bool]:
886
+ return lambda m: m.branch == req.branch and m.cseq_method == req.method and m.status >= 200
887
+
888
+
889
+ def _floor(pkts: analyze.Timed, ts0: int) -> float:
890
+ """Least arrival time minus RTP time (ms, timestamps relative to `ts0`) over `pkts`: the
891
+ sender's clock against the capture's, with the path's base delay and without its jitter.
892
+ Comparing it across a pause shows whether the RTP clock kept time; one packet each side
893
+ would carry two packets' jitter."""
894
+ return min(u / 1e6 - analyze.signed(p.ts - ts0, 32) / 8 for u, p in pkts)
895
+
896
+
897
+ # answers to a hold offer that honour it (RFC 3264 6.1: inactive is fine for sendonly; 8.4 asks
898
+ # nothing of the answer to c=0.0.0.0, only that the answerer sends nothing)
899
+ HOLD_ANSWERS = {
900
+ "sendonly": ("recvonly", "inactive"),
901
+ "inactive": ("inactive",),
902
+ "c=0.0.0.0": sip.DIRECTIONS,
903
+ }
904
+
905
+
906
+ def check_hold(w: Wire) -> Result:
907
+ """Per hold and resume: 2xx with an answer that honours the offer, no agent RTP while held,
908
+ RTP back within 1 s of the resume, and the same RTP stream: SSRC, next seq, timestamps that
909
+ kept time (RFC 3550 5.1)."""
910
+ r = Result()
911
+ sent: dict[str, tuple[int, sip.Message]] = {}
912
+ for t, out, m in w.msgs:
913
+ if out and m.method == "INVITE":
914
+ sent.setdefault(m.branch, (t, m))
915
+ reinvites = list(sent.values())[1:]
916
+ if len(reinvites) < 2:
917
+ r.checks.append(Check("hold and resume sent", None, "the call ended first"))
918
+ return r
919
+ for (t_hold, held), (t_back, back) in zip(reinvites[::2], reinvites[1::2], strict=False):
920
+ sdp = sip.parse_sdp(held.body)
921
+ offer = "c=0.0.0.0" if sdp.ip == "0.0.0.0" else sdp.direction
922
+ answers = []
923
+ for t, req, want in ((t_hold, held, HOLD_ANSWERS[offer]), (t_back, back, ("sendrecv",))):
924
+ what = f"{offer} hold" if req is held else f"resume from {offer}"
925
+ resp = w.find(_final_to(req), sent=False)
926
+ ok = resp is not None and resp[1].status < 300
927
+ r.checks.append(Check(f"{what}: 2xx", ok, resp[1].first if resp else "no response"))
928
+ if resp is None or not ok:
929
+ return r
930
+ r.timings.append(
931
+ Timing(f"{what}: re-INVITE to 2xx", (resp[0] - t) / 1e6, WIRE, span=(t, resp[0]))
932
+ )
933
+ got = sip.parse_sdp(resp[1].body).direction
934
+ r.checks.append(Check(f"{what}: answer {' or '.join(want)}", got in want, got))
935
+ answers.append(resp[0])
936
+ grace = MEDIA_GRACE_MS * 1_000_000
937
+ during = [u for u, _ in w.down if answers[0] + grace < u < t_back]
938
+ quiet = (answers[0], t_back)
939
+ r.checks.append(
940
+ Check(f"{offer} hold: no agent RTP", not during, f"{len(during)} packets", quiet)
941
+ )
942
+ if during:
943
+ continue # it never paused, so there is no resume to time
944
+ before = [(u, p) for u, p in w.down if u < t_back]
945
+ after = [(u, p) for u, p in w.down if u > t_back]
946
+ last = max((u for u, _ in w.down if u < t_hold), default=None)
947
+ if not after and last is not None and t_hold - last >= SILENT_NS:
948
+ # an agent that sends RTP only while it speaks, and had nothing to say
949
+ said = "the agent said nothing after the resume, nor for a second before the hold"
950
+ r.checks.append(Check(f"resume from {offer}: agent RTP again", None, said))
951
+ continue
952
+ if not after or not before:
953
+ r.checks.append(Check(f"resume from {offer}: agent RTP again", False))
954
+ continue
955
+ # by sequence, not arrival: the edges of the pause may arrive reordered
956
+ p0, p1 = analyze.by_seq(before)[-1][1], analyze.by_seq(after)[0][1]
957
+ back_span = (answers[1], after[0][0])
958
+ ms = (after[0][0] - answers[1]) / 1e6
959
+ r.timings.append(Timing(f"resume from {offer}: 2xx to agent RTP", ms, WIRE, span=back_span))
960
+ within = f"resume from {offer}: agent RTP within {RESUME_MS} ms"
961
+ r.checks.append(Check(within, ms <= RESUME_MS, span=back_span))
962
+ drift = _floor(before[-FLOOR_PACKETS:], p0.ts) - _floor(after[:FLOOR_PACKETS], p0.ts)
963
+ floors = (before[-FLOOR_PACKETS:][0][0], after[:FLOOR_PACKETS][-1][0])
964
+ r.timings.append(Timing(f"resume from {offer}: RTP clock drift", drift, WIRE, span=floors))
965
+ lost = (p1.seq - p0.seq - 1) & 0xFFFF # packets missing at the resume
966
+ same = p1.ssrc == p0.ssrc and lost <= SEQ_LOST_MAX and abs(drift) <= CLOCK_MS
967
+ detail = f"ssrc {p0.ssrc:#x}->{p1.ssrc:#x}, seq {p0.seq}->{p1.seq}, clock {drift:+.0f} ms"
968
+ r.checks.append(Check(f"resume from {offer}: same RTP stream", same, detail, floors))
969
+ return r
970
+
971
+
972
+ def _reaction(w: Wire, r: Result, t: int) -> Result:
973
+ """The agent's first speech after a digit at `t`."""
974
+ onset = w.speech_after(t)
975
+ r.checks.append(
976
+ Check(
977
+ f"agent reacts within {DTMF_MAX_MS} ms",
978
+ onset is not None and onset - t <= DTMF_MAX_MS * 1e6,
979
+ span=(t, t + DTMF_MAX_MS * 1_000_000 if onset is None else onset),
980
+ )
981
+ )
982
+ if onset is not None:
983
+ truth = w.truth("dtmf")
984
+ truth_ms = None if truth is None else (truth[0] - t) / 1e6
985
+ ms = (onset - t) / 1e6
986
+ r.timings.append(Timing("digit to agent speech", ms, w.onset(onset), truth_ms, (t, onset)))
987
+ return r
988
+
989
+
990
+ def check_dtmf_rfc4733(w: Wire) -> Result:
991
+ """telephone-event in the answer; the agent reacts to the digit (timed from the first
992
+ packet with the end bit). An answer without it declines the offer's (RFC 4733 is optional):
993
+ unsupported."""
994
+ r = Result()
995
+ ok = w.find(_is_2xx_invite, sent=False)
996
+ pt = sip.parse_sdp(ok[1].body).dtmf if ok else None
997
+ declined = ok is not None and pt is None
998
+ r.checks.append(Check("telephone-event negotiated", pt is not None, declined=declined))
999
+ end = next(
1000
+ (t for t, p in w.up if p.pt == pt and len(p.payload) >= 4 and p.payload[1] & 0x80), None
1001
+ )
1002
+ if pt is None or end is None:
1003
+ return r
1004
+ return _reaction(w, r, end)
1005
+
1006
+
1007
+ def check_dtmf_info(w: Wire) -> Result:
1008
+ """INFO answered 2xx; the agent reacts to the digit (timed from the INFO). A refusal in
1009
+ DECLINE_INFO turns down legacy INFO, which it may: unsupported. A 2xx and no reaction is a
1010
+ digit taken and lost."""
1011
+ r = Result()
1012
+ info = w.find(lambda m: m.method == "INFO", sent=True)
1013
+ if info is None:
1014
+ r.checks.append(Check("INFO sent", None))
1015
+ return r
1016
+ resp = w.find(_final_to(info[1]), sent=False)
1017
+ declined = resp is not None and resp[1].status in DECLINE_INFO
1018
+ r.checks.append(
1019
+ Check(
1020
+ "INFO answered 2xx",
1021
+ resp is not None and resp[1].status < 300,
1022
+ resp[1].first if resp else "no response",
1023
+ declined=declined,
1024
+ )
1025
+ )
1026
+ if resp:
1027
+ ms = (resp[0] - info[0]) / 1e6
1028
+ r.timings.append(Timing("INFO to its response", ms, WIRE, span=(info[0], resp[0])))
1029
+ return r if declined else _reaction(w, r, info[0])
1030
+
1031
+
1032
+ def check_refer(w: Wire) -> Result:
1033
+ """A REFER with a SIP or tel Refer-To, both NOTIFYs answered 2xx (the subscription outlives
1034
+ a BYE, RFC 5057), and the agent hangs up within 10 s of the final NOTIFY. If the transfer
1035
+ itself failed (the final NOTIFY is not 2xx), the agent should take the call back instead
1036
+ (`refer-fail`): inconclusive."""
1037
+ r = Result()
1038
+ if (last := _transfer(w, r)) is None:
1039
+ return r
1040
+ # one per NOTIFY in the order first sent (a retransmitted 100 can follow the final one)
1041
+ notifies = list({m.branch: m for _, out, m in w.msgs if out and m.method == "NOTIFY"}.values())
1042
+ frag = notifies[-1].body.decode(errors="replace").strip() if notifies else ""
1043
+ status = frag.split()[1] if len(frag.split()) > 1 else ""
1044
+ if status.isdigit() and int(status) >= 300: # failed; a 1xx is a transfer still pending
1045
+ r.checks.append(Check("Refer-To target answers 2xx", None, frag))
1046
+ return r
1047
+ bye = w.find(lambda m: m.method == "BYE", sent=False)
1048
+ in_time = bye is not None and bye[0] - last <= BYE_WAIT_S * 1e9
1049
+ span = (last, last + round(BYE_WAIT_S * 1e9) if bye is None else bye[0])
1050
+ r.checks.append(Check(f"agent hangs up within {BYE_WAIT_S:.0f} s", in_time, span=span))
1051
+ if bye:
1052
+ r.timings.append(
1053
+ Timing("final NOTIFY to agent BYE", (bye[0] - last) / 1e6, WIRE, span=span)
1054
+ )
1055
+ return r
1056
+
1057
+
1058
+ def check_refer_fail(w: Wire) -> Result:
1059
+ """As `check_refer` up to the NOTIFYs; then, the transfer having failed, the agent takes the
1060
+ call back (RFC 5589 6.3: the transferor resumes the session): no BYE for 10 s, and speech
1061
+ that starts within 5 s of the failure NOTIFY."""
1062
+ r = Result()
1063
+ if (last := _transfer(w, r)) is None:
1064
+ return r
1065
+ bye = w.find(lambda m: m.method == "BYE", sent=False)
1066
+ keeps = bye is None or bye[0] - last > BYE_WAIT_S * 1e9
1067
+ detail = "" if bye is None else f"BYE {(bye[0] - last) / 1e6:.0f} ms after it"
1068
+ span = (last, last + round(BYE_WAIT_S * 1e9) if bye is None else bye[0])
1069
+ r.checks.append(Check(f"agent keeps the call for {BYE_WAIT_S:.0f} s", keeps, detail, span))
1070
+ onset = w.speech_after(last)
1071
+ spoke = onset is not None and onset - last <= RETAKE_MAX_MS * 1e6
1072
+ span = (last, last + RETAKE_MAX_MS * 1_000_000 if onset is None else onset)
1073
+ r.checks.append(Check(f"agent speaks within {RETAKE_MAX_MS} ms", spoke and keeps, span=span))
1074
+ if onset is not None and (bye is None or onset < bye[0]):
1075
+ truth = w.truth("retake")
1076
+ truth_ms = None if truth is None else (truth[0] - last) / 1e6
1077
+ ms = (onset - last) / 1e6
1078
+ r.timings.append(
1079
+ Timing("failure NOTIFY to agent speech", ms, w.onset(onset), truth_ms, span)
1080
+ )
1081
+ return r
1082
+
1083
+
1084
+ def check_refer_lost(w: Wire) -> Result:
1085
+ """As `check_refer`, the agent having resent the REFER the caller lost (`_resent`)."""
1086
+ r, lost = check_refer(w), _resent(w, "REFER")
1087
+ return Result(r.checks + lost.checks, r.timings + lost.timings)
1088
+
1089
+
1090
+ def check_bye_lost(w: Wire) -> Result:
1091
+ """The agent resent the BYE the caller lost (`_resent`)."""
1092
+ r = _resent(w, "BYE")
1093
+ if not r.checks:
1094
+ r.checks.append(Check("agent hangs up", None, "no BYE from the agent"))
1095
+ return r
1096
+
1097
+
1098
+ def _resent(w: Wire, method: str) -> Result:
1099
+ """The agent's first `method` request, which the caller lost: a copy (its branch) within
1100
+ 2*T1 (RFC 3261 17.1.2.2: over UDP timer E fires at T1). Without one, for a REFER, what the
1101
+ agent did once its timer F gave up on it. No checks if it sent none."""
1102
+ r = Result()
1103
+ first = w.find(lambda m: m.method == method, sent=False)
1104
+ if first is None:
1105
+ return r
1106
+ t, req = first
1107
+ if (transport := w.log.get("transport", "UDP")) != "UDP":
1108
+ r.checks.append(Check(f"{method} resent", None, f"over {transport}, which loses nothing"))
1109
+ return r
1110
+ copy = w.find(lambda m: m.method == method and m.branch == req.branch, sent=False, after=t)
1111
+ window = round(2 * sip.T1 * 1e9)
1112
+ detail = "none"
1113
+ if copy:
1114
+ ms = (copy[0] - t) / 1e6
1115
+ r.timings.append(Timing(f"{method} to its copy", ms, WIRE, span=(t, copy[0])))
1116
+ detail = f"after {ms:.0f} ms"
1117
+ elif method == "REFER":
1118
+ gave_up = t + round(64 * sip.T1 * 1e9)
1119
+ bye = w.find(lambda m: m.method == "BYE", sent=False, after=t)
1120
+ if bye and bye[0] < gave_up:
1121
+ detail += f"; the agent hung up {(bye[0] - t) / 1e9:.1f} s after it"
1122
+ elif w.speech_after(gave_up) is not None:
1123
+ detail += "; the agent spoke again after its timer F"
1124
+ else:
1125
+ detail += "; nor did the agent speak again after its timer F"
1126
+ in_time = copy is not None and copy[0] - t <= window
1127
+ span = (t, copy[0] if copy else t + window)
1128
+ name = f"{method} resent within {window // 1_000_000} ms (timer E)"
1129
+ r.checks.append(Check(name, in_time, detail, span))
1130
+ return r
1131
+
1132
+
1133
+ def _transfer(w: Wire, r: Result) -> int | None:
1134
+ """The REFER and the NOTIFYs that report on it; when the last NOTIFY went (the REFER's time
1135
+ if none did), or None without a REFER."""
1136
+ ref = w.find(lambda m: m.method == "REFER", sent=False)
1137
+ if ref is None and w.untransferable: # the mock could not ask the target to transfer
1138
+ tools = f"the engine was offered no transfer tool the mock can call ({w.untransferable})"
1139
+ r.checks.append(Check("agent transfers (REFER)", None, tools))
1140
+ return None
1141
+ r.checks.append(Check("agent transfers (REFER)", ref is not None))
1142
+ if ref is None:
1143
+ return None
1144
+ said = w.said()
1145
+ if said:
1146
+ ms = (ref[0] - said[1]) / 1e6
1147
+ r.timings.append(
1148
+ Timing("end of the request to REFER", ms, LABEL_END, span=(said[1], ref[0]))
1149
+ )
1150
+ target = sip.uri_of(ref[1].get("refer-to"))
1151
+ r.checks.append(
1152
+ Check("Refer-To is a SIP or tel URI", target.startswith(("sip:", "sips:", "tel:")), target)
1153
+ )
1154
+ sent: dict[str, tuple[int, sip.Message]] = {} # first copy of each NOTIFY
1155
+ for t, out, m in w.msgs:
1156
+ if out and m.method == "NOTIFY":
1157
+ sent.setdefault(m.branch, (t, m))
1158
+ notifies = list(sent.values())
1159
+ for _, n in notifies:
1160
+ resp = w.find(_final_to(n), sent=False)
1161
+ what = n.body.decode(errors="replace").strip()
1162
+ r.checks.append(
1163
+ Check(
1164
+ f"NOTIFY ({what}) answered 2xx",
1165
+ resp is not None and resp[1].status < 300,
1166
+ resp[1].first if resp else "no response",
1167
+ )
1168
+ )
1169
+ return notifies[-1][0] if notifies else ref[0]
1170
+
1171
+
1172
+ def check_codec(w: Wire) -> Result:
1173
+ """The offer had one G.711 law: answered 2xx with that law (RFC 3264 6.1: the answer MUST
1174
+ have a format from the offer, and MAY list others it cannot use yet), and the agent's RTP
1175
+ carries it. Refused with DECLINE_OFFER instead (`judge`): unsupported."""
1176
+ r = Result()
1177
+ pt = _offered(w)
1178
+ name = g711.NAMES[pt]
1179
+ ok = w.find(_is_2xx_invite, sent=False)
1180
+ r.checks.append(Check(f"{name}-only offer answered 2xx", ok is not None))
1181
+ if ok is None:
1182
+ return r
1183
+ answer = sip.parse_sdp(ok[1].body).pts
1184
+ r.checks.append(Check(f"answer has {name}", pt in answer, " ".join(map(str, answer))))
1185
+ got = sorted({p.pt for _, p in w.down})
1186
+ detail = " ".join(map(str, got)) or "no agent RTP"
1187
+ r.checks.append(Check(f"agent RTP is {name}", got == [pt], f"payload types {detail}"))
1188
+ return r
1189
+
1190
+
1191
+ def _offered(w: Wire) -> int:
1192
+ """The single law a codec case offered: from the caller's log, or, when the call failed
1193
+ (whose log lacks it), from the INVITE it sent."""
1194
+ if "offered" in w.log:
1195
+ return int(w.log["offered"][0])
1196
+ invite = w.find(lambda m: m.method == "INVITE", sent=True)
1197
+ assert invite is not None # a final response came to it
1198
+ return sip.parse_sdp(invite[1].body).pts[0]
1199
+
1200
+
1201
+ def _refused(w: Wire) -> Result | None:
1202
+ """A codec case's offer refused with DECLINE_OFFER, the check named as in `check_codec`."""
1203
+ final = w.find(_final("INVITE"), sent=False)
1204
+ if final is None or final[1].status not in DECLINE_OFFER:
1205
+ return None
1206
+ name = g711.NAMES[_offered(w)]
1207
+ return Result([Check(f"{name}-only offer answered 2xx", False, final[1].first, declined=True)])
1208
+
1209
+
1210
+ def check_timer(w: Wire) -> Result:
1211
+ """RFC 4028: the 2xx takes the timer (Session-Expires, refresher=uac kept, Require: timer);
1212
+ the refresh is answered 2xx with media undisturbed; the agent, not refreshed again, sends
1213
+ BYE after half the interval and before it expires. A 2xx without Session-Expires is an
1214
+ agent without the extension, which the offer only says the caller supports: unsupported."""
1215
+ r = Result()
1216
+ ok = w.find(_is_2xx_invite, sent=False)
1217
+ expires = ok[1].get("session-expires") if ok else ""
1218
+ tags = {t.strip().lower() for h in ok[1].all("require") for t in h.split(",")} if ok else set()
1219
+ # without it, an agent that does not take timers, unless it says it requires them
1220
+ declined = ok is not None and not expires and "timer" not in tags
1221
+ detail = "" if expires else "no 2xx" if not ok else NO_TIMER if declined else "Require: timer"
1222
+ r.checks.append(Check("Session-Expires in the 2xx", bool(expires), detail, declined=declined))
1223
+ if not ok or not expires:
1224
+ return r
1225
+ interval = int(expires.split(";")[0])
1226
+ kept = sip.param(expires, "refresher") == "uac" and "timer" in ok[1].get("require")
1227
+ r.checks.append(Check("refresher=uac kept, Require: timer", kept, expires))
1228
+ invites: dict[str, tuple[int, sip.Message]] = {}
1229
+ for t, out, m in w.msgs:
1230
+ if out and m.method == "INVITE":
1231
+ invites.setdefault(m.branch, (t, m))
1232
+ refresh = list(invites.values())[1:2]
1233
+ if not refresh:
1234
+ r.checks.append(Check("refresh sent", None, "the call ended first"))
1235
+ return r
1236
+ if (resp_t := _refreshed(w, r, refresh[0])) is None:
1237
+ return r
1238
+ bye = w.find(lambda m: m.method == "BYE", sent=False)
1239
+ after = None if bye is None else (bye[0] - resp_t) / 1e9
1240
+ in_window = after is not None and interval / 2 < after <= interval
1241
+ span = (resp_t, resp_t + interval * 1_000_000_000 if bye is None else bye[0])
1242
+ r.checks.append(
1243
+ Check(f"agent ends the session in ({interval / 2:.0f}, {interval}] s", in_window, span=span)
1244
+ )
1245
+ if bye:
1246
+ r.timings.append(
1247
+ Timing("refresh 2xx to agent BYE", (bye[0] - resp_t) / 1e6, WIRE, span=span)
1248
+ )
1249
+ return r
1250
+
1251
+
1252
+ def _refreshed(w: Wire, r: Result, sent: tuple[int, sip.Message]) -> int | None:
1253
+ """A refresh re-INVITE: answered 2xx (sendrecv) with the agent's media undisturbed; when
1254
+ the 2xx came, or None if it did not."""
1255
+ t, req = sent
1256
+ resp = w.find(_final_to(req), sent=False)
1257
+ good = resp is not None and resp[1].status < 300
1258
+ r.checks.append(Check("refresh answered 2xx", good, resp[1].first if resp else "no response"))
1259
+ if not resp or not good:
1260
+ return None
1261
+ r.timings.append(
1262
+ Timing("refresh re-INVITE to 2xx", (resp[0] - t) / 1e6, WIRE, span=(t, resp[0]))
1263
+ )
1264
+ got = sip.parse_sdp(resp[1].body).direction
1265
+ r.checks.append(Check("refresh answer sendrecv", got == "sendrecv", got))
1266
+ # on the sender's clock, so that network jitter and loss do not read as a disturbance: time
1267
+ # its RTP clock skipped between packets, and how far its clock moved against arrival times
1268
+ byes = [u for u, _, m in w.msgs if m.method == "BYE" and u > resp[0]]
1269
+ end = min([resp[0] + 2_000_000_000, *byes])
1270
+ near = [(u, p) for u, p in w.down if t - SILENT_NS <= u <= end]
1271
+ # silent around it, and silence is what its stream does (a loss burst leaves a seq gap)
1272
+ if not near and analyze.pauses(w.down, SEQ_LOST_MAX):
1273
+ _silent_refresh(w, r, t, resp[0], end)
1274
+ return resp[0]
1275
+ ssrcs = len({p.ssrc for _, p in near})
1276
+ skipped = max(
1277
+ (
1278
+ analyze.signed(b.ts - a.ts, 32) / 8 - analyze.signed(b.seq - a.seq, 16) * PTIME_MS
1279
+ for a, b in itertools.pairwise(p for _, p in near)
1280
+ ),
1281
+ default=0.0,
1282
+ )
1283
+ pre, post = [x for x in near if x[0] < t], [x for x in near if x[0] > resp[0]]
1284
+ shift = _floor(post, near[0][1].ts) - _floor(pre, near[0][1].ts) if pre and post else 0
1285
+ detail = f"clock skipped {skipped:.0f} ms, moved {shift:+.0f} ms, {ssrcs} SSRC"
1286
+ # and it goes on: a stream that stops leaves no gap between the packets it did send
1287
+ goes_on = bool(post) and end - post[-1][0] <= MEDIA_GRACE_MS * 1e6
1288
+ if not goes_on:
1289
+ stop = (post[-1][0] if post else resp[0]) - resp[0]
1290
+ detail = f"agent RTP stops {stop / 1e6:.0f} ms after the 2xx"
1291
+ calm = ssrcs == 1 and goes_on and skipped <= MEDIA_GAP_MS and abs(shift) <= MEDIA_GAP_MS
1292
+ r.checks.append(Check("media undisturbed by the refresh", calm, detail, (round(t - 1e9), end)))
1293
+ return resp[0]
1294
+
1295
+
1296
+ def _silent_refresh(w: Wire, r: Result, t: int, ok: int, end: int) -> None:
1297
+ """The agent sent nothing around a refresh (re-INVITE at `t`, 2xx at `ok`), as one that
1298
+ sends RTP only while it speaks does between replies (its stream pauses in the call): its
1299
+ next RTP must go on with the same stream (SSRC, next seq). How its clock went across the
1300
+ silence is not the refresh's doing; the summary shows it for every pause
1301
+ (`analyze.pauses`)."""
1302
+ name = "media undisturbed by the refresh"
1303
+ before = [p for u, p in w.down if u < t]
1304
+ after = [(u, p) for u, p in w.down if u > ok]
1305
+ if not before or not after:
1306
+ r.checks.append(Check(name, None, "no agent RTP around it", (t - SILENT_NS, end)))
1307
+ return
1308
+ (u, b), a = after[0], before[-1]
1309
+ lost = analyze.signed(b.seq - a.seq, 16) - 1
1310
+ same = b.ssrc == a.ssrc and 0 <= lost <= SEQ_LOST_MAX
1311
+ detail = (
1312
+ f"silent around it; next RTP {(u - ok) / 1e9:.1f} s after the 2xx: "
1313
+ f"ssrc {a.ssrc:#x}->{b.ssrc:#x}, seq {a.seq}->{b.seq}"
1314
+ )
1315
+ r.checks.append(Check(name, same, detail, (t - SILENT_NS, u)))
1316
+
1317
+
1318
+ def check_carried(w: Wire) -> Result:
1319
+ """Whatever the carrier did, the call works: answered 2xx, and the agent replies to a
1320
+ question asked after it within 10 s (so each side hears the other)."""
1321
+ r = Result()
1322
+ r.checks.append(Check("call answered 2xx", w.find(_is_2xx_invite, sent=False) is not None))
1323
+ return _replies(w, r)
1324
+
1325
+
1326
+ def check_large_invite(w: Wire) -> Result:
1327
+ """The INVITE, over 1300 bytes, went over TCP and was answered 2xx there (RFC 3261 18: every
1328
+ element MUST take TCP); then the agent replies to a question as in `check_carried`."""
1329
+ r = Result()
1330
+ invite = w.find(lambda m: m.method == "INVITE", sent=True)
1331
+ size = len(invite[1].render()) if invite else 0
1332
+ r.checks.append(Check(f"INVITE over {LARGE} bytes", size > LARGE or None, f"{size} bytes"))
1333
+ via = invite[1].get("via").split()[0] if invite else ""
1334
+ if refused := w.log.get("tcp_refused"):
1335
+ final = w.find(_final("INVITE"), sent=False)
1336
+ detail = f"{refused}; over UDP then: {final[1].first if final else 'no answer'}"
1337
+ r.checks.append(Check("INVITE over TCP answered 2xx", False, detail))
1338
+ else:
1339
+ ok = w.find(_is_2xx_invite, sent=False)
1340
+ good = ok is not None and via == "SIP/2.0/TCP"
1341
+ r.checks.append(Check("INVITE over TCP answered 2xx", good, via or "no INVITE"))
1342
+ return _replies(w, r)
1343
+
1344
+
1345
+ def check_ssrc_change(w: Wire) -> Result:
1346
+ """As `check_carried`, the caller's audio having switched to a new RTP source first."""
1347
+ r = Result()
1348
+ ssrcs = {p.ssrc for _, p in w.up if p.pt == w.log["pt"]}
1349
+ r.checks.append(Check("caller's RTP switched source", len(ssrcs) == 2 or None, f"{len(ssrcs)}"))
1350
+ return _replies(w, r)
1351
+
1352
+
1353
+ def check_reinvite(w: Wire) -> Result:
1354
+ """A refresh re-INVITE with unchanged SDP: 2xx sendrecv, agent media undisturbed (as for
1355
+ `timer`); then the agent still replies to a question within 10 s."""
1356
+ r = Result()
1357
+ invites: dict[str, tuple[int, sip.Message]] = {}
1358
+ for t, out, m in w.msgs:
1359
+ if out and m.method == "INVITE":
1360
+ invites.setdefault(m.branch, (t, m))
1361
+ refresh = list(invites.values())[1:2]
1362
+ if not refresh:
1363
+ r.checks.append(Check("refresh sent", None, "the call ended first"))
1364
+ return r
1365
+ if not refresh[0][1].body: # the 2xx offers (RFC 3261 14.1), overlapping the session (14.2)
1366
+ resp = w.find(_final_to(refresh[0][1]), sent=False)
1367
+ first = w.find(_is_2xx_invite, sent=False)
1368
+ before = set(sip.parse_sdp(first[1].body).pts) if first else set()
1369
+ got = sip.parse_sdp(resp[1].body).pts if resp else []
1370
+ detail = " ".join(map(str, got)) or "no SDP"
1371
+ r.checks.append(Check("2xx offers the call's codec", bool(before & set(got)), detail))
1372
+ if _refreshed(w, r, refresh[0]) is None:
1373
+ return r
1374
+ return _replies(w, r)
1375
+
1376
+
1377
+ def check_codec_change(w: Wire) -> Result:
1378
+ """A re-INVITE offering the other G.711 law alone: 2xx with that law (RFC 3264 6.1), agent
1379
+ RTP in it from then on, and the agent replies to a question asked in it within 10 s. A 488
1380
+ instead leaves the session as it was (RFC 3261 14.2): unsupported, if the agent's RTP stays
1381
+ in the call's law and it replies to the question asked in that."""
1382
+ r = Result()
1383
+ invites: dict[str, tuple[int, sip.Message]] = {}
1384
+ for t, out, m in w.msgs:
1385
+ if out and m.method == "INVITE":
1386
+ invites.setdefault(m.branch, (t, m))
1387
+ change = list(invites.values())[1:2]
1388
+ if not change:
1389
+ r.checks.append(Check("codec change sent", None, "the call ended first"))
1390
+ return r
1391
+ law = sip.parse_sdp(change[0][1].body).pts[0]
1392
+ name = g711.NAMES[law]
1393
+ resp = w.find(_final_to(change[0][1]), sent=False)
1394
+ ok = resp is not None and resp[1].status < 300
1395
+ declined = resp is not None and resp[1].status == 488
1396
+ first = resp[1].first if resp else ""
1397
+ r.checks.append(Check(f"{name} re-INVITE answered 2xx", ok, first, declined=declined))
1398
+ if resp is None or not (ok or declined):
1399
+ return r
1400
+ if ok:
1401
+ answer = sip.parse_sdp(resp[1].body).pts
1402
+ r.checks.append(Check(f"answer has {name}", law in answer, " ".join(map(str, answer))))
1403
+ else: # the session goes on in the call's law
1404
+ law, name = w.log["pt"], g711.NAMES[w.log["pt"]]
1405
+ after = {p.pt for u, p in w.down if u > resp[0] + MEDIA_GRACE_MS * 1e6}
1406
+ detail = "payload types " + (" ".join(map(str, sorted(after))) or "none")
1407
+ r.checks.append(Check(f"agent RTP is {name} after it", after == {law}, detail))
1408
+ return _replies(w, r)
1409
+
1410
+
1411
+ def _replies(w: Wire, r: Result) -> Result:
1412
+ """The agent's reply to the caller's question: speech within REPLY_MAX_MS of its end."""
1413
+ said = w.said()
1414
+ if said is None:
1415
+ r.checks.append(Check("question asked", None, "the call ended first"))
1416
+ return r
1417
+ onset = w.speech_after(said[1])
1418
+ in_time = onset is not None and onset - said[1] <= REPLY_MAX_MS * 1e6
1419
+ span = (said[1], said[1] + REPLY_MAX_MS * 1_000_000 if onset is None else onset)
1420
+ r.checks.append(Check(f"agent replies within {REPLY_MAX_MS} ms", in_time, span=span))
1421
+ if onset is not None:
1422
+ truth = w.truth("reply")
1423
+ truth_ms = None if truth is None else (truth[0] - said[1]) / 1e6
1424
+ ms = (onset - said[1]) / 1e6
1425
+ timing = Timing("end of the question to agent speech", ms, w.onset(onset), truth_ms, span)
1426
+ r.timings.append(timing)
1427
+ return r
1428
+
1429
+
1430
+ @dataclass(frozen=True)
1431
+ class Case:
1432
+ """A caller script, its check, and the reference agent behaviours that pass and fail it."""
1433
+
1434
+ script: Callable[[Dial, int, Lines], dict[str, Any]]
1435
+ check: Callable[[Wire], Result]
1436
+ good: Behaviour
1437
+ bad: dict[str, Behaviour]
1438
+ # "carrier": only through the carrier (it applies what the case tests); "direct": never
1439
+ # through it (it would hide what the case tests); else either way
1440
+ route: str = ""
1441
+
1442
+ def behaviours(self) -> dict[str, Behaviour]:
1443
+ return {"good": self.good} | self.bad
1444
+
1445
+
1446
+ CASES = {
1447
+ "greeting": Case(
1448
+ greeting,
1449
+ check_greeting,
1450
+ Behaviour(greet_s=0.8),
1451
+ {"late": Behaviour(greet_s=3.0), "silent": Behaviour()},
1452
+ ),
1453
+ "bye": Case(
1454
+ bye,
1455
+ check_bye,
1456
+ Behaviour(greet_s=0.5, greeting="long"),
1457
+ {"linger": Behaviour(greet_s=0.5, greeting="long", linger_s=3.0)},
1458
+ ),
1459
+ "cancel": Case(
1460
+ cancel,
1461
+ check_cancel,
1462
+ Behaviour(ring_s=3.0),
1463
+ {"answers": Behaviour(ring_s=3.0, cancel=False)},
1464
+ ),
1465
+ "hold": Case(
1466
+ hold,
1467
+ check_hold,
1468
+ Behaviour(),
1469
+ {"ignore": Behaviour(hold=False), "clock": Behaviour(clock=False)},
1470
+ ),
1471
+ "dtmf-rfc4733": Case(
1472
+ dtmf_rfc4733,
1473
+ check_dtmf_rfc4733,
1474
+ Behaviour(dtmf_s=0.8),
1475
+ {"deaf": Behaviour(), "no-events": Behaviour(dtmf_s=0.8, events=False)},
1476
+ ),
1477
+ "dtmf-info": Case(
1478
+ dtmf_info,
1479
+ check_dtmf_info,
1480
+ Behaviour(dtmf_s=0.8),
1481
+ {"deaf": Behaviour(), "declines": Behaviour(dtmf_s=0.8, info=False)},
1482
+ ),
1483
+ "refer": Case(
1484
+ refer,
1485
+ check_refer,
1486
+ Behaviour(refer_s=1.5),
1487
+ {
1488
+ "stays": Behaviour(refer_s=1.5, transfer="stay"),
1489
+ "early": Behaviour(refer_s=1.5, transfer="early"),
1490
+ },
1491
+ ),
1492
+ "refer-fail": Case(
1493
+ refer_fail,
1494
+ check_refer_fail,
1495
+ Behaviour(refer_s=1.5, retake_s=0.8),
1496
+ {
1497
+ "hangs-up": Behaviour(refer_s=1.5, transfer="always"),
1498
+ "silent": Behaviour(refer_s=1.5),
1499
+ },
1500
+ ),
1501
+ "codec-pcmu": Case(
1502
+ codec_pcmu,
1503
+ check_codec,
1504
+ Behaviour(greet_s=0.5),
1505
+ {
1506
+ "pcma-anyway": Behaviour(greet_s=0.5, codec=g711.PCMA),
1507
+ "declines": Behaviour(greet_s=0.5, only=g711.PCMA),
1508
+ },
1509
+ route="direct", # rtpengine transcodes a wrong-law answer to the offered law
1510
+ ),
1511
+ "codec-pcma": Case(
1512
+ codec_pcma,
1513
+ check_codec,
1514
+ Behaviour(greet_s=0.5),
1515
+ {
1516
+ "pcmu-anyway": Behaviour(greet_s=0.5, codec=g711.PCMU),
1517
+ "declines": Behaviour(greet_s=0.5, only=g711.PCMU),
1518
+ },
1519
+ route="direct",
1520
+ ),
1521
+ "timer": Case(
1522
+ timer,
1523
+ check_timer,
1524
+ Behaviour(timer=True),
1525
+ {"unsupported": Behaviour(), "no-expiry": Behaviour(timer=True, expire=False)},
1526
+ ),
1527
+ # what a carrier sends; the agent greets and answers the caller's question
1528
+ "compact-headers": Case(
1529
+ compact_headers,
1530
+ check_carried,
1531
+ Behaviour(greet_s=0.5, reply_s=1.0),
1532
+ {"long-names": Behaviour(greet_s=0.5, reply_s=1.0, compact=False)},
1533
+ route="carrier",
1534
+ ),
1535
+ "sdp-ssrc": Case(
1536
+ sdp_ssrc,
1537
+ check_carried,
1538
+ Behaviour(greet_s=0.5, reply_s=1.0),
1539
+ {"strict": Behaviour(greet_s=0.5, reply_s=1.0, lenient_sdp=False)},
1540
+ route="carrier",
1541
+ ),
1542
+ "late-offer": Case(
1543
+ late_offer,
1544
+ check_carried,
1545
+ Behaviour(greet_s=0.5, reply_s=1.0),
1546
+ {"rejects": Behaviour(greet_s=0.5, reply_s=1.0, late_offer=False)},
1547
+ route="carrier",
1548
+ ),
1549
+ "ptime-30": Case(
1550
+ ptime_30,
1551
+ check_carried,
1552
+ Behaviour(greet_s=0.5, reply_s=1.0),
1553
+ {"20ms-only": Behaviour(greet_s=0.5, reply_s=1.0, any_ptime=False)},
1554
+ route="carrier",
1555
+ ),
1556
+ "ssrc-change": Case(
1557
+ ssrc_change,
1558
+ check_ssrc_change,
1559
+ Behaviour(greet_s=0.5, reply_s=1.0),
1560
+ {"first-ssrc": Behaviour(greet_s=0.5, reply_s=1.0, any_ssrc=False)},
1561
+ ),
1562
+ "large-invite": Case(
1563
+ large_invite,
1564
+ check_large_invite,
1565
+ Behaviour(greet_s=0.5, reply_s=1.0),
1566
+ {"udp-only": Behaviour(greet_s=0.5, reply_s=1.0, tcp=False)},
1567
+ route="direct", # the carrier picks the transport to the agent itself
1568
+ ),
1569
+ "reinvite": Case(
1570
+ reinvite,
1571
+ check_reinvite,
1572
+ Behaviour(greet_s=0.5, reply_s=1.0),
1573
+ {"one-way": Behaviour(greet_s=0.5, reply_s=1.0, refresh=False)},
1574
+ ),
1575
+ "reinvite-nosdp": Case(
1576
+ reinvite_nosdp,
1577
+ check_reinvite,
1578
+ Behaviour(greet_s=0.5, reply_s=1.0),
1579
+ {"no-offer": Behaviour(greet_s=0.5, reply_s=1.0, reoffer=False)},
1580
+ ),
1581
+ "hold-zero": Case(
1582
+ hold_zero,
1583
+ check_hold,
1584
+ Behaviour(),
1585
+ {"ignores-address": Behaviour(zero_hold=False)},
1586
+ route="direct", # rtpengine adds a=sendonly to a c=0.0.0.0 offer
1587
+ ),
1588
+ "codec-change": Case(
1589
+ codec_change,
1590
+ check_codec_change,
1591
+ Behaviour(greet_s=0.5, reply_s=1.0),
1592
+ {
1593
+ "stale": Behaviour(greet_s=0.5, reply_s=1.0, recodec=False),
1594
+ "declines": Behaviour(greet_s=0.5, reply_s=1.0, only=g711.PCMU),
1595
+ },
1596
+ route="direct", # rtpengine transcodes a stale answer to the offered law
1597
+ ),
1598
+ "refer-lost": Case(
1599
+ refer_lost,
1600
+ check_refer_lost,
1601
+ Behaviour(refer_s=1.5),
1602
+ {"once": Behaviour(refer_s=1.5, retransmit=False)},
1603
+ route="direct", # the carrier's own transaction resends the REFER to the caller
1604
+ ),
1605
+ "bye-lost": Case(
1606
+ bye_lost,
1607
+ check_bye_lost,
1608
+ Behaviour(refer_s=1.5),
1609
+ {"once": Behaviour(refer_s=1.5, retransmit=False)},
1610
+ route="direct", # likewise the BYE
1611
+ ),
1612
+ "invite-retransmit": Case(
1613
+ invite_retransmit,
1614
+ check_invite_retransmit,
1615
+ Behaviour(ring_s=2.0),
1616
+ {
1617
+ "rejects": Behaviour(ring_s=2.0, copies="reject"),
1618
+ "ignores": Behaviour(ring_s=2.0, copies="ignore"),
1619
+ },
1620
+ route="direct", # the carrier's own transaction answers the copy
1621
+ ),
1622
+ }
1623
+ # not over TCP or TLS: `large-invite`'s INVITE goes over TCP because UDP cannot take it; over a
1624
+ # reliable transport nothing is retransmitted (RFC 3261 17.1.1.2, 17.1.2.2)
1625
+ NOT_STREAM = ("large-invite", "refer-lost", "bye-lost", "invite-retransmit")
1626
+
1627
+
1628
+ def names(carrier: bool, stream: bool = False) -> list[str]:
1629
+ """The cases to run by default with or without the carrier, and over TCP or TLS or not."""
1630
+ skip = "direct" if carrier else "carrier"
1631
+ return [
1632
+ n for n, case in CASES.items() if case.route != skip and not (stream and n in NOT_STREAM)
1633
+ ]
1634
+
1635
+
1636
+ def run(
1637
+ name: str, uri: str, seed: int, lines: Path | None = None, proxy: str | None = None
1638
+ ) -> dict[str, Any]:
1639
+ """The caller side of a case; its log (with the error if the call failed). `lines`: a
1640
+ directory with the caller's own prompt.wav and transfer.wav (else corpus utterances).
1641
+ `proxy`: where calls to other URIs (a transfer's) go, else to their own host."""
1642
+ said = {p.stem: speech.read_wav(p) for p in sorted(lines.glob("*.wav"))} if lines else {}
1643
+ calls: list[Session] = []
1644
+
1645
+ def dial(to: str | None = None, **options: Any) -> Session:
1646
+ codecs = options.pop("codecs", CODECS)
1647
+ calls.append(Session(to or uri, codecs, proxy=proxy if to else None, **options))
1648
+ return calls[-1]
1649
+
1650
+ try:
1651
+ return {"case": name} | CASES[name].script(dial, seed, said)
1652
+ except sip.SipError as e:
1653
+ log = calls[0].meta(seed, False) if calls else {"seed": seed}
1654
+ return {"case": name, "error": str(e)} | log
1655
+
1656
+
1657
+ @functools.cache # a run is judged as it ends, for the progress, and again for the summary
1658
+ def judge(run: Path, profile: str = "clean") -> Result:
1659
+ """The case's verdict from a run directory."""
1660
+ w = load(run, profile)
1661
+ if "error" in w.log:
1662
+ # a single-law offer refused as RFC 3261 allows: the agent does not take that law alone
1663
+ if CASES[w.log["case"]].check is check_codec and (refused := _refused(w)):
1664
+ return refused
1665
+ return Result([Check("call completed", False, w.log["error"])])
1666
+ r = CASES[w.log["case"]].check(w)
1667
+ if (silent := no_media(w)) is not None:
1668
+ r.checks.append(silent)
1669
+ return r
1670
+
1671
+
1672
+ def no_media(w: Wire) -> Check | None:
1673
+ """A real target answered and we sent RTP, but none came back: a failing check that says
1674
+ where our offer asked for it."""
1675
+ if w.agent is not None or not w.up or w.down or not w.find(_is_2xx_invite, sent=False):
1676
+ return None
1677
+ offer = w.find(lambda m: m.method == "INVITE", sent=True)
1678
+ addr = sip.parse_sdp(offer[1].body).ip if offer else "?"
1679
+ why = f"none, while ours went out; our offer gave c=IN IP4 {addr}: {NO_MEDIA}"
1680
+ return Check("agent RTP arrived", False, why)
1681
+
1682
+
1683
+ class Run(NamedTuple):
1684
+ """One case call of a results directory."""
1685
+
1686
+ profile: str # netem profile
1687
+ repeat: int
1688
+ case: str
1689
+ behaviour: str | None # the reference agent's; None for a real target
1690
+ run: str # its directory, relative to the results directory
1691
+
1692
+
1693
+ Group = tuple[str, str, str | None] # profile, case, behaviour
1694
+
1695
+
1696
+ def groups(records: list[dict[str, Any]]) -> dict[Group, list[dict[str, Any]]]:
1697
+ """Records by profile, case and behaviour, repeats in order."""
1698
+ out: dict[Group, list[dict[str, Any]]] = {}
1699
+ for rec in records:
1700
+ out.setdefault((rec["profile"], rec["case"], rec["behaviour"]), []).append(rec)
1701
+ return out
1702
+
1703
+
1704
+ DECLINES = ("unsupported", "declines", "no-events") # reference behaviours that decline
1705
+
1706
+
1707
+ def expected(behaviour: str | None) -> str | None:
1708
+ if behaviour is None:
1709
+ return None
1710
+ if behaviour in DECLINES: # an optional feature declined as an RFC allows
1711
+ return "unsupported"
1712
+ return "pass" if behaviour == "good" else "fail"
1713
+
1714
+
1715
+ def verdicts(recs: list[dict[str, Any]]) -> str:
1716
+ """The verdict (with how many repeats gave it), or the mix when it flips between repeats."""
1717
+ count = Counter(r["verdict"] for r in recs)
1718
+ if len(count) > 1:
1719
+ return "flips: " + ", ".join(f"{v} {n}" for v, n in count.most_common())
1720
+ v = recs[0]["verdict"]
1721
+ return v if len(recs) == 1 else f"{v} {len(recs)}/{len(recs)}"
1722
+
1723
+
1724
+ def _spread(v: list[float], fmt: str) -> str:
1725
+ """One value, or the median [min, max] over repeats."""
1726
+ if len(v) == 1:
1727
+ return format(v[0], fmt)
1728
+ return f"{format(statistics.median(v), fmt)} [{format(min(v), fmt)}, {format(max(v), fmt)}]"
1729
+
1730
+
1731
+ def _stepped(recs: list[dict[str, Any]], name: str) -> str:
1732
+ """How many of the runs measured `name` across a wall-clock step, as a note."""
1733
+ k = sum(name in r.get("stepped", ()) for r in recs)
1734
+ return f" [{k} across a clock step]" if k else ""
1735
+
1736
+
1737
+ def timings(recs: list[dict[str, Any]]) -> list[tuple[str, str]]:
1738
+ """Each timing's name and value over the repeats, with its error bar: true value within
1739
+ value - hi .. value - lo."""
1740
+ by: dict[str, tuple[list[float], Bar]] = {}
1741
+ for r in recs:
1742
+ for t in r["timings"]:
1743
+ by.setdefault(t["name"], ([], Bar(*t["bar"])))[0].append(t["ms"])
1744
+ n = len(recs) > 1
1745
+ return [
1746
+ (
1747
+ name,
1748
+ f"{_spread(v, '.1f')} ms ({-b.hi:+.1f}/{-b.lo:+.1f})"
1749
+ + (f" n={len(v)}" if n else "")
1750
+ + _stepped(recs, name),
1751
+ )
1752
+ for name, (v, b) in by.items()
1753
+ ]
1754
+
1755
+
1756
+ def failures(recs: list[dict[str, Any]]) -> list[str]:
1757
+ """Checks that failed or could not tell: with the detail for one run, else how many runs
1758
+ and the first one's detail."""
1759
+ count: Counter[str] = Counter()
1760
+ detail: dict[str, str] = {}
1761
+ for r in recs:
1762
+ for c in r["checks"]:
1763
+ if not c["ok"]:
1764
+ count[c["name"]] += 1
1765
+ detail.setdefault(c["name"], c["detail"])
1766
+
1767
+ def stepped(c: str) -> str: # of the runs where `c` failed
1768
+ return _stepped(
1769
+ [r for r in recs if any(x["name"] == c and not x["ok"] for x in r["checks"])], c
1770
+ )
1771
+
1772
+ if len(recs) == 1:
1773
+ return [(f"{c} ({detail[c]})" if detail[c] else c) + stepped(c) for c in count]
1774
+ return [
1775
+ f"{c} ({k}/{len(recs)}{': ' + detail[c] if detail[c] else ''})" + stepped(c)
1776
+ for c, k in count.items()
1777
+ ]
1778
+
1779
+
1780
+ def errors(recs: list[dict[str, Any]]) -> list[str]:
1781
+ """measured - truth of each timing whose truth the reference agent knows, over the repeats,
1782
+ and how many fell outside the error bar."""
1783
+ by: dict[str, list[tuple[float, bool]]] = {}
1784
+ for r in recs:
1785
+ for t in r["timings"]:
1786
+ if t["truth_ms"] is not None:
1787
+ e, b = t["ms"] - t["truth_ms"], Bar(*t["bar"])
1788
+ by.setdefault(t["name"], []).append((e, b.lo <= e <= b.hi))
1789
+ out = []
1790
+ for name, es in by.items():
1791
+ outside = sum(not ok for _, ok in es)
1792
+ note = f" ({outside}/{len(es)} outside the bar)" if outside else ""
1793
+ out.append(_spread([e for e, _ in es], "+.1f") + note + _stepped(recs, name))
1794
+ return out
1795
+
1796
+
1797
+ def proven(records: list[dict[str, Any]]) -> bool:
1798
+ """Every reference behaviour got the verdict it should in every repeat."""
1799
+ return all(r["verdict"] == expected(r["behaviour"]) for r in records if r["behaviour"])
1800
+
1801
+
1802
+ def pacing(calls: list[tuple[str, Path]], uri: bool, agent_side: Path | None = None) -> str:
1803
+ """How evenly the agent's RTP reached the caller, per profile over the `calls` (profile, run
1804
+ directory) that have one: `analyze.pacing` of each, median [min, max] over the calls; empty
1805
+ if none has. `uri`: the agent is at a SIP URI, beyond a real path. `agent_side`: a capture
1806
+ taken at the agent, which splits the agent's own pacing from the path's."""
1807
+ by: dict[str, list[tuple[float, float]]] = {}
1808
+ paused: dict[str, list[list[tuple[float, float, bool]]]] = {}
1809
+ sent = _sent(agent_side) if agent_side else {}
1810
+ split: dict[str, list[tuple[tuple[float, float], tuple[float, float]] | None]] = {}
1811
+ for profile, run in calls:
1812
+ wire = load(run, profile)
1813
+ down = wire.down
1814
+ if (paced := analyze.pacing(down)) is not None:
1815
+ by.setdefault(profile, []).append(paced)
1816
+ if agent_side:
1817
+ split.setdefault(profile, []).append(_split(down, sent, bool(wire.log.get("srtp"))))
1818
+ if not run.name.startswith("hold"): # in a hold, stopping is what the case asks for
1819
+ paused.setdefault(profile, []).append(analyze.pauses(down, SEQ_LOST_MAX))
1820
+ if not by:
1821
+ return _pauses(paused).lstrip() if paused else ""
1822
+ rows = [
1823
+ [p, str(len(v)), _spread([late for _, late in v], ".1f"), _spread([j for j, _ in v], ".2f")]
1824
+ for p, v in by.items()
1825
+ ]
1826
+ return (
1827
+ "The agent's RTP as the caller received it, ms, median [min, max] over calls: late, p99 "
1828
+ "of how much later than their timestamps say the packets arrived, against the earliest "
1829
+ "before them: the jitter buffer a receiver needs to play 99% of them in time; jitter, the "
1830
+ "RFC 3550 interarrival jitter estimate, its median over each call. On a clean network "
1831
+ + (
1832
+ "both are the agent's own pacing plus the jitter of the real path beyond the router, "
1833
+ "which clean does not remove"
1834
+ if uri
1835
+ else "both are the agent's own pacing (and a carrier's)"
1836
+ )
1837
+ + "; a profile adds its own.\n\n"
1838
+ + analyze._table(["profile", "calls", "late, p99", "jitter"], rows)
1839
+ + (_split_table(split) if agent_side else "")
1840
+ + _pauses(paused)
1841
+ )
1842
+
1843
+
1844
+ Sent = dict[tuple[int, int, int], tuple[int, rtp.Packet] | None]
1845
+
1846
+
1847
+ def _sent(capture: Path) -> Sent:
1848
+ """Each G.711 RTP packet the capture taken at the agent saw, and when, by SSRC, sequence
1849
+ number and timestamp; None where two packets share those (which of them arrived is
1850
+ unknown). A packet seen twice within SAME_PACKET_NS (on two interfaces) counts once, at its
1851
+ first. A capture cut to the headers (tcpdump -s) is enough: its payloads are what it kept."""
1852
+ out: Sent = {}
1853
+ for g in pcap.read(capture, partial=True):
1854
+ if g.tcp or (p := rtp.parse(g.payload)) is None or p.pt not in g711.NAMES:
1855
+ continue
1856
+ key = (p.ssrc, p.seq, p.ts)
1857
+ if key not in out:
1858
+ out[key] = g.t_ns, p
1859
+ elif (seen := out[key]) is not None and g.t_ns - seen[0] >= SAME_PACKET_NS:
1860
+ out[key] = None
1861
+ return out
1862
+
1863
+
1864
+ def _split(
1865
+ down: analyze.Timed, sent: Sent, encrypted: bool
1866
+ ) -> tuple[tuple[float, float], tuple[float, float]] | None:
1867
+ """For one call: the agent's own pacing (`analyze.pacing` on its packets as the agent's
1868
+ capture saw them leave) and the path's (`analyze.path`); None when the capture holds under
1869
+ MATCHED_MIN of the packets the caller received (something between rewrote their SSRC,
1870
+ sequence numbers or timestamps, as a repacketizing media relay does, or the capture missed
1871
+ the call). A packet pairs by SSRC, sequence number and timestamp, never where those are
1872
+ ambiguous, and with the same payload, as far as the capture kept it, unless the call is SRTP
1873
+ (`encrypted`)."""
1874
+ packets = [(t, p) for t, p in down if p.pt in g711.NAMES]
1875
+ pairs = []
1876
+ for t, p in packets:
1877
+ mine = sent.get((p.ssrc, p.seq, p.ts))
1878
+ if mine is not None and (encrypted or p.payload.startswith(mine[1].payload)):
1879
+ pairs.append((mine[0], t))
1880
+ if not packets or len(pairs) < MATCHED_MIN * len(packets):
1881
+ return None
1882
+ first, last = min(s for s, _ in pairs), max(s for s, _ in pairs)
1883
+ ssrcs = {p.ssrc for _, p in packets}
1884
+ # what the agent sent over the span, lost on the way or not, in the order it left
1885
+ own = sorted(
1886
+ (x for x in sent.values() if x and x[1].ssrc in ssrcs and first <= x[0] <= last),
1887
+ key=lambda x: x[0],
1888
+ )
1889
+ at_agent, on_path = analyze.pacing(own), analyze.path(pairs)
1890
+ return (at_agent, on_path) if at_agent and on_path else None
1891
+
1892
+
1893
+ def _split_table(
1894
+ by: dict[str, list[tuple[tuple[float, float], tuple[float, float]] | None]],
1895
+ ) -> str:
1896
+ rows = []
1897
+ for profile, calls in by.items():
1898
+ got = [c for c in calls if c]
1899
+ columns = (
1900
+ ([a[1] for a, _ in got], ".1f"), # at the agent: late
1901
+ ([a[0] for a, _ in got], ".2f"), # jitter
1902
+ ([w[0] for _, w in got], ".1f"), # the path: late
1903
+ ([float(round(w[1])) for _, w in got], ".0f"), # its clock rate, no "-0"
1904
+ )
1905
+ cells = [_spread(v, fmt) if v else "-" for v, fmt in columns]
1906
+ rows.append([profile, f"{len(got)}/{len(calls)}", *cells])
1907
+ head = ["profile", "calls matched", "at the agent: late, p99", "jitter", "path: late, p99"]
1908
+ return (
1909
+ f"\nSplit by the capture taken at the agent ({AGENT_SIDE}), ms, median [min, max] over "
1910
+ "calls: at the agent, late and jitter as above from when its packets left; the path, "
1911
+ "p99 of how much later than the quickest each packet took from the agent to the "
1912
+ "caller, against a line through the quickest of each second, which takes up the two "
1913
+ "clocks' offset and rate difference (ppm, the caller's clock against the agent's). "
1914
+ "Packets pair by SSRC, sequence number and timestamp; a call whose packets the capture "
1915
+ f"does not hold (at least {MATCHED_MIN:.0%}) is not matched: something between the "
1916
+ "two captures rewrote them, or the capture missed the call.\n\n"
1917
+ + analyze._table([*head, "clock rate, ppm"], rows)
1918
+ )
1919
+
1920
+
1921
+ def _pauses(by: dict[str, list[list[tuple[float, float, bool]]]]) -> str:
1922
+ """Where the agent stopped sending RTP and went on with the same stream, per profile over
1923
+ calls (each a list of `analyze.pauses`); a sentence if it never did."""
1924
+ if not any(p for calls in by.values() for p in calls):
1925
+ return (
1926
+ "\nPauses: none; outside a hold, the agent's RTP never went on after stopping for a "
1927
+ "second or more.\n"
1928
+ )
1929
+ rows = []
1930
+ for profile, calls in by.items():
1931
+ each = [p for c in calls for p in c]
1932
+ behind = _spread([b for _, b, _ in each], ".0f") if each else "-"
1933
+ marked = f"{sum(m for _, _, m in each)}/{len(each)}"
1934
+ counts = _spread([float(len(c)) for c in calls], ".0f")
1935
+ rows.append([profile, str(len(calls)), counts, behind, marked])
1936
+ return (
1937
+ "\nPauses: where the agent stopped sending RTP for a second or more and went on with the "
1938
+ "same stream, outside a hold (silence suppression, RFC 3551 4.1), median [min, max]: per "
1939
+ "call; how far the RTP clock fell behind across each, ms (the pause less what the "
1940
+ "timestamps advanced: 0 if they kept time, as RFC 3550 5.1 requires); and how many "
1941
+ "packets after one carry the marker bit, as 4.1 asks. A carrier that hangs up on RTP "
1942
+ "inactivity may hang up on these.\n\n"
1943
+ + analyze._table(["profile", "calls", "pauses", "clock behind", "marker"], rows)
1944
+ )
1945
+
1946
+
1947
+ def summarize(out: Path, runs: list[Run]) -> tuple[str, list[dict[str, Any]], bool]:
1948
+ """Markdown summary per profile (repeats grouped), per-run records, and whether every
1949
+ reference behaviour got the verdict it should (good: pass, bad: fail) in every repeat."""
1950
+ steps = clock.steps(out)
1951
+ known = steps or []
1952
+ records = []
1953
+
1954
+ def stepped(x: Check | Timing) -> bool: # what was timed across a step reads off by it
1955
+ width = x.bar.hi - x.bar.lo if isinstance(x, Timing) else 0 # a check's limit: no bar
1956
+ return x.span is not None and clock.spans(*x.span, known, width)
1957
+
1958
+ for run in runs:
1959
+ r = judge(out / run.run, run.profile)
1960
+ timed: list[Check | Timing] = [*r.checks, *r.timings]
1961
+ names = sorted({x.name for x in timed if stepped(x)})
1962
+ record = run._asdict() | {"verdict": r.verdict} | asdict(r) | {"stepped": names}
1963
+ # `declined` only where true, so records of sets without a decline stay as they were
1964
+ record["checks"] = [
1965
+ {k: v for k, v in c.items() if k != "declined" or v} for c in record["checks"]
1966
+ ]
1967
+ records.append(record)
1968
+ reference = bool(records) and records[0]["behaviour"] is not None
1969
+ head = ["case", "agent", "expected"] if reference else ["case"]
1970
+ head += ["result", "timings, ms (error bar)", "failed checks"]
1971
+ head += ["measured - truth, ms"] if reference else []
1972
+ rows: dict[str, list[list[str]]] = {}
1973
+ for (profile, case, behaviour), recs in groups(records).items():
1974
+ row = [case, behaviour or "", expected(behaviour) or ""] if reference else [case]
1975
+ row += [verdicts(recs), "<br>".join(f"{n}: {v}" for n, v in timings(recs)) or "-"]
1976
+ row += ["; ".join(failures(recs)) or "-"]
1977
+ row += [", ".join(errors(recs)) or "-"] if reference else []
1978
+ rows.setdefault(profile, []).append(row)
1979
+ text = "".join(f"### {p}\n\n{analyze._table(head, r)}\n" for p, r in rows.items())
1980
+ if reference:
1981
+ repeats = max(r["repeat"] for r in records)
1982
+ ok = proven(records)
1983
+ text += f"Every case {'proven' if ok else 'NOT proven'} over {repeats} repeat(s): "
1984
+ text += "good passes, bad fails, a decline is unsupported.\n"
1985
+ bars = {b for r in records for t in r["timings"] for b in [Bar(*t["bar"])]}
1986
+ text += "\nError bars: true value = measured - error, error within\n\n"
1987
+ text += "".join(f"- [{b.lo:+.1f}, {b.hi:+.1f}] ms: {b.source}\n" for b in sorted(bars))
1988
+ text += "\n" + clock.note(steps) + clock.rate_note(out)
1989
+ return text, records, proven(records)