rtpbench 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. rtpbench/.dockerignore +4 -0
  2. rtpbench/Dockerfile +12 -0
  3. rtpbench/__init__.py +6 -0
  4. rtpbench/__main__.py +3 -0
  5. rtpbench/agent.py +746 -0
  6. rtpbench/analyze.py +520 -0
  7. rtpbench/bargein.py +272 -0
  8. rtpbench/bench.py +1069 -0
  9. rtpbench/caller.py +781 -0
  10. rtpbench/capture.py +33 -0
  11. rtpbench/carrier/compose.yaml +68 -0
  12. rtpbench/carrier/opensips.cfg +226 -0
  13. rtpbench/carrier/tls.cfg +25 -0
  14. rtpbench/carrier/tls.yaml +9 -0
  15. rtpbench/cases.py +1989 -0
  16. rtpbench/check.py +369 -0
  17. rtpbench/cli.py +616 -0
  18. rtpbench/clock.py +174 -0
  19. rtpbench/compose.yaml +55 -0
  20. rtpbench/corpus/README.md +55 -0
  21. rtpbench/corpus/cough-c1-0.wav +0 -0
  22. rtpbench/corpus/cough-c1-1.wav +0 -0
  23. rtpbench/corpus/cough-c2-0.wav +0 -0
  24. rtpbench/corpus/cough-c2-1.wav +0 -0
  25. rtpbench/corpus/cough-man-0.wav +0 -0
  26. rtpbench/corpus/cough-man-1.wav +0 -0
  27. rtpbench/corpus/cough-short-0.wav +0 -0
  28. rtpbench/corpus/cough-short-1.wav +0 -0
  29. rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
  30. rtpbench/corpus/human-en-dr-0.wav +0 -0
  31. rtpbench/corpus/human-en-dr-1.wav +0 -0
  32. rtpbench/corpus/human-en-sc-0.wav +0 -0
  33. rtpbench/corpus/human-en-sc-1.wav +0 -0
  34. rtpbench/corpus/human-en-sc-2.wav +0 -0
  35. rtpbench/corpus/human-es-f012-0.wav +0 -0
  36. rtpbench/corpus/human-es-f012-1.wav +0 -0
  37. rtpbench/corpus/human-es-f012-2.wav +0 -0
  38. rtpbench/corpus/human-es-f016-0.wav +0 -0
  39. rtpbench/corpus/human-es-f016-1.wav +0 -0
  40. rtpbench/corpus/human-es-f016-2.wav +0 -0
  41. rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
  42. rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
  43. rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
  44. rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
  45. rtpbench/corpus/lines/en/prompt-00.wav +0 -0
  46. rtpbench/corpus/lines/en/prompt-01.wav +0 -0
  47. rtpbench/corpus/lines/en/prompt-02.wav +0 -0
  48. rtpbench/corpus/lines/en/prompt-03.wav +0 -0
  49. rtpbench/corpus/lines/en/prompt-04.wav +0 -0
  50. rtpbench/corpus/lines/en/prompt-05.wav +0 -0
  51. rtpbench/corpus/lines/en/prompt-06.wav +0 -0
  52. rtpbench/corpus/lines/en/prompt-07.wav +0 -0
  53. rtpbench/corpus/lines/en/prompt-08.wav +0 -0
  54. rtpbench/corpus/lines/en/prompt-09.wav +0 -0
  55. rtpbench/corpus/lines/en/prompt.wav +0 -0
  56. rtpbench/corpus/lines/en/transfer.wav +0 -0
  57. rtpbench/corpus/lines/en/turn-00.wav +0 -0
  58. rtpbench/corpus/lines/en/turn-01.wav +0 -0
  59. rtpbench/corpus/lines/en/turn-02.wav +0 -0
  60. rtpbench/corpus/lines/en/turn-03.wav +0 -0
  61. rtpbench/corpus/lines/en/turn-04.wav +0 -0
  62. rtpbench/corpus/lines/en/turn-05.wav +0 -0
  63. rtpbench/corpus/lines/en/turn-06.wav +0 -0
  64. rtpbench/corpus/lines/en/turn-07.wav +0 -0
  65. rtpbench/corpus/lines/en/turn-08.wav +0 -0
  66. rtpbench/corpus/lines/en/turn-09.wav +0 -0
  67. rtpbench/corpus/lines/en/turn-10.wav +0 -0
  68. rtpbench/corpus/lines/en/turn-11.wav +0 -0
  69. rtpbench/corpus/tts-ald-bc.wav +0 -0
  70. rtpbench/corpus/tts-ald-long.wav +0 -0
  71. rtpbench/corpus/tts-ald-s03.wav +0 -0
  72. rtpbench/corpus/tts-ald-s07.wav +0 -0
  73. rtpbench/corpus/tts-ald-s11.wav +0 -0
  74. rtpbench/corpus/tts-cori-bc.wav +0 -0
  75. rtpbench/corpus/tts-cori-long.wav +0 -0
  76. rtpbench/corpus/tts-cori-s01.wav +0 -0
  77. rtpbench/corpus/tts-cori-s05.wav +0 -0
  78. rtpbench/corpus/tts-cori-s09.wav +0 -0
  79. rtpbench/corpus/tts-davefx-bc.wav +0 -0
  80. rtpbench/corpus/tts-davefx-long.wav +0 -0
  81. rtpbench/corpus/tts-davefx-s00.wav +0 -0
  82. rtpbench/corpus/tts-davefx-s04.wav +0 -0
  83. rtpbench/corpus/tts-davefx-s08.wav +0 -0
  84. rtpbench/corpus/tts-libritts0-bc.wav +0 -0
  85. rtpbench/corpus/tts-libritts0-long.wav +0 -0
  86. rtpbench/corpus/tts-libritts0-s02.wav +0 -0
  87. rtpbench/corpus/tts-libritts0-s06.wav +0 -0
  88. rtpbench/corpus/tts-libritts0-s10.wav +0 -0
  89. rtpbench/corpus/tts-libritts3-bc.wav +0 -0
  90. rtpbench/corpus/tts-libritts3-long.wav +0 -0
  91. rtpbench/corpus/tts-libritts3-s03.wav +0 -0
  92. rtpbench/corpus/tts-libritts3-s07.wav +0 -0
  93. rtpbench/corpus/tts-libritts3-s11.wav +0 -0
  94. rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
  95. rtpbench/corpus/tts-ljspeech-long.wav +0 -0
  96. rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
  97. rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
  98. rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
  99. rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
  100. rtpbench/corpus/tts-sharvardf-long.wav +0 -0
  101. rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
  102. rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
  103. rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
  104. rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
  105. rtpbench/corpus/tts-sharvardm-long.wav +0 -0
  106. rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
  107. rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
  108. rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
  109. rtpbench/detect.py +145 -0
  110. rtpbench/g711.py +82 -0
  111. rtpbench/mock/compose.yaml +18 -0
  112. rtpbench/pcap.py +106 -0
  113. rtpbench/progress.py +184 -0
  114. rtpbench/realtime.py +1015 -0
  115. rtpbench/report.py +450 -0
  116. rtpbench/rtp.py +166 -0
  117. rtpbench/sip.py +653 -0
  118. rtpbench/speech.py +100 -0
  119. rtpbench/speechcal.py +367 -0
  120. rtpbench/srtp.py +191 -0
  121. rtpbench/targets/aivc/aivc.ini +25 -0
  122. rtpbench/targets/aivc/compose.yaml +72 -0
  123. rtpbench/targets/aivc/constraints.txt +52 -0
  124. rtpbench/targets/aivc/opensips.cfg +45 -0
  125. rtpbench/targets/ava/ai-agent.yaml +275 -0
  126. rtpbench/targets/ava/ari.conf +7 -0
  127. rtpbench/targets/ava/compose.yaml +128 -0
  128. rtpbench/targets/ava/constraints.txt +75 -0
  129. rtpbench/targets/ava/extensions.conf +10 -0
  130. rtpbench/targets/ava/http.conf +4 -0
  131. rtpbench/targets/ava/pjsip.conf +19 -0
  132. rtpbench/targets/ava/websocket_client.conf +15 -0
  133. rtpbench/targets/livekit/agent.py +111 -0
  134. rtpbench/targets/livekit/compose.yaml +86 -0
  135. rtpbench/targets/livekit/constraints.txt +72 -0
  136. rtpbench/tls.py +284 -0
  137. rtpbench/uri/advertise.yaml +7 -0
  138. rtpbench/uri/network.yaml +14 -0
  139. rtpbench/voice.py +18 -0
  140. rtpbench-0.3.0.dist-info/METADATA +123 -0
  141. rtpbench-0.3.0.dist-info/RECORD +144 -0
  142. rtpbench-0.3.0.dist-info/WHEEL +4 -0
  143. rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
  144. rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
rtpbench/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])