rtpbench 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtpbench/.dockerignore +4 -0
- rtpbench/Dockerfile +12 -0
- rtpbench/__init__.py +6 -0
- rtpbench/__main__.py +3 -0
- rtpbench/agent.py +746 -0
- rtpbench/analyze.py +520 -0
- rtpbench/bargein.py +272 -0
- rtpbench/bench.py +1069 -0
- rtpbench/caller.py +781 -0
- rtpbench/capture.py +33 -0
- rtpbench/carrier/compose.yaml +68 -0
- rtpbench/carrier/opensips.cfg +226 -0
- rtpbench/carrier/tls.cfg +25 -0
- rtpbench/carrier/tls.yaml +9 -0
- rtpbench/cases.py +1989 -0
- rtpbench/check.py +369 -0
- rtpbench/cli.py +616 -0
- rtpbench/clock.py +174 -0
- rtpbench/compose.yaml +55 -0
- rtpbench/corpus/README.md +55 -0
- rtpbench/corpus/cough-c1-0.wav +0 -0
- rtpbench/corpus/cough-c1-1.wav +0 -0
- rtpbench/corpus/cough-c2-0.wav +0 -0
- rtpbench/corpus/cough-c2-1.wav +0 -0
- rtpbench/corpus/cough-man-0.wav +0 -0
- rtpbench/corpus/cough-man-1.wav +0 -0
- rtpbench/corpus/cough-short-0.wav +0 -0
- rtpbench/corpus/cough-short-1.wav +0 -0
- rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
- rtpbench/corpus/human-en-dr-0.wav +0 -0
- rtpbench/corpus/human-en-dr-1.wav +0 -0
- rtpbench/corpus/human-en-sc-0.wav +0 -0
- rtpbench/corpus/human-en-sc-1.wav +0 -0
- rtpbench/corpus/human-en-sc-2.wav +0 -0
- rtpbench/corpus/human-es-f012-0.wav +0 -0
- rtpbench/corpus/human-es-f012-1.wav +0 -0
- rtpbench/corpus/human-es-f012-2.wav +0 -0
- rtpbench/corpus/human-es-f016-0.wav +0 -0
- rtpbench/corpus/human-es-f016-1.wav +0 -0
- rtpbench/corpus/human-es-f016-2.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-00.wav +0 -0
- rtpbench/corpus/lines/en/prompt-01.wav +0 -0
- rtpbench/corpus/lines/en/prompt-02.wav +0 -0
- rtpbench/corpus/lines/en/prompt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-04.wav +0 -0
- rtpbench/corpus/lines/en/prompt-05.wav +0 -0
- rtpbench/corpus/lines/en/prompt-06.wav +0 -0
- rtpbench/corpus/lines/en/prompt-07.wav +0 -0
- rtpbench/corpus/lines/en/prompt-08.wav +0 -0
- rtpbench/corpus/lines/en/prompt-09.wav +0 -0
- rtpbench/corpus/lines/en/prompt.wav +0 -0
- rtpbench/corpus/lines/en/transfer.wav +0 -0
- rtpbench/corpus/lines/en/turn-00.wav +0 -0
- rtpbench/corpus/lines/en/turn-01.wav +0 -0
- rtpbench/corpus/lines/en/turn-02.wav +0 -0
- rtpbench/corpus/lines/en/turn-03.wav +0 -0
- rtpbench/corpus/lines/en/turn-04.wav +0 -0
- rtpbench/corpus/lines/en/turn-05.wav +0 -0
- rtpbench/corpus/lines/en/turn-06.wav +0 -0
- rtpbench/corpus/lines/en/turn-07.wav +0 -0
- rtpbench/corpus/lines/en/turn-08.wav +0 -0
- rtpbench/corpus/lines/en/turn-09.wav +0 -0
- rtpbench/corpus/lines/en/turn-10.wav +0 -0
- rtpbench/corpus/lines/en/turn-11.wav +0 -0
- rtpbench/corpus/tts-ald-bc.wav +0 -0
- rtpbench/corpus/tts-ald-long.wav +0 -0
- rtpbench/corpus/tts-ald-s03.wav +0 -0
- rtpbench/corpus/tts-ald-s07.wav +0 -0
- rtpbench/corpus/tts-ald-s11.wav +0 -0
- rtpbench/corpus/tts-cori-bc.wav +0 -0
- rtpbench/corpus/tts-cori-long.wav +0 -0
- rtpbench/corpus/tts-cori-s01.wav +0 -0
- rtpbench/corpus/tts-cori-s05.wav +0 -0
- rtpbench/corpus/tts-cori-s09.wav +0 -0
- rtpbench/corpus/tts-davefx-bc.wav +0 -0
- rtpbench/corpus/tts-davefx-long.wav +0 -0
- rtpbench/corpus/tts-davefx-s00.wav +0 -0
- rtpbench/corpus/tts-davefx-s04.wav +0 -0
- rtpbench/corpus/tts-davefx-s08.wav +0 -0
- rtpbench/corpus/tts-libritts0-bc.wav +0 -0
- rtpbench/corpus/tts-libritts0-long.wav +0 -0
- rtpbench/corpus/tts-libritts0-s02.wav +0 -0
- rtpbench/corpus/tts-libritts0-s06.wav +0 -0
- rtpbench/corpus/tts-libritts0-s10.wav +0 -0
- rtpbench/corpus/tts-libritts3-bc.wav +0 -0
- rtpbench/corpus/tts-libritts3-long.wav +0 -0
- rtpbench/corpus/tts-libritts3-s03.wav +0 -0
- rtpbench/corpus/tts-libritts3-s07.wav +0 -0
- rtpbench/corpus/tts-libritts3-s11.wav +0 -0
- rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
- rtpbench/corpus/tts-ljspeech-long.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
- rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardf-long.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
- rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardm-long.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
- rtpbench/detect.py +145 -0
- rtpbench/g711.py +82 -0
- rtpbench/mock/compose.yaml +18 -0
- rtpbench/pcap.py +106 -0
- rtpbench/progress.py +184 -0
- rtpbench/realtime.py +1015 -0
- rtpbench/report.py +450 -0
- rtpbench/rtp.py +166 -0
- rtpbench/sip.py +653 -0
- rtpbench/speech.py +100 -0
- rtpbench/speechcal.py +367 -0
- rtpbench/srtp.py +191 -0
- rtpbench/targets/aivc/aivc.ini +25 -0
- rtpbench/targets/aivc/compose.yaml +72 -0
- rtpbench/targets/aivc/constraints.txt +52 -0
- rtpbench/targets/aivc/opensips.cfg +45 -0
- rtpbench/targets/ava/ai-agent.yaml +275 -0
- rtpbench/targets/ava/ari.conf +7 -0
- rtpbench/targets/ava/compose.yaml +128 -0
- rtpbench/targets/ava/constraints.txt +75 -0
- rtpbench/targets/ava/extensions.conf +10 -0
- rtpbench/targets/ava/http.conf +4 -0
- rtpbench/targets/ava/pjsip.conf +19 -0
- rtpbench/targets/ava/websocket_client.conf +15 -0
- rtpbench/targets/livekit/agent.py +111 -0
- rtpbench/targets/livekit/compose.yaml +86 -0
- rtpbench/targets/livekit/constraints.txt +72 -0
- rtpbench/tls.py +284 -0
- rtpbench/uri/advertise.yaml +7 -0
- rtpbench/uri/network.yaml +14 -0
- rtpbench/voice.py +18 -0
- rtpbench-0.3.0.dist-info/METADATA +123 -0
- rtpbench-0.3.0.dist-info/RECORD +144 -0
- rtpbench-0.3.0.dist-info/WHEEL +4 -0
- rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
- rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
rtpbench/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)
|