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/speech.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Reference speech boundaries on a clean source, the labels detectors are measured against.
|
|
2
|
+
|
|
3
|
+
Measured in the telephone band (zero-phase high-pass at 200 Hz, which removes the sub-100 Hz
|
|
4
|
+
wobble TTS engines and bridges leave after speech). Speech is where the 4 ms RMS, one value per
|
|
5
|
+
ms, is within 35 dB of the source's active speech level for at least 10 ms; gaps shorter than
|
|
6
|
+
20 ms do not split it. Each edge is then moved to the first/last sample in its window whose
|
|
7
|
+
unfiltered magnitude reaches the threshold (the filter rings around abrupt edges). At 8 kHz a
|
|
8
|
+
word-edge /s/ sits 25-35 dB below the active level, so a higher threshold cuts fricatives off.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import wave
|
|
13
|
+
from array import array
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from rtpbench import ROOT
|
|
17
|
+
|
|
18
|
+
CORPUS = ROOT / "corpus"
|
|
19
|
+
WINDOW = 32 # samples, 4 ms
|
|
20
|
+
STEP = 8 # samples, 1 ms
|
|
21
|
+
BELOW_ACTIVE_DB = 35.0
|
|
22
|
+
MIN_SPEECH = 10 # steps
|
|
23
|
+
MIN_GAP = 20 # steps
|
|
24
|
+
FLOOR_MARGIN_DB = 6 # the quietest 200 ms must sit this far under the threshold
|
|
25
|
+
HIGHPASS_HZ = 200
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def highpass(pcm: list[int], rate: int = 8000) -> list[float]:
|
|
29
|
+
"""2nd-order Butterworth high-pass, run forwards then backwards (no phase shift)."""
|
|
30
|
+
w = 2 * math.pi * HIGHPASS_HZ / rate
|
|
31
|
+
alpha = math.sin(w) / math.sqrt(2)
|
|
32
|
+
a0 = 1 + alpha
|
|
33
|
+
b0, b1 = (1 + math.cos(w)) / 2 / a0, -(1 + math.cos(w)) / a0
|
|
34
|
+
a1, a2 = -2 * math.cos(w) / a0, (1 - alpha) / a0
|
|
35
|
+
|
|
36
|
+
def run(x: list[float]) -> list[float]:
|
|
37
|
+
y, x1, x2, y1, y2 = [], 0.0, 0.0, 0.0, 0.0
|
|
38
|
+
for s in x:
|
|
39
|
+
v = b0 * (s + x2) + b1 * x1 - a1 * y1 - a2 * y2
|
|
40
|
+
x2, x1, y2, y1 = x1, s, y1, v
|
|
41
|
+
y.append(v)
|
|
42
|
+
return y
|
|
43
|
+
|
|
44
|
+
return run(run([float(s) for s in pcm])[::-1])[::-1]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def active_power(x: list[float]) -> float:
|
|
48
|
+
"""Mean power of the 20 ms frames within 30 dB of the loudest one."""
|
|
49
|
+
frames = [sum(s * s for s in x[i : i + 160]) / 160 for i in range(0, len(x) - 159, 160)]
|
|
50
|
+
loud = [p for p in frames if p > max(frames, default=0) / 1000]
|
|
51
|
+
if not loud:
|
|
52
|
+
raise ValueError("no speech")
|
|
53
|
+
return sum(loud) / len(loud)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def label(pcm: list[int], below_db: float = BELOW_ACTIVE_DB) -> list[tuple[int, int]]:
|
|
57
|
+
"""[start, end) sample spans of speech. Raises if the source is too noisy to label."""
|
|
58
|
+
x = highpass(pcm)
|
|
59
|
+
thr = active_power(x) * 10 ** (-below_db / 10)
|
|
60
|
+
quiet = sorted(sum(s * s for s in x[i : i + 80]) / 80 for i in range(0, len(x) - 79, 80))
|
|
61
|
+
if quiet[min(19, len(quiet) - 1)] > thr * 10 ** (-FLOOR_MARGIN_DB / 10):
|
|
62
|
+
raise ValueError("source too noisy, or without silence, to label speech")
|
|
63
|
+
acc = [0.0]
|
|
64
|
+
for s in x:
|
|
65
|
+
acc.append(acc[-1] + s * s)
|
|
66
|
+
runs: list[list[int]] = []
|
|
67
|
+
for k, c in enumerate(range(0, len(x), STEP)):
|
|
68
|
+
lo, hi = max(0, c - WINDOW // 2), min(len(x), c + WINDOW // 2)
|
|
69
|
+
if acc[hi] - acc[lo] < thr * (hi - lo):
|
|
70
|
+
continue
|
|
71
|
+
if runs and k - runs[-1][1] <= MIN_GAP:
|
|
72
|
+
runs[-1][1] = k
|
|
73
|
+
else:
|
|
74
|
+
runs.append([k, k])
|
|
75
|
+
amp = math.sqrt(thr)
|
|
76
|
+
spans = []
|
|
77
|
+
for a, b in runs:
|
|
78
|
+
if b - a + 1 < MIN_SPEECH:
|
|
79
|
+
continue
|
|
80
|
+
lo, hi = max(0, a * STEP - WINDOW // 2), min(len(x), b * STEP + WINDOW // 2)
|
|
81
|
+
start = next((i for i in range(lo, hi) if abs(pcm[i]) >= amp), a * STEP)
|
|
82
|
+
end = next((i for i in range(hi - 1, start - 1, -1) if abs(pcm[i]) >= amp), b * STEP) + 1
|
|
83
|
+
spans.append((start, end))
|
|
84
|
+
return spans
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_wav(path: Path) -> list[int]:
|
|
88
|
+
try:
|
|
89
|
+
with wave.open(str(path)) as w:
|
|
90
|
+
if (w.getnchannels(), w.getsampwidth(), w.getframerate()) != (1, 2, 8000):
|
|
91
|
+
raise ValueError(f"{path}: need 8 kHz mono 16-bit PCM")
|
|
92
|
+
return list(array("h", w.readframes(w.getnframes())))
|
|
93
|
+
except wave.Error, EOFError: # not a WAV file, or a truncated one
|
|
94
|
+
raise ValueError(f"{path}: need 8 kHz mono 16-bit PCM") from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def clips(*prefixes: str) -> dict[str, list[int]]:
|
|
98
|
+
"""Corpus clips whose name starts with one of `prefixes`, in name order."""
|
|
99
|
+
paths = sorted(p for p in CORPUS.glob("*.wav") if p.stem.startswith(prefixes))
|
|
100
|
+
return {p.stem: read_wav(p) for p in paths}
|
rtpbench/speechcal.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Speech-detection error against labelled speech, from the caller's capture alone.
|
|
2
|
+
|
|
3
|
+
The reference agent replies with labelled clips and logs where each sits in its stream; the
|
|
4
|
+
caller logs where its own utterances sit. All times come from the caller pcap (wire method).
|
|
5
|
+
Truth is the first / last labelled sample that arrived. Per detector:
|
|
6
|
+
|
|
7
|
+
latency turn latency measured blind (the caller's labelled end to the first detected agent
|
|
8
|
+
speech after it) minus the truth (to the agent's first labelled sample)
|
|
9
|
+
offset last detected sample of the speech around a labelled clip minus its last labelled
|
|
10
|
+
sample; likewise onset and offset of the caller's own utterances (uplink)
|
|
11
|
+
rec turn latency as a two-channel recording at the caller reads it: the caller's end and
|
|
12
|
+
the agent's onset both detected, the agent's channel timed as the wire, a jitter
|
|
13
|
+
buffer (rtp) or a recorder appending payloads as they arrive (concat) places it
|
|
14
|
+
agent the truth measured on the agent's capture instead, as an agent-side log sees a turn
|
|
15
|
+
|
|
16
|
+
A detected span belongs to a clip if it overlaps the clip's labelled speech widened by 300 ms.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import statistics
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from rtpbench import analyze, detect, speech
|
|
25
|
+
|
|
26
|
+
WIDEN = 2400 # samples
|
|
27
|
+
ALIGN = 1280 # samples, a whole number of every detector's frames: they keep the stream's grid
|
|
28
|
+
Span = tuple[int, int]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _truth(st: analyze.Stream, spans: list[Span]) -> tuple[int, int] | None:
|
|
32
|
+
"""Times of the first and last labelled samples whose packets arrived."""
|
|
33
|
+
on = off = None
|
|
34
|
+
slots = [(a, b, k) for a, b in spans for k in range(a // 160, (b - 1) // 160 + 1)]
|
|
35
|
+
for a, _b, k in slots:
|
|
36
|
+
if 0 <= k < len(st.times) and st.times[k] is not None:
|
|
37
|
+
on = st.time(max(a, k * 160), True)
|
|
38
|
+
break
|
|
39
|
+
for _a, b, k in reversed(slots):
|
|
40
|
+
if 0 <= k < len(st.times) and st.times[k] is not None:
|
|
41
|
+
off = st.time(min(b, (k + 1) * 160) - 1, False)
|
|
42
|
+
break
|
|
43
|
+
return (on, off) if on is not None and off is not None else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _edges(
|
|
47
|
+
st: analyze.Stream, found: list[Span], spans: list[Span]
|
|
48
|
+
) -> tuple[int | None, int | None]:
|
|
49
|
+
"""Times of the first and last detected samples around labelled `spans`."""
|
|
50
|
+
lo, hi = spans[0][0] - WIDEN, spans[-1][1] + WIDEN
|
|
51
|
+
near = [s for s in found if s[1] > lo and s[0] < hi]
|
|
52
|
+
if not near:
|
|
53
|
+
return None, None
|
|
54
|
+
return st.time(near[0][0], True), st.time(near[-1][1] - 1, False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _first(
|
|
58
|
+
down: analyze.Stream, detector: str, agent_log: dict[str, Any], i: int, reply: list[Span]
|
|
59
|
+
) -> int | None:
|
|
60
|
+
"""Time of the first speech `detector` finds in reply `i` when it starts listening in the
|
|
61
|
+
agent's silence just before it: as on a stream's first speech, with nothing heard before."""
|
|
62
|
+
k0 = down.index(agent_log["seq0"], agent_log["turns"][i]["start"]) // ALIGN * ALIGN
|
|
63
|
+
starts = (
|
|
64
|
+
down.time(k0 + a, True) for a, _ in detect.DETECT[detector](down.pcm[k0 : reply[-1][1]])
|
|
65
|
+
)
|
|
66
|
+
return next((t for t in starts if t), None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _after(st: analyze.Stream, found: list[Span], t: int | None, until: int | None) -> int | None:
|
|
70
|
+
"""Time of the first detected onset after `t` and before `until`."""
|
|
71
|
+
starts = (st.time(a, True) for a, _ in found)
|
|
72
|
+
return next((x for x in starts if t and x and x > t and (until is None or x < until)), None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ms(a: int | None, b: int | None) -> float | None:
|
|
76
|
+
return None if a is None or b is None else (a - b) / 1e6
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def turns(run: Path, detectors: tuple[str, ...] = detect.DETECTORS) -> list[dict[str, Any]]:
|
|
80
|
+
"""Per caller turn and detector: blind latency, and caller onset/offset error; with a
|
|
81
|
+
reference agent's log also the truth and the agent offset error."""
|
|
82
|
+
caller_log = json.loads((run / "caller.json").read_text())
|
|
83
|
+
agent_path = run / "agent.json"
|
|
84
|
+
agent_log = json.loads(agent_path.read_text()) if agent_path.exists() else None
|
|
85
|
+
cap = analyze.load(run / "caller.pcap", caller_log["rtp_port"])
|
|
86
|
+
pt = caller_log["pt"]
|
|
87
|
+
up, down = analyze.stream(cap.up, pt), analyze.stream(cap.down, pt)
|
|
88
|
+
found = {d: (up.detect(detect.DETECT[d]), down.detect(detect.DETECT[d])) for d in detectors}
|
|
89
|
+
rtp, concat = analyze.stream(cap.down, pt, "rtp"), analyze.stream(cap.down, pt, "concat")
|
|
90
|
+
heard = { # the agent's channel per recording; rtp has the wire's audio on its own clock
|
|
91
|
+
d: {
|
|
92
|
+
"wire": (down, fd),
|
|
93
|
+
"rtp": (rtp, rtp.detect(detect.DETECT[d])), # its pauses where the clock has them
|
|
94
|
+
"concat": (concat, detect.DETECT[d](concat.pcm)),
|
|
95
|
+
}
|
|
96
|
+
for d, (_, fd) in found.items()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def place(st: analyze.Stream, seq0: int, spans: list[list[int]]) -> list[Span]:
|
|
100
|
+
return [(st.index(seq0, a), st.index(seq0, b)) for a, b in spans]
|
|
101
|
+
|
|
102
|
+
said = [place(up, caller_log["seq0"], t["speech"]) for t in caller_log["turns"]]
|
|
103
|
+
replies = (
|
|
104
|
+
[place(down, agent_log["seq0"], t["speech"]) for t in agent_log["turns"]]
|
|
105
|
+
if agent_log
|
|
106
|
+
else []
|
|
107
|
+
)
|
|
108
|
+
if agent_log and len(replies) != len(said):
|
|
109
|
+
raise ValueError(f"{run}: agent answered {len(replies)} turns, caller spoke {len(said)}")
|
|
110
|
+
said_t = [_truth(up, s) for s in said]
|
|
111
|
+
if agent_log and (run / "agent.pcap").exists():
|
|
112
|
+
mine = analyze.load(run / "agent.pcap")
|
|
113
|
+
a_up, a_down = analyze.stream(mine.up, pt), analyze.stream(mine.down, pt)
|
|
114
|
+
ends = [
|
|
115
|
+
_truth(a_up, place(a_up, caller_log["seq0"], t["speech"])) for t in caller_log["turns"]
|
|
116
|
+
]
|
|
117
|
+
onsets = [
|
|
118
|
+
_truth(a_down, place(a_down, agent_log["seq0"], t["speech"]))
|
|
119
|
+
for t in agent_log["turns"]
|
|
120
|
+
]
|
|
121
|
+
else:
|
|
122
|
+
ends = onsets = []
|
|
123
|
+
out = []
|
|
124
|
+
for i, c in enumerate(said):
|
|
125
|
+
ct = said_t[i]
|
|
126
|
+
at = _truth(down, replies[i]) if replies else None
|
|
127
|
+
if ct is None or (replies and at is None):
|
|
128
|
+
continue
|
|
129
|
+
later = said_t[i + 1] if i + 1 < len(said) else None
|
|
130
|
+
nxt = later[0] if later else None
|
|
131
|
+
rec: dict[str, Any] = {
|
|
132
|
+
"turn": i,
|
|
133
|
+
"caller_clip": caller_log["turns"][i].get("clip"),
|
|
134
|
+
"end_ns": ct[1], # capture time of the caller's labelled end, the latency's start
|
|
135
|
+
}
|
|
136
|
+
if agent_log and at:
|
|
137
|
+
rec |= {"agent_clip": agent_log["turns"][i]["clip"], "truth_ms": (at[0] - ct[1]) / 1e6}
|
|
138
|
+
if ends and (e := ends[i]) and (o := onsets[i]):
|
|
139
|
+
rec["agent_ms"] = (o[0] - e[1]) / 1e6
|
|
140
|
+
for d, (fu, fd) in found.items():
|
|
141
|
+
on = _after(down, fd, ct[1], nxt)
|
|
142
|
+
c_on, c_off = _edges(up, fu, c)
|
|
143
|
+
c_next = _edges(up, fu, said[i + 1])[0] if i + 1 < len(said) else None
|
|
144
|
+
for m, (st, spans) in heard[d].items():
|
|
145
|
+
rec[f"{d}_rec_{m}_ms"] = _ms(_after(st, spans, c_off, c_next), c_off)
|
|
146
|
+
rec |= {
|
|
147
|
+
f"{d}_ms": _ms(on, ct[1]),
|
|
148
|
+
f"{d}_caller_onset_ms": _ms(c_on, ct[0]),
|
|
149
|
+
f"{d}_caller_offset_ms": _ms(c_off, ct[1]),
|
|
150
|
+
}
|
|
151
|
+
if at and agent_log:
|
|
152
|
+
rec[f"{d}_offset_ms"] = _ms(_edges(down, fd, replies[i])[1], at[1])
|
|
153
|
+
rec[f"{d}_first_ms"] = _ms(_first(down, d, agent_log, i, replies[i]), ct[1])
|
|
154
|
+
out.append(rec)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _signed(v: list[float]) -> list[str]:
|
|
159
|
+
"""Mean, 5th, 50th and 95th percentile, max magnitude."""
|
|
160
|
+
if not v:
|
|
161
|
+
return ["-"] * 5
|
|
162
|
+
pct = [f"{analyze._pct(v, q):+.1f}" for q in (0.05, 0.5, 0.95)]
|
|
163
|
+
return [f"{sum(v) / len(v):+.1f}", *pct, f"{max(abs(x) for x in v):.1f}"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def sensitivity(clips: dict[str, list[int]]) -> str:
|
|
167
|
+
"""How far the labels move when the threshold moves 5 dB either way (clips too noisy to
|
|
168
|
+
label at the lower threshold are left out)."""
|
|
169
|
+
rows = []
|
|
170
|
+
base = {n: speech.label(pcm) for n, pcm in clips.items()}
|
|
171
|
+
for db in (speech.BELOW_ACTIVE_DB - 5, speech.BELOW_ACTIVE_DB + 5):
|
|
172
|
+
on, off = [], []
|
|
173
|
+
for n, pcm in clips.items():
|
|
174
|
+
try:
|
|
175
|
+
alt = speech.label(pcm, db)
|
|
176
|
+
except ValueError:
|
|
177
|
+
continue
|
|
178
|
+
on.append(abs(alt[0][0] - base[n][0][0]) / 8)
|
|
179
|
+
off.append(abs(alt[-1][1] - base[n][-1][1]) / 8)
|
|
180
|
+
stats = [f"{analyze._pct(v, q):.1f}" for v in (on, off) for q in (0.5, 0.9)]
|
|
181
|
+
rows.append([f"{db:.0f} dB", str(len(on)), *stats, f"{max(on):.1f}", f"{max(off):.1f}"])
|
|
182
|
+
head = ["threshold", "clips", "onset p50", "onset p90", "offset p50", "offset p90"]
|
|
183
|
+
return analyze._table([*head, "onset max", "offset max"], rows)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def summarize(runs: dict[str, list[Path]], clips: dict[str, list[int]] | None = None) -> str:
|
|
187
|
+
"""With a reference agent: detection error per detector. Against a real agent: turn
|
|
188
|
+
latency per detector, whose error the calibration gives. The runs under one name (one per
|
|
189
|
+
seed) pool into one row."""
|
|
190
|
+
stat = ["mean", "p5", "p50", "p95", "max abs"]
|
|
191
|
+
tables: dict[str, list[list[str]]] = {
|
|
192
|
+
k: [] for k in ("latency", "first", "offset", "caller", "raw")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def values(recs: list[dict[str, Any]], key: str) -> list[float]:
|
|
196
|
+
return [r[key] for r in recs if r.get(key) is not None]
|
|
197
|
+
|
|
198
|
+
for name, dirs in runs.items():
|
|
199
|
+
recs = []
|
|
200
|
+
for run in dirs:
|
|
201
|
+
part = turns(run)
|
|
202
|
+
(run / "turns.jsonl").write_text("".join(json.dumps(r) + "\n" for r in part))
|
|
203
|
+
recs += part
|
|
204
|
+
for d in detect.DETECTORS:
|
|
205
|
+
if any("truth_ms" in r for r in recs):
|
|
206
|
+
err = [r[f"{d}_ms"] - r["truth_ms"] for r in recs if r[f"{d}_ms"] is not None]
|
|
207
|
+
first = [
|
|
208
|
+
r[f"{d}_first_ms"] - r["truth_ms"]
|
|
209
|
+
for r in recs
|
|
210
|
+
if r.get(f"{d}_first_ms") is not None
|
|
211
|
+
]
|
|
212
|
+
off = values(recs, f"{d}_offset_ms")
|
|
213
|
+
tables["latency"].append([name, d, f"{len(err)}/{len(recs)}", *_signed(err)])
|
|
214
|
+
tables["first"].append([name, d, f"{len(first)}/{len(recs)}", *_signed(first)])
|
|
215
|
+
tables["offset"].append([name, d, f"{len(off)}/{len(recs)}", *_signed(off)])
|
|
216
|
+
raw = values(recs, f"{d}_ms")
|
|
217
|
+
cells = [f"{analyze._pct(raw, q):.1f}" for q in (0.05, 0.5, 0.95)] if raw else ["-"] * 3
|
|
218
|
+
tables["raw"].append([name, d, f"{len(raw)}/{len(recs)}", *cells])
|
|
219
|
+
c_on, c_off = (
|
|
220
|
+
values(recs, f"{d}_caller_onset_ms"),
|
|
221
|
+
values(recs, f"{d}_caller_offset_ms"),
|
|
222
|
+
)
|
|
223
|
+
tables["caller"].append(
|
|
224
|
+
[name, d, f"{len(c_on)}/{len(recs)}", *_signed(c_on)[:4], *_signed(c_off)[:4]]
|
|
225
|
+
)
|
|
226
|
+
out = (
|
|
227
|
+
"Turn latency, ms: caller's labelled end to the first agent speech detected after it\n\n"
|
|
228
|
+
+ analyze._table(["run", "detector", "n", "p5", "p50", "p95"], tables["raw"])
|
|
229
|
+
)
|
|
230
|
+
if tables["latency"]:
|
|
231
|
+
out += (
|
|
232
|
+
"\nAgent reply onset: blind turn latency - truth, ms\n\n"
|
|
233
|
+
+ analyze._table(["run", "detector", "n", *stat], tables["latency"])
|
|
234
|
+
+ "\nAgent reply onset as a stream's first speech (detector started on the reply):"
|
|
235
|
+
+ " blind turn latency - truth, ms\n\n"
|
|
236
|
+
+ analyze._table(["run", "detector", "n", *stat], tables["first"])
|
|
237
|
+
+ "\nAgent reply offset: detected - labelled, ms\n\n"
|
|
238
|
+
+ analyze._table(["run", "detector", "n", *stat], tables["offset"])
|
|
239
|
+
)
|
|
240
|
+
out += "\nCaller utterance onset | offset: detected - labelled, ms\n\n" + analyze._table(
|
|
241
|
+
["run", "detector", "n", *stat[:4], *stat[:4]], tables["caller"]
|
|
242
|
+
)
|
|
243
|
+
if clips:
|
|
244
|
+
out += "\nLabel definition: |label at threshold - label at 35 dB|, ms\n\n" + sensitivity(
|
|
245
|
+
clips
|
|
246
|
+
)
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def pooled(
|
|
251
|
+
runs: dict[str, list[Path]],
|
|
252
|
+
steps: list[dict[str, Any]] | None = None,
|
|
253
|
+
detector: str = "webrtcvad",
|
|
254
|
+
) -> str:
|
|
255
|
+
"""Turn latency per profile against a real agent, over every turn of every run: the
|
|
256
|
+
caller's labelled end to the agent's first onset by `detector`, the spread of the per-run
|
|
257
|
+
medians, the detector's error bar for the profile and codec, and how many turns ran across
|
|
258
|
+
one of the wall-clock `steps`."""
|
|
259
|
+
from rtpbench import cases, clock
|
|
260
|
+
|
|
261
|
+
rows = []
|
|
262
|
+
for profile, dirs in runs.items():
|
|
263
|
+
per_run, said, bars, stepped = [], 0, set(), 0
|
|
264
|
+
for run in dirs:
|
|
265
|
+
recs = turns(run, (detector,))
|
|
266
|
+
(run / "turns.jsonl").write_text("".join(json.dumps(r) + "\n" for r in recs))
|
|
267
|
+
said += len(recs)
|
|
268
|
+
got = [r for r in recs if r[f"{detector}_ms"] is not None]
|
|
269
|
+
per_run.append([r[f"{detector}_ms"] for r in got])
|
|
270
|
+
pt = json.loads((run / "caller.json").read_text())["pt"]
|
|
271
|
+
b = cases.bar(cases.ONSET, profile, pt)
|
|
272
|
+
bars.add(b)
|
|
273
|
+
width = b.hi - b.lo if b else 0
|
|
274
|
+
stepped += sum(
|
|
275
|
+
clock.spans(
|
|
276
|
+
r["end_ns"], r["end_ns"] + round(r[f"{detector}_ms"] * 1e6), steps or [], width
|
|
277
|
+
)
|
|
278
|
+
for r in got
|
|
279
|
+
)
|
|
280
|
+
v = [x for xs in per_run for x in xs]
|
|
281
|
+
meds = [statistics.median(xs) for xs in per_run if xs]
|
|
282
|
+
cells = [f"{analyze._pct(v, q):.0f}" for q in (0.05, 0.5, 0.95)] if v else ["-"] * 3
|
|
283
|
+
spread = f"{min(meds):.0f} .. {max(meds):.0f}" if meds else "-"
|
|
284
|
+
error = "; ".join(f"[{b.lo:+.1f}, {b.hi:+.1f}] {b.source}" for b in bars if b)
|
|
285
|
+
rows.append(
|
|
286
|
+
[profile, str(len(dirs)), f"{len(v)}/{said}", *cells, spread, str(stepped), error]
|
|
287
|
+
)
|
|
288
|
+
head = ["profile", "runs", "answered", "p5", "p50", "p95", "run medians"]
|
|
289
|
+
head += ["across a clock step", "onset error bar"]
|
|
290
|
+
return (
|
|
291
|
+
f"Turn latency, ms: caller's labelled end to the agent's first speech ({detector}), every "
|
|
292
|
+
"turn of every run. True value = measured - error, error within the onset bar; where the "
|
|
293
|
+
"caller's speech ends is defined to ±17 ms (label threshold ±5 dB).\n\n"
|
|
294
|
+
+ analyze._table(head, rows)
|
|
295
|
+
+ "\nEach turn: a line of PROFILE/REPEAT/latency/turns.jsonl (results.json holds "
|
|
296
|
+
"the cases only; rtpbench check gathers the turns into check.json).\n"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
METHODS = ( # how a turn's latency is measured, and the per-turn key it lands in
|
|
301
|
+
("rtpbench: wire, labelled end, webrtcvad onset", "webrtcvad_ms"),
|
|
302
|
+
("the agent's capture, labelled: an agent-side log", "agent_ms"),
|
|
303
|
+
*((f"recording, {d} at both edges", f"{d}_rec_rtp_ms") for d in detect.DETECTORS[1:]),
|
|
304
|
+
("recording appending payloads as they arrive, webrtcvad", "webrtcvad_rec_concat_ms"),
|
|
305
|
+
)
|
|
306
|
+
GAP_MS = 500 # Coval's Latency counts only silences at least this long
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def methods(runs: dict[str, list[Path]]) -> str:
|
|
310
|
+
"""Turn latency by common measurement methods against the truth, on calls with a reference
|
|
311
|
+
agent: each method's error per turn, where a recording's error comes from, and the error of
|
|
312
|
+
the mean of gaps over GAP_MS per call. The runs under one name (one per seed) pool."""
|
|
313
|
+
calls: dict[str, list[list[dict[str, Any]]]] = {}
|
|
314
|
+
for name, dirs in runs.items():
|
|
315
|
+
for run in dirs:
|
|
316
|
+
recs = turns(run)
|
|
317
|
+
(run / "turns.jsonl").write_text("".join(json.dumps(r) + "\n" for r in recs))
|
|
318
|
+
calls.setdefault(name, []).append([r for r in recs if "truth_ms" in r])
|
|
319
|
+
pooled = {n: [r for c in cs for r in c] for n, cs in calls.items()}
|
|
320
|
+
|
|
321
|
+
def cell(key: str, minus: str | None = "truth_ms") -> list[str]:
|
|
322
|
+
out = []
|
|
323
|
+
for recs in pooled.values():
|
|
324
|
+
v = [r[key] - (r[minus] if minus else 0) for r in recs if r.get(key) is not None]
|
|
325
|
+
p = [f"{analyze._pct(v, q):+.0f}" for q in (0.05, 0.5, 0.95)] if v else ["-"] * 3
|
|
326
|
+
miss = f" ({len(v)}/{len(recs)})" if len(v) < len(recs) else ""
|
|
327
|
+
out.append(f"{p[1]} [{p[0]}, {p[2]}]{miss}")
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
def gaps(d: str) -> list[str]:
|
|
331
|
+
out = []
|
|
332
|
+
for cs in calls.values():
|
|
333
|
+
errs, kept, total = [], 0, 0
|
|
334
|
+
for c in cs:
|
|
335
|
+
v = [r[f"{d}_rec_rtp_ms"] for r in c if (r[f"{d}_rec_rtp_ms"] or 0) >= GAP_MS]
|
|
336
|
+
kept, total = kept + len(v), total + len(c)
|
|
337
|
+
if v:
|
|
338
|
+
errs.append(statistics.mean(v) - statistics.mean(r["truth_ms"] for r in c))
|
|
339
|
+
p = (statistics.median(errs), min(errs), max(errs)) if errs else None
|
|
340
|
+
out.append(f"{p[0]:+.0f} [{p[1]:+.0f}, {p[2]:+.0f}] ({kept}/{total})" if p else "-")
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
names = list(pooled)
|
|
344
|
+
detectors = detect.DETECTORS[1:]
|
|
345
|
+
latency = [["truth itself", *cell("truth_ms", None)], *([m, *cell(k)] for m, k in METHODS)]
|
|
346
|
+
edges = [
|
|
347
|
+
row
|
|
348
|
+
for d in detectors
|
|
349
|
+
for row in (
|
|
350
|
+
[f"{d}: caller's end", *cell(f"{d}_caller_offset_ms", None)],
|
|
351
|
+
[f"{d}: agent's onset", *cell(f"{d}_ms")],
|
|
352
|
+
)
|
|
353
|
+
]
|
|
354
|
+
return (
|
|
355
|
+
"Turn latency, measured - truth, ms: p50 [p5, p95] over every turn (turns measured / "
|
|
356
|
+
"turns where some were missed). Truth: from the caller's last labelled sample to the "
|
|
357
|
+
"agent's first, as they crossed the caller's wire. A recording is two channels at the "
|
|
358
|
+
"caller, the agent's placed by a jitter buffer, unless said; both edges detected.\n\n"
|
|
359
|
+
+ analyze._table(["method", *names], latency)
|
|
360
|
+
+ "\nWhere a recording's error comes from: detected - labelled, ms, p50 [p5, p95]. "
|
|
361
|
+
"A recording's error is about the agent's onset error - the caller's end error.\n\n"
|
|
362
|
+
+ analyze._table(["edge", *names], edges)
|
|
363
|
+
+ f"\nPer call, the mean of the recording's gaps of at least {GAP_MS} ms (Coval's "
|
|
364
|
+
"Latency) - the mean of the call's true latencies, ms: median [min, max] over calls "
|
|
365
|
+
"(gaps counted / turns)\n\n"
|
|
366
|
+
+ analyze._table(["detector", *names], [[d, *gaps(d)] for d in detectors])
|
|
367
|
+
)
|
rtpbench/srtp.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""SRTP (RFC 3711) keyed in the SDP (SDES, RFC 4568): protecting and opening RTP packets for the
|
|
2
|
+
caller and the reference agent, and for judging, which opens the captured packets with the keys
|
|
3
|
+
in the captured SDP. AES counter mode with HMAC-SHA1, and AES-GCM (RFC 7714); no MKI, no
|
|
4
|
+
session parameters, key derivation rate 0."""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hmac
|
|
8
|
+
import secrets
|
|
9
|
+
import struct
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from cryptography.exceptions import InvalidTag
|
|
13
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
14
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
15
|
+
|
|
16
|
+
from rtpbench import sip
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Suite:
|
|
21
|
+
salt: int # master and session salt, bytes
|
|
22
|
+
tag: int # authentication tag, bytes
|
|
23
|
+
gcm: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
KEY = 16 # master and session key, bytes
|
|
27
|
+
SUITES = { # in the order the caller offers them
|
|
28
|
+
"AES_CM_128_HMAC_SHA1_80": Suite(14, 10, False),
|
|
29
|
+
"AES_CM_128_HMAC_SHA1_32": Suite(14, 4, False),
|
|
30
|
+
"AEAD_AES_128_GCM": Suite(12, 16, True),
|
|
31
|
+
}
|
|
32
|
+
_ENCRYPTION, _AUTHENTICATION, _SALT = 0, 1, 2 # key derivation labels (RFC 3711 4.3.2)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _prf(key: bytes, salt: bytes, label: int, length: int) -> bytes:
|
|
36
|
+
"""AES-CM key derivation (RFC 3711 4.3.1) at index 0; a 12-byte salt (AES-GCM, RFC 7714 11)
|
|
37
|
+
padded with zeros, as libsrtp and pion do."""
|
|
38
|
+
x = bytearray(salt.ljust(14, b"\0") + b"\0\0")
|
|
39
|
+
x[7] ^= label
|
|
40
|
+
enc = Cipher(algorithms.AES(key), modes.CTR(bytes(x))).encryptor()
|
|
41
|
+
return enc.update(b"\0" * length) + enc.finalize()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _header(data: bytes) -> int:
|
|
45
|
+
"""Length of the RTP header, CSRCs and extension included."""
|
|
46
|
+
n = 12 + 4 * (data[0] & 0x0F)
|
|
47
|
+
if data[0] & 0x10 and len(data) >= n + 4:
|
|
48
|
+
n += 4 + 4 * struct.unpack_from("!H", data, n + 2)[0]
|
|
49
|
+
return n
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Context:
|
|
53
|
+
"""One direction's crypto context (RFC 3711 3.2) from its master key and salt, for every SSRC
|
|
54
|
+
it carries: `protect` for the sender, which knows each packet's index; `open` for whoever
|
|
55
|
+
receives (a peer, or judging), which estimates the index with a rollover counter per SSRC
|
|
56
|
+
(3.3.1). `state` carries those counters over from the context a new key replaces: a rekey
|
|
57
|
+
does not reset them (RFC 3711 3.3.1)."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self, suite: str, master: bytes, state: dict[int, tuple[int, int]] | None = None
|
|
61
|
+
) -> None:
|
|
62
|
+
spec = SUITES[suite]
|
|
63
|
+
key, salt = master[:KEY], master[KEY:]
|
|
64
|
+
self.suite, self.master, self.spec = suite, master, spec
|
|
65
|
+
self.key = _prf(key, salt, _ENCRYPTION, KEY)
|
|
66
|
+
self.salt = _prf(key, salt, _SALT, spec.salt)
|
|
67
|
+
self.auth = b"" if spec.gcm else _prf(key, salt, _AUTHENTICATION, 20)
|
|
68
|
+
# SSRC: rollover counter, highest seq
|
|
69
|
+
self.state: dict[int, tuple[int, int]] = {} if state is None else state
|
|
70
|
+
|
|
71
|
+
def _iv(self, ssrc: int, index: int) -> bytes:
|
|
72
|
+
if self.spec.gcm: # RFC 7714 8.1
|
|
73
|
+
block = struct.pack("!xxIIH", ssrc, index >> 16, index & 0xFFFF)
|
|
74
|
+
return bytes(a ^ b for a, b in zip(block, self.salt, strict=True))
|
|
75
|
+
iv = int.from_bytes(self.salt) << 16 ^ ssrc << 64 ^ index << 16 # RFC 3711 4.1.1
|
|
76
|
+
return iv.to_bytes(16)
|
|
77
|
+
|
|
78
|
+
def _ctr(self, ssrc: int, index: int, data: bytes) -> bytes:
|
|
79
|
+
return (
|
|
80
|
+
Cipher(algorithms.AES(self.key), modes.CTR(self._iv(ssrc, index)))
|
|
81
|
+
.encryptor()
|
|
82
|
+
.update(data)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _tag(self, packet: bytes, roc: int) -> bytes:
|
|
86
|
+
return hmac.digest(self.auth, packet + struct.pack("!I", roc), "sha1")[: self.spec.tag]
|
|
87
|
+
|
|
88
|
+
def protect(self, packet: bytes, index: int) -> bytes:
|
|
89
|
+
"""`packet` (RTP) as SRTP; `index`: its 48-bit packet index, rollover counter and seq."""
|
|
90
|
+
n, ssrc = _header(packet), struct.unpack_from("!I", packet, 8)[0]
|
|
91
|
+
head, body = packet[:n], packet[n:]
|
|
92
|
+
if self.spec.gcm:
|
|
93
|
+
return head + AESGCM(self.key).encrypt(self._iv(ssrc, index), body, head)
|
|
94
|
+
out = head + self._ctr(ssrc, index, body)
|
|
95
|
+
return out + self._tag(out, index >> 16)
|
|
96
|
+
|
|
97
|
+
def open(self, data: bytes) -> bytes | None:
|
|
98
|
+
"""The RTP packet in `data` (SRTP), or None if it does not authenticate."""
|
|
99
|
+
if len(data) < 12 + self.spec.tag or len(data) < _header(data) + self.spec.tag:
|
|
100
|
+
return None
|
|
101
|
+
n = _header(data)
|
|
102
|
+
ssrc, seq = struct.unpack_from("!I", data, 8)[0], struct.unpack_from("!H", data, 2)[0]
|
|
103
|
+
roc, high = self.state.get(ssrc, (0, seq))
|
|
104
|
+
if high < 32768: # the guess of RFC 3711 3.3.1: the closest index to the highest yet
|
|
105
|
+
v = roc - 1 if seq - high > 32768 else roc
|
|
106
|
+
else:
|
|
107
|
+
v = roc + 1 if high - 32768 > seq else roc
|
|
108
|
+
if v < 0:
|
|
109
|
+
return None
|
|
110
|
+
index = v << 16 | seq
|
|
111
|
+
if self.spec.gcm:
|
|
112
|
+
try:
|
|
113
|
+
body = AESGCM(self.key).decrypt(self._iv(ssrc, index), data[n:], data[:n])
|
|
114
|
+
except InvalidTag:
|
|
115
|
+
return None
|
|
116
|
+
else:
|
|
117
|
+
packet, tag = data[: -self.spec.tag], data[-self.spec.tag :]
|
|
118
|
+
if not hmac.compare_digest(self._tag(packet, v), tag):
|
|
119
|
+
return None
|
|
120
|
+
body = self._ctr(ssrc, index, packet[n:])
|
|
121
|
+
if v > roc:
|
|
122
|
+
roc, high = v, seq
|
|
123
|
+
elif v == roc and seq > high:
|
|
124
|
+
high = seq
|
|
125
|
+
self.state[ssrc] = (roc, high)
|
|
126
|
+
return data[:n] + body
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def master() -> dict[str, bytes]:
|
|
130
|
+
"""A fresh master key and salt for each suite."""
|
|
131
|
+
return {name: secrets.token_bytes(KEY + s.salt) for name, s in SUITES.items()}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def line(tag: int, suite: str, key: bytes) -> str:
|
|
135
|
+
"""An `a=crypto` value (RFC 4568 9.1)."""
|
|
136
|
+
return f"{tag} {suite} inline:{base64.b64encode(key).decode()}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse(value: str) -> tuple[int, str, bytes]:
|
|
140
|
+
"""Tag, suite and master key and salt of an `a=crypto` value; ValueError if this cannot
|
|
141
|
+
use it (an unknown suite, MKI, session parameters, a key of the wrong length)."""
|
|
142
|
+
fields = value.split()
|
|
143
|
+
if len(fields) != 3 or not fields[0].isdigit() or fields[1] not in SUITES:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"a=crypto:{value}: not supported (one known suite, no session parameters)"
|
|
146
|
+
)
|
|
147
|
+
params = fields[2].removeprefix("inline:").split("|")
|
|
148
|
+
if not fields[2].startswith("inline:") or len(params) > 2 or ":" in params[-1]:
|
|
149
|
+
raise ValueError(f"a=crypto:{value}: not supported (one inline key, no MKI)")
|
|
150
|
+
key = base64.b64decode(params[0] + "=" * (-len(params[0]) % 4))
|
|
151
|
+
if len(key) != KEY + SUITES[fields[1]].salt:
|
|
152
|
+
raise ValueError(f"a=crypto:{value}: the key and salt are {len(key)} bytes")
|
|
153
|
+
return int(fields[0]), fields[1], key
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Keyring:
|
|
157
|
+
"""Opens the packets of one direction of a captured call with any of the keys its side gave
|
|
158
|
+
in the SDP: the one that authenticates a packet's SSRC stays with it until one fails. The
|
|
159
|
+
keys share one rollover counter per SSRC, as the side's successive keys do."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, keys: list[tuple[str, bytes]]) -> None:
|
|
162
|
+
state: dict[int, tuple[int, int]] = {}
|
|
163
|
+
self.contexts = [Context(s, k, state) for s, k in dict.fromkeys(keys)]
|
|
164
|
+
self.bound: dict[int, Context] = {}
|
|
165
|
+
self.failed = 0 # packets that no key opened
|
|
166
|
+
|
|
167
|
+
def open(self, data: bytes) -> bytes | None:
|
|
168
|
+
ssrc = struct.unpack_from("!I", data, 8)[0] if len(data) >= 12 else 0
|
|
169
|
+
first = [self.bound[ssrc]] if ssrc in self.bound else []
|
|
170
|
+
for ctx in [*first, *(c for c in self.contexts if c not in first)]:
|
|
171
|
+
if (plain := ctx.open(data)) is not None:
|
|
172
|
+
self.bound[ssrc] = ctx
|
|
173
|
+
return plain
|
|
174
|
+
self.failed += 1
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def keyrings(msgs: list[tuple[bool, bytes]]) -> tuple[Keyring, Keyring] | None:
|
|
179
|
+
"""Keyrings for the caller's packets and the agent's, from the bodies of the SIP messages
|
|
180
|
+
of a call (whether the caller sent it, body); None if none offered SRTP."""
|
|
181
|
+
keys: dict[bool, list[tuple[str, bytes]]] = {True: [], False: []}
|
|
182
|
+
for from_caller, body in msgs:
|
|
183
|
+
for value in sip.parse_sdp(body).crypto if body else []:
|
|
184
|
+
try:
|
|
185
|
+
_, suite, key = parse(value)
|
|
186
|
+
except ValueError:
|
|
187
|
+
continue
|
|
188
|
+
keys[from_caller].append((suite, key))
|
|
189
|
+
if not keys[True] and not keys[False]:
|
|
190
|
+
return None
|
|
191
|
+
return Keyring(keys[True]), Keyring(keys[False])
|