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/realtime.py ADDED
@@ -0,0 +1,1015 @@
1
+ """Mock engine: a deterministic stand-in for the OpenAI Realtime API (GA protocol).
2
+
3
+ A WebSocket server (stdlib only) that speaks the protocol both public targets use: G.711 in
4
+ and out (AI Voice Connector CE, by OPENAI_URL) or 24 kHz PCM16 (LiveKit's `with_azure`, by its
5
+ endpoint). It hears the caller with the reference agent's energy gate: a turn starts at the
6
+ first voiced 10 ms frame and ends after HANGOVER_NS of audio without one. At the end of a turn
7
+ it tells which of the caller's lines (corpus/lines/en) was said from its energy envelope and,
8
+ HANGOVER_NS + DELAY_NS after the turn's last labelled sample arrived (labelled as the caller's
9
+ lines are), answers: the transfer line
10
+ with a call to the transfer tool, a long question with a long clip, anything else with a short
11
+ one (always the same clips). A turn that starts while it answers cancels the answer, as server
12
+ VAD does. Audio goes out in 100 ms deltas, kept LEAD_NS ahead of real time.
13
+
14
+ Every answer is logged (`/out/realtime.jsonl`) with when the caller's speech ended as heard
15
+ here and when the first audio went out, as captures stamp time (CLOCK_REALTIME, steps and all;
16
+ it runs on `CLOCK`, which does not step): a target's turn latency
17
+ then splits into its uplink, this engine's known share and its downlink (`decompose`). What
18
+ arrives is recorded as it arrived (`/out/realtime/<session>.raw`, `.idx`), so the summary can
19
+ say what a target did to the caller's audio (`uplink`) as to the engine's (`downlink`).
20
+ """
21
+
22
+ import asyncio
23
+ import base64
24
+ import hashlib
25
+ import json
26
+ import math
27
+ import os
28
+ import secrets
29
+ import statistics
30
+ import struct
31
+ from array import array
32
+ from collections.abc import Sequence
33
+ from pathlib import Path
34
+ from typing import Any, BinaryIO, TextIO
35
+
36
+ from rtpbench import g711, speech
37
+ from rtpbench.clock import CLOCK, Steps
38
+ from rtpbench.voice import GATE
39
+
40
+ PORT = 8000
41
+ FRAME = 80 # 10 ms at 8 kHz
42
+ PACKET = 160 # 20 ms
43
+ HANGOVER_NS = 1_000_000_000 # silence that ends a turn (real speech pauses up to 0.8 s inside)
44
+ DELAY_NS = 500_000_000 # from the end of a turn to the answer
45
+ DELTA_MS = 100 # audio per delta
46
+ LEAD_NS = 500_000_000 # how far ahead of real time the audio is sent
47
+ SPAN_DB = 40 # a line runs from its first to its last 20 ms within this of its loudest
48
+ TOOL = "transfer_call"
49
+ # the transfer tool as each target names it: AI Voice Connector CE and LiveKit, AVA
50
+ TOOLS = (TOOL, "blind_transfer")
51
+ DESTINATION = "operator" # each bundled target's name for where a transfer goes
52
+ SHORT, LONG = "tts-ljspeech-s00", "tts-ljspeech-long"
53
+ _GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
54
+
55
+
56
+ def envelope(pcm: list[int]) -> list[float]:
57
+ """20 ms log energy (dB) over 8 kHz samples: a packet's worth, so a lost packet blurs one
58
+ value instead of splitting two."""
59
+ return [
60
+ 10 * math.log10(sum(s * s for s in pcm[i : i + PACKET]) / PACKET + 1.0)
61
+ for i in range(0, len(pcm) - PACKET + 1, PACKET)
62
+ ]
63
+
64
+
65
+ def shape(pcm: list[int]) -> list[float]:
66
+ """The envelope from the first to the last value within SPAN_DB of its loudest, less its mean:
67
+ the same for a line at any level (one connector passes G.711 through, another resamples and
68
+ re-encodes it on the way)."""
69
+ e = envelope(pcm)
70
+ if not e:
71
+ return []
72
+ on = [i for i, x in enumerate(e) if x >= max(e) - SPAN_DB]
73
+ e = e[on[0] : on[-1] + 1]
74
+ mean = sum(e) / len(e)
75
+ return [x - mean for x in e]
76
+
77
+
78
+ def distance(a: list[float], b: list[float]) -> float:
79
+ """Mean dB apart along the closest time warp of two envelopes that strays from the diagonal by
80
+ 500 ms or a fifth of the longer, whichever is more: packets dropped or concealed on the way
81
+ stretch or shrink a line, which a straight comparison takes for a different line. inf if the
82
+ lengths differ by more."""
83
+ n, m = len(a), len(b)
84
+ band = max(25, max(n, m) // 5)
85
+ if not n or not m or abs(n - m) > band:
86
+ return math.inf
87
+ prev = [0.0] + [math.inf] * m
88
+ for i in range(1, n + 1):
89
+ cur = [math.inf] * (m + 1)
90
+ for j in range(max(1, i - band), min(m, i + band) + 1):
91
+ cur[j] = abs(a[i - 1] - b[j - 1]) + min(prev[j], prev[j - 1], cur[j - 1])
92
+ prev = cur
93
+ return prev[m] / (n + m)
94
+
95
+
96
+ class Lines:
97
+ """The caller's lines, and what to answer each with."""
98
+
99
+ def __init__(self) -> None:
100
+ paths = sorted((speech.CORPUS / "lines" / "en").glob("*.wav"))
101
+ self.refs = {p.stem: shape(speech.read_wav(p)) for p in paths}
102
+ self.clips = {}
103
+ for name in (SHORT, LONG): # from the labelled onset, so sample 0 is speech
104
+ pcm = speech.read_wav(speech.CORPUS / f"{name}.wav")
105
+ self.clips[name] = pcm[speech.label(pcm)[0][0] :]
106
+
107
+ def recognise(self, pcm: list[int]) -> tuple[str, float]:
108
+ """The line nearest to `pcm` (8 kHz) and its distance; "" if no line is near its length.
109
+ No distance limit: a target's path can move a line by as much as a different line differs
110
+ (LiveKit's by 1.7 dB, where the nearest other line was 2.8), and the caller says only its
111
+ lines, or short interjections that come out as short turns."""
112
+ e = shape(pcm)
113
+ best = min(((distance(e, r), n) for n, r in self.refs.items()), default=(math.inf, ""))
114
+ return (best[1] if best[0] < math.inf else ""), best[0]
115
+
116
+ @staticmethod
117
+ def answer(line: str) -> str:
118
+ """The clip for a line, or TOOL."""
119
+ if line == "transfer":
120
+ return TOOL
121
+ return LONG if line.startswith("prompt") else SHORT
122
+
123
+
124
+ # --- WebSocket (RFC 6455, server side: text frames out, masked frames in) ---
125
+
126
+
127
+ async def _handshake(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> str:
128
+ head = (await reader.readuntil(b"\r\n\r\n")).decode(errors="replace").split("\r\n")
129
+ headers = {k.strip().lower(): v.strip() for k, _, v in (h.partition(":") for h in head[1:])}
130
+ key = headers["sec-websocket-key"].encode()
131
+ accept = base64.b64encode(hashlib.sha1(key + _GUID).digest()).decode()
132
+ writer.write(
133
+ (
134
+ "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
135
+ f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
136
+ ).encode()
137
+ )
138
+ await writer.drain()
139
+ return head[0]
140
+
141
+
142
+ async def _frame(reader: asyncio.StreamReader) -> tuple[int, bytes]:
143
+ """One message: opcode and payload (continuation frames joined)."""
144
+ data, first = b"", None
145
+ while True:
146
+ b0, b1 = await reader.readexactly(2)
147
+ n = b1 & 0x7F
148
+ if n == 126:
149
+ n = struct.unpack("!H", await reader.readexactly(2))[0]
150
+ elif n == 127:
151
+ n = struct.unpack("!Q", await reader.readexactly(8))[0]
152
+ mask = await reader.readexactly(4) if b1 & 0x80 else b""
153
+ chunk = await reader.readexactly(n)
154
+ if mask:
155
+ chunk = bytes(c ^ mask[i % 4] for i, c in enumerate(chunk))
156
+ if b0 & 0x0F >= 8: # a control frame may sit between fragments
157
+ return b0 & 0x0F, chunk
158
+ first = b0 & 0x0F if first is None else first
159
+ data += chunk
160
+ if b0 & 0x80:
161
+ return first, data
162
+
163
+
164
+ def _pack(opcode: int, data: bytes) -> bytes:
165
+ n = len(data)
166
+ if n < 126:
167
+ head = struct.pack("!BB", 0x80 | opcode, n)
168
+ elif n < 1 << 16:
169
+ head = struct.pack("!BBH", 0x80 | opcode, 126, n)
170
+ else:
171
+ head = struct.pack("!BBQ", 0x80 | opcode, 127, n)
172
+ return head + data
173
+
174
+
175
+ # --- the engine ---
176
+
177
+
178
+ def _arguments(tool: dict[str, Any]) -> dict[str, str] | None:
179
+ """A transfer tool's arguments: every required one names the operator (AVA requires a
180
+ destination). None if one is not text or its schema rules the operator out: the mock does
181
+ not call the tool rather than send a call its schema rejects."""
182
+ params = tool.get("parameters") or {}
183
+ props = params.get("properties") or {}
184
+ required = params.get("required") or []
185
+ for k in required:
186
+ prop = props.get(k, {})
187
+ if prop.get("type") != "string" or DESTINATION not in prop.get("enum", [DESTINATION]):
188
+ return None
189
+ if prop.get("const", DESTINATION) != DESTINATION:
190
+ return None
191
+ return dict.fromkeys(required, DESTINATION)
192
+
193
+
194
+ def _id(prefix: str) -> str:
195
+ return f"{prefix}_{secrets.token_hex(8)}"
196
+
197
+
198
+ def _upsample(pcm: list[int]) -> bytes:
199
+ """8 kHz samples as 24 kHz PCM16, by linear interpolation."""
200
+ up = array("h")
201
+ for a, b in zip(pcm, [*pcm[1:], pcm[-1]], strict=True):
202
+ up.extend((a, round(a + (b - a) / 3), round(a + 2 * (b - a) / 3)))
203
+ return up.tobytes()
204
+
205
+
206
+ class Session:
207
+ """One WebSocket connection: a conversation."""
208
+
209
+ def __init__(self, writer: asyncio.StreamWriter, lines: Lines, log: Path, steps: Steps) -> None:
210
+ self.writer, self.lines, self.log, self.steps = writer, lines, log, steps
211
+ self.id = _id("sess")
212
+ self.session: dict[str, Any] = {"type": "realtime", "object": "realtime.session"}
213
+ self.pt: int | None = None # G.711 law, or None for 24 kHz PCM16
214
+ self.heard: list[int] = [] # the current turn, 8 kHz
215
+ self.heard_at: list[int] = [] # clock time of each of its 10 ms frames' sample 0
216
+ self.speaking = False
217
+ self.last_voiced = 0 # clock time of the turn's last voiced sample
218
+ self.quiet = 0 # samples since it
219
+ self.started = 0 # clock time the turn started
220
+ self.answering: asyncio.Task[None] | None = None
221
+ self.responding = False # an answer has been created and is not done
222
+ self.user_item = ""
223
+ self.last_item = ""
224
+ self.tools: list[dict[str, Any]] = []
225
+ self.kept: tuple[BinaryIO, TextIO] | None = None # realtime/<id>.raw and .idx
226
+
227
+ # outgoing
228
+
229
+ async def send(self, event: dict[str, Any]) -> None:
230
+ event = {"event_id": _id("event")} | event
231
+ self.writer.write(_pack(1, json.dumps(event).encode()))
232
+ await self.writer.drain()
233
+
234
+ def note(self, record: dict[str, Any]) -> None:
235
+ """Log `record`, its times (`*_ns`) as captures stamp them."""
236
+ record = {
237
+ k: self.steps.wall(v) if k.endswith("_ns") and isinstance(v, int) else v
238
+ for k, v in record.items()
239
+ }
240
+ with self.log.open("a") as f:
241
+ f.write(json.dumps({"session": self.id} | record) + "\n")
242
+
243
+ # incoming
244
+
245
+ async def handle(self, event: dict[str, Any]) -> None:
246
+ kind = event.get("type")
247
+ if kind == "session.update":
248
+ self.configure(event.get("session", {}))
249
+ await self.send({"type": "session.updated", "session": self.session})
250
+ elif kind == "input_audio_buffer.append":
251
+ audio, now = base64.b64decode(event["audio"]), CLOCK.now()
252
+ await self.hear(audio, now)
253
+ self.keep(audio, now)
254
+ elif kind == "response.create":
255
+ asked, now = event.get("response", {}), CLOCK.now()
256
+ self.start(self.reply(self.lines.answer(""), "create", asked, now, {"asked_ns": now}))
257
+ elif kind == "response.cancel":
258
+ await self.cancel()
259
+ elif kind == "conversation.item.create":
260
+ item = event.get("item", {}) | {"id": event.get("item", {}).get("id") or _id("item")}
261
+ await self.added(item)
262
+ elif kind == "conversation.item.truncate":
263
+ await self.send(
264
+ {
265
+ "type": "conversation.item.truncated",
266
+ "item_id": event.get("item_id"),
267
+ "content_index": event.get("content_index", 0),
268
+ "audio_end_ms": event.get("audio_end_ms", 0),
269
+ }
270
+ )
271
+ elif kind in ("input_audio_buffer.clear", "input_audio_buffer.commit"):
272
+ if kind.endswith("clear"):
273
+ self.heard, self.speaking = [], False
274
+ await self.send(
275
+ {"type": kind.replace("clear", "cleared").replace("commit", "committed")}
276
+ )
277
+
278
+ def keep(self, audio: bytes, arrival: int) -> None:
279
+ """Record what arrived as it arrived: the bytes to realtime/<id>.raw, and the arrival
280
+ time, byte count and format (pcmu, pcma or pcm16 at 24 kHz) to realtime/<id>.idx."""
281
+ if self.kept is None:
282
+ where = self.log.parent / "realtime"
283
+ where.mkdir(exist_ok=True)
284
+ raw, idx = where / f"{self.id}.raw", where / f"{self.id}.idx"
285
+ self.kept = raw.open("ab"), idx.open("a")
286
+ fmt = "pcm16" if self.pt is None else g711.NAMES[self.pt].lower()
287
+ self.kept[0].write(audio)
288
+ self.kept[1].write(f"{self.steps.wall(arrival)} {len(audio)} {fmt}\n")
289
+ for f in self.kept:
290
+ f.flush() # whole appends on disk, however the engine is stopped
291
+
292
+ def close(self) -> None:
293
+ if self.kept is not None:
294
+ for f in self.kept:
295
+ f.close()
296
+
297
+ def configure(self, update: dict[str, Any]) -> None:
298
+ self.session |= update
299
+ fmt = update.get("audio", {}).get("input", {}).get("format", {}).get("type", "")
300
+ if fmt:
301
+ self.pt = {"audio/pcmu": g711.PCMU, "audio/pcma": g711.PCMA}.get(fmt)
302
+ if "tools" in update:
303
+ self.tools = list(update["tools"])
304
+ names = [t.get("name", "") for t in self.tools]
305
+ tool = self.transfer_tool()
306
+ self.note(
307
+ {
308
+ "event": "session",
309
+ "format": fmt or None,
310
+ "tools": names,
311
+ "transfer": tool.get("name") if tool else None,
312
+ }
313
+ )
314
+
315
+ def transfer_tool(self) -> dict[str, Any] | None:
316
+ """The offered tool the mock calls to transfer: named as in TOOLS, its arguments able to
317
+ name the operator (`_arguments`); None if the target offers none."""
318
+ return next(
319
+ (t for t in self.tools if t.get("name") in TOOLS and _arguments(t) is not None), None
320
+ )
321
+
322
+ async def added(self, item: dict[str, Any], done: bool = True) -> None:
323
+ """conversation.item.added, and with `done` its conversation.item.done."""
324
+ await self.send(
325
+ {"type": "conversation.item.added", "previous_item_id": self.last_item or None}
326
+ | {"item": item}
327
+ )
328
+ if done:
329
+ await self.send({"type": "conversation.item.done", "item": item})
330
+ self.last_item = item["id"]
331
+
332
+ def decode(self, audio: bytes) -> list[int]:
333
+ """8 kHz samples of what was appended."""
334
+ if self.pt is not None:
335
+ return g711.decode(self.pt, audio)
336
+ pcm = array("h", audio[: len(audio) // 2 * 2])
337
+ return [round((pcm[i] + pcm[i + 1] + pcm[i + 2]) / 3) for i in range(0, len(pcm) - 2, 3)]
338
+
339
+ def encode(self, pcm: list[int]) -> bytes:
340
+ """8 kHz samples as this session sends audio."""
341
+ return g711.encode(self.pt, pcm) if self.pt is not None else _upsample(pcm)
342
+
343
+ async def hear(self, audio: bytes, arrival: int) -> None:
344
+ """Audio that arrived at `arrival`: turns start and end, by the audio itself (a turn ends
345
+ after HANGOVER_NS of silent audio, however fast it came). Sample k of a chunk sits at
346
+ arrival + k/8000 s, the convention the captures' analysis uses for a packet."""
347
+ pcm = self.decode(audio)
348
+ for i in range(0, len(pcm), FRAME):
349
+ frame = pcm[i : i + FRAME]
350
+ t0 = arrival + i * 125_000 # the frame's sample 0
351
+ loud = [k for k, s in enumerate(frame) if abs(s) >= GATE]
352
+ if loud:
353
+ if not self.speaking:
354
+ await self.turn_starts(t0 + loud[0] * 125_000)
355
+ self.last_voiced = t0 + loud[-1] * 125_000
356
+ self.quiet = len(frame) - 1 - loud[-1]
357
+ elif self.speaking:
358
+ self.quiet += len(frame)
359
+ if self.speaking:
360
+ self.heard += frame
361
+ self.heard_at.append(t0)
362
+ if self.quiet * 125_000 >= HANGOVER_NS:
363
+ await self.turn_ends()
364
+
365
+ async def turn_starts(self, at: int) -> None:
366
+ self.speaking, self.heard, self.heard_at, self.started = True, [], [], at
367
+ self.user_item = _id("item")
368
+ barged = self.responding
369
+ await self.send(
370
+ {"type": "input_audio_buffer.speech_started", "item_id": self.user_item}
371
+ | {"audio_start_ms": 0}
372
+ )
373
+ if barged:
374
+ self.note({"event": "barge", "speech_start_ns": at, "cancel_ns": CLOCK.now()})
375
+ await self.cancel() # an answer playing, or still to come
376
+
377
+ async def turn_ends(self) -> None:
378
+ self.speaking = False
379
+ line, how = self.lines.recognise(self.heard)
380
+ await self.send(
381
+ {"type": "input_audio_buffer.speech_stopped", "item_id": self.user_item}
382
+ | {"audio_end_ms": 0}
383
+ )
384
+ await self.send(
385
+ {"type": "input_audio_buffer.committed", "item_id": self.user_item}
386
+ | {"previous_item_id": self.last_item or None}
387
+ )
388
+ content = [{"type": "input_audio", "transcript": line or None}]
389
+ await self.added(
390
+ {"id": self.user_item, "type": "message", "role": "user"} | {"content": content}
391
+ )
392
+ await self.send(
393
+ {
394
+ "type": "conversation.item.input_audio_transcription.completed",
395
+ "item_id": self.user_item,
396
+ "content_index": 0,
397
+ "transcript": line or "(not one of the caller's lines)",
398
+ }
399
+ )
400
+ end = self.speech_end()
401
+ heard = {
402
+ "line": line,
403
+ "distance": round(how, 3) if how < math.inf else None,
404
+ "speech_end_ns": end,
405
+ }
406
+ heard["gate_end_ns"] = self.last_voiced
407
+ since = end + HANGOVER_NS # so the engine always takes HANGOVER + DELAY from the end
408
+ self.start(self.reply(self.lines.answer(line), "turn", {}, since, heard))
409
+
410
+ def speech_end(self) -> int:
411
+ """When the turn's last labelled sample (`speech.label`, as the caller's lines are
412
+ labelled) arrived; the gate's last voiced sample if the turn will not label."""
413
+ try:
414
+ k = speech.label(self.heard)[-1][1] - 1
415
+ except ValueError:
416
+ return self.last_voiced
417
+ return self.heard_at[k // FRAME] + k % FRAME * 125_000
418
+
419
+ def start(self, answer: Any) -> None:
420
+ if self.answering is not None and not self.answering.done():
421
+ self.answering.cancel()
422
+ self.answering = asyncio.ensure_future(answer)
423
+
424
+ async def cancel(self) -> None:
425
+ if self.answering is not None and not self.answering.done():
426
+ self.answering.cancel()
427
+ await asyncio.gather(self.answering, return_exceptions=True)
428
+
429
+ async def reply(
430
+ self,
431
+ what: str,
432
+ trigger: str,
433
+ asked: dict[str, Any],
434
+ since: int,
435
+ heard: dict[str, Any] | None = None,
436
+ ) -> None:
437
+ """Answer DELAY_NS after `since`: a clip, or a call to the target's transfer tool."""
438
+ await asyncio.sleep(max(0, since + DELAY_NS - CLOCK.now()) / 1e9)
439
+ tool = self.transfer_tool()
440
+ if what == TOOL and tool is None:
441
+ what = SHORT
442
+ rid, item_id = _id("resp"), _id("item")
443
+ response: dict[str, Any] = {
444
+ "id": rid,
445
+ "object": "realtime.response",
446
+ "status": "in_progress",
447
+ "output": [],
448
+ "metadata": asked.get("metadata"),
449
+ "audio": {
450
+ "output": {"format": self.session.get("audio", {}).get("output", {}).get("format")}
451
+ },
452
+ }
453
+ await self.send({"type": "response.created", "response": response})
454
+ self.responding = True
455
+ record = {"event": "answer", "trigger": trigger, "answer": what} | (heard or {})
456
+ status, item = "completed", {}
457
+ try:
458
+ if what == TOOL and tool is not None:
459
+ item = await self.call_tool(rid, item_id, tool)
460
+ record |= {"tool": item["name"], "arguments": item["arguments"]}
461
+ else:
462
+ item = await self.speak(rid, item_id, what, record)
463
+ except asyncio.CancelledError:
464
+ status = "cancelled"
465
+ raise
466
+ finally:
467
+ self.responding = False
468
+ self.note(record | {"status": status, "end_ns": CLOCK.now()})
469
+ done = response | {"status": status, "output": [item] if item else []}
470
+ done["usage"] = {"total_tokens": 0, "input_tokens": 0, "output_tokens": 0}
471
+ await asyncio.shield(self.send({"type": "response.done", "response": done}))
472
+
473
+ async def call_tool(self, rid: str, item_id: str, tool: dict[str, Any]) -> dict[str, Any]:
474
+ """Call `tool` with `_arguments`, as a model told where a transfer can go would."""
475
+ arguments = json.dumps(_arguments(tool))
476
+ name, call_id = tool["name"], _id("call")
477
+ item = {"id": item_id, "object": "realtime.item", "type": "function_call"}
478
+ item |= {"status": "in_progress", "name": name, "call_id": call_id, "arguments": ""}
479
+ where = {"response_id": rid, "output_index": 0}
480
+ await self.send({"type": "response.output_item.added", "item": item} | where)
481
+ await self.added(item, done=False) # done once its arguments are
482
+ args = {"item_id": item_id, "call_id": call_id} | where
483
+ delta = {"type": "response.function_call_arguments.delta", "delta": arguments}
484
+ await self.send(delta | args)
485
+ done = {"type": "response.function_call_arguments.done", "name": name}
486
+ await self.send(done | {"arguments": arguments} | args)
487
+ item |= {"status": "completed", "arguments": arguments}
488
+ await self.send({"type": "conversation.item.done", "item": item})
489
+ await self.send({"type": "response.output_item.done", "item": item} | where)
490
+ return item
491
+
492
+ async def speak(
493
+ self, rid: str, item_id: str, clip: str, record: dict[str, Any]
494
+ ) -> dict[str, Any]:
495
+ item: dict[str, Any] = {"id": item_id, "object": "realtime.item", "type": "message"}
496
+ item |= {"role": "assistant", "status": "in_progress", "content": []}
497
+ where = {"response_id": rid, "item_id": item_id, "output_index": 0, "content_index": 0}
498
+ await self.send(
499
+ {"type": "response.output_item.added", "item": item, "response_id": rid}
500
+ | {"output_index": 0}
501
+ )
502
+ await self.added(item)
503
+ part = {"type": "audio", "transcript": ""}
504
+ await self.send({"type": "response.content_part.added", "part": part} | where)
505
+ pcm, step = self.lines.clips[clip], 8 * DELTA_MS
506
+ t0 = CLOCK.now()
507
+ record |= {"clip": clip, "first_audio_ns": t0}
508
+ for k, i in enumerate(range(0, len(pcm), step)):
509
+ await asyncio.sleep(max(0, t0 + k * DELTA_MS * 1_000_000 - LEAD_NS - CLOCK.now()) / 1e9)
510
+ delta = base64.b64encode(self.encode(pcm[i : i + step])).decode()
511
+ await self.send({"type": "response.output_audio.delta", "delta": delta} | where)
512
+ await self.send({"type": "response.output_audio.done"} | where)
513
+ await self.send(
514
+ {"type": "response.output_audio_transcript.done", "transcript": clip} | where
515
+ )
516
+ part = {"type": "audio", "transcript": clip}
517
+ await self.send({"type": "response.content_part.done", "part": part} | where)
518
+ item |= {"status": "completed", "content": [{"type": "output_audio", "transcript": clip}]}
519
+ await self.send(
520
+ {"type": "response.output_item.done", "item": item, "response_id": rid}
521
+ | {"output_index": 0}
522
+ )
523
+ return item
524
+
525
+
526
+ def serve(out: Path, port: int = PORT, ready: Path | None = None) -> None:
527
+ """Serve sessions until killed; log to out/realtime.jsonl; touch `ready` once listening."""
528
+ lines, steps = Lines(), Steps()
529
+ out.mkdir(parents=True, exist_ok=True)
530
+ log = out / "realtime.jsonl"
531
+
532
+ async def connection(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
533
+ try:
534
+ request = await _handshake(reader, writer)
535
+ except asyncio.IncompleteReadError, KeyError, ConnectionError:
536
+ writer.close()
537
+ return
538
+ s = Session(writer, lines, log, steps)
539
+ s.note({"event": "connect", "request": request.split(" ")[1], "at_ns": CLOCK.now()})
540
+ await s.send({"type": "session.created", "session": s.session | {"id": s.id}})
541
+ try:
542
+ while True:
543
+ opcode, data = await _frame(reader)
544
+ if opcode == 8:
545
+ break
546
+ if opcode == 9:
547
+ writer.write(_pack(10, data))
548
+ elif opcode == 1:
549
+ await s.handle(json.loads(data))
550
+ except asyncio.IncompleteReadError, ConnectionError:
551
+ pass
552
+ finally:
553
+ await s.cancel()
554
+ s.close()
555
+ s.note({"event": "close", "at_ns": CLOCK.now()})
556
+ writer.close()
557
+
558
+ async def main() -> None:
559
+ try:
560
+ server = await asyncio.start_server(connection, "0.0.0.0", port)
561
+ except OSError as e:
562
+ why = os.strerror(e.errno or 0)
563
+ raise SystemExit(
564
+ f"realtime: cannot listen on port {port}: {why} (--port picks another)"
565
+ ) from None
566
+ if ready:
567
+ await asyncio.to_thread(ready.touch)
568
+ async with server:
569
+ await server.serve_forever()
570
+
571
+ asyncio.run(main())
572
+
573
+
574
+ def conditions() -> dict[str, Any]:
575
+ """The mock engine as a run's conditions record it."""
576
+ return {
577
+ "mock": "rtpbench.realtime",
578
+ "hangover_ms": HANGOVER_NS / 1e6,
579
+ "delay_ms": DELAY_NS / 1e6,
580
+ "lead_ms": LEAD_NS / 1e6,
581
+ "clips": [SHORT, LONG],
582
+ }
583
+
584
+
585
+ def _cells(parts: list[tuple[float, ...]]) -> list[str]:
586
+ """p50 [p5, p95] of each column."""
587
+ from rtpbench import analyze
588
+
589
+ out = []
590
+ for k in range(len(parts[0])):
591
+ v = [p[k] for p in parts]
592
+ q = [analyze._pct(v, x) for x in (0.5, 0.05, 0.95)]
593
+ out.append(f"{q[0]:.0f} [{q[1]:.0f}, {q[2]:.0f}]")
594
+ return out
595
+
596
+
597
+ def decompose(out: Path) -> str:
598
+ """Turn latency and greeting time against the mock engine, split where it heard and
599
+ answered, per profile. Turns (the latency calls): uplink (caller's labelled end on the wire to
600
+ the end of speech as heard here), engine (to the first audio sent; HANGOVER_NS + DELAY_NS by
601
+ design) and downlink (to the agent's first speech on the wire, webrtcvad). Greeting (the
602
+ `greeting` case calls): 200 OK to the target's request for a greeting, the engine's DELAY_NS,
603
+ first audio to the agent's speech on the wire."""
604
+ from rtpbench import analyze, cases, speechcal
605
+
606
+ records = [json.loads(line) for line in (out / "realtime.jsonl").read_text().splitlines()]
607
+ answers = [r for r in records if r.get("event") == "answer" and "first_audio_ns" in r]
608
+ heard = sorted(
609
+ (r["speech_end_ns"], r["first_audio_ns"]) for r in answers if "speech_end_ns" in r
610
+ )
611
+ asked = sorted((r["asked_ns"], r["first_audio_ns"]) for r in answers if "asked_ns" in r)
612
+ turns: dict[str, list[tuple[float, ...]]] = {}
613
+ for path in sorted(out.glob("*/*/latency/caller.json")):
614
+ for t in speechcal.turns(path.parent, ("webrtcvad",)):
615
+ if t["webrtcvad_ms"] is None:
616
+ continue
617
+ end = t["end_ns"]
618
+ onset = end + round(t["webrtcvad_ms"] * 1e6)
619
+ # the end as heard here: after the caller's on the wire, give or take label noise
620
+ if got := [(e, a) for e, a in heard if end - 500_000_000 <= e < onset and a < onset]:
621
+ e, a = got[-1]
622
+ parts = (t["webrtcvad_ms"], (e - end) / 1e6, (a - e) / 1e6, (onset - a) / 1e6)
623
+ turns.setdefault(path.parts[-4], []).append(parts)
624
+ greetings: dict[str, list[tuple[float, ...]]] = {}
625
+ for path in sorted(out.glob("*/*/greeting/caller.json")):
626
+ w = cases.load(path.parent)
627
+ ok = w.find(cases._is_2xx_invite, sent=False)
628
+ onset = w.speech_after(ok[0]) if ok else None
629
+ if ok and onset and (got := [(q, a) for q, a in asked if ok[0] - 5e9 < q and a < onset]):
630
+ q, a = got[-1]
631
+ parts = ((onset - ok[0]) / 1e6, (q - ok[0]) / 1e6, (a - q) / 1e6, (onset - a) / 1e6)
632
+ greetings.setdefault(path.parts[-4], []).append(parts)
633
+ text = ""
634
+ if turns:
635
+ head = ["profile", "turns", "wire latency", "uplink", "engine", "downlink"]
636
+ rows = [[p, str(len(v)), *_cells(v)] for p, v in turns.items()]
637
+ text += (
638
+ "Turn latency on the mock engine, split where it heard the end of the caller's speech "
639
+ "and sent its first audio (one clock with the captures), ms, p50 [p5, p95]: uplink and "
640
+ f"downlink are the target's; the engine waits {HANGOVER_NS / 1e6:.0f} ms of silence "
641
+ f"and answers {DELAY_NS / 1e6:.0f} ms later by design. Uplink carries the caller's "
642
+ "label error (±17 ms), downlink the onset error bar of the profile and codec.\n\n"
643
+ + analyze._table(head, rows)
644
+ )
645
+ if greetings:
646
+ head = ["profile", "calls", "200 OK to speech", "to the request", "engine", "downlink"]
647
+ rows = [[p, str(len(v)), *_cells(v)] for p, v in greetings.items()]
648
+ text += (
649
+ "\nGreeting on the mock engine, ms, p50 [p5, p95]: from the 200 OK on the wire to the "
650
+ "target's request for a greeting (negative: asked before answering), the engine's "
651
+ f"{DELAY_NS / 1e6:.0f} ms, and from its first audio to the agent's speech on the wire "
652
+ "(webrtcvad onset bar).\n\n" + analyze._table(head, rows)
653
+ )
654
+ return text
655
+
656
+
657
+ # --- what reached the caller of the audio sent here ---
658
+
659
+ PAUSE = 800 # 100 ms below the gate: a pause between stretches of speech
660
+ PIECE = 8000 # at most 1 s of speech aligned as one piece
661
+ SEARCH = 480 # how far a piece may sit from the one before it, 60 ms
662
+ SHIFT = 40 # 5 ms: a piece moved by less is a resampler's fraction of a sample, not a timing change
663
+ TAPS = 31 # a filter of ±4 ms explains any resampler's or band filter's change to a piece
664
+
665
+
666
+ def pieces(pcm: list[int]) -> list[tuple[int, int]]:
667
+ """The speech in `pcm` as [start, end) sample spans between pauses, split to at most PIECE."""
668
+ spans: list[list[int]] = []
669
+ for i, s in enumerate(pcm):
670
+ if abs(s) >= GATE:
671
+ if spans and i - spans[-1][1] < PAUSE:
672
+ spans[-1][1] = i + 1
673
+ else:
674
+ spans.append([i, i + 1])
675
+ out = []
676
+ for a, b in spans:
677
+ n = -(-(b - a) // PIECE)
678
+ out += [(a + (b - a) * k // n, a + (b - a) * (k + 1) // n) for k in range(n)]
679
+ return out
680
+
681
+
682
+ def sent(clip: list[int], fmt: str | None) -> list[float]:
683
+ """A clip as this engine sends it in `fmt`, at 8 kHz: G.711 decoded again, or the 24 kHz
684
+ interpolation through an ideal 8 kHz converter (none of this engine's resampling counted
685
+ against a target)."""
686
+ import numpy as np
687
+
688
+ law = {"audio/pcmu": g711.PCMU, "audio/pcma": g711.PCMA}.get(fmt or "")
689
+ if law is not None:
690
+ return [float(s) for s in g711.decode(law, g711.encode(law, clip))]
691
+ step = 8 * DELTA_MS # interpolated a delta at a time, as `Session.speak` sends it
692
+ raw = b"".join(_upsample(clip[i : i + step]) for i in range(0, len(clip), step))
693
+ return [float(s) for s in _to8k(np, np.frombuffer(raw, dtype="<i2"))]
694
+
695
+
696
+ def _to8k(np: Any, pcm: Any) -> Any:
697
+ """24 kHz samples through an ideal 8 kHz converter."""
698
+ spectrum = np.fft.rfft(np.asarray(pcm, dtype=np.float64))
699
+ spectrum[len(pcm) // 6 + 1 :] = 0 # above 4 kHz
700
+ return np.fft.irfft(spectrum, len(pcm))[::3]
701
+
702
+
703
+ def _lag(np: Any, ref: Any, rx: Any) -> int:
704
+ """Offset of `ref` in `rx` (no shorter) that correlates best."""
705
+ n = 1 << (len(ref) + len(rx) - 1).bit_length()
706
+ c = np.fft.irfft(np.fft.rfft(rx, n) * np.conj(np.fft.rfft(ref, n)), n)
707
+ return int(np.argmax(c[: len(rx) - len(ref) + 1]))
708
+
709
+
710
+ def heard(ref: Sequence[float], rx: Sequence[float], ok: Sequence[bool]) -> dict[str, Any] | None:
711
+ """What a path did to `ref` (8 kHz), received as `rx` from where it may start up to 1.5 s
712
+ late, `ok` false for samples the network lost. Each piece of speech is aligned on its own,
713
+ near the one before: gain (dB, least squares), what no linear filter of ±TAPS explains (dB
714
+ below the audio; inf if unchanged), 3.4-4 kHz against 300-3400 Hz relative to `ref`'s (dB),
715
+ the moves of 5 ms or more between pieces (ms, positive later), whether every aligned sample
716
+ is unchanged (pauses and pieces that could not be aligned are not compared), where in `rx`
717
+ `ref` starts (the median piece's offset), and its voiced 20 ms with nothing like them nearby
718
+ in `rx`, by what came instead. None if under half the speech could be aligned."""
719
+ import numpy as np
720
+
721
+ r_all, x_all, m_all = np.asarray(ref), np.asarray(rx, dtype=np.float64), np.asarray(ok)
722
+ spans = pieces([round(s) for s in ref])
723
+ if not spans or len(x_all) < len(r_all) + 12000:
724
+ return None
725
+ lag = _lag(np, r_all[:16000], x_all[:28000])
726
+ xs, rs, lags, band, fit = [], [], [], np.zeros((2, 2)), np.zeros(2)
727
+ placed: list[tuple[int, int, int]] = []
728
+ for a, b in spans:
729
+ r = r_all[a:b]
730
+ lo = max(0, lag + a - SEARCH)
731
+ found = lo + _lag(np, r, x_all[lo : lag + b + SEARCH]) - a
732
+ x, m = x_all[a + found : b + found], m_all[a + found : b + found]
733
+ if m.mean() < 0.8:
734
+ continue
735
+ if np.sum(x[m] * r[m]) < 0.5 * np.sqrt(np.sum(x[m] ** 2) * np.sum(r[m] ** 2)):
736
+ continue
737
+ lag = found
738
+ lags.append(found)
739
+ placed.append((a, b, found))
740
+ xs.append(x[m])
741
+ rs.append(r[m])
742
+ if m.all():
743
+ f = np.fft.rfftfreq(b - a, 1 / 8000)
744
+ mid, hi = (f >= 300) & (f < 3400), f >= 3400
745
+ for k, v in enumerate((x, r)):
746
+ p = np.abs(np.fft.rfft(v * np.hanning(b - a))) ** 2
747
+ band[k] += p[mid].sum(), p[hi].sum()
748
+ # least squares filter from r to x (normal equations: r's autocorrelation, r by x)
749
+ rp = np.pad(r, TAPS)
750
+ c = np.correlate(rp, x, "valid")
751
+ acf = np.correlate(np.pad(r, (0, 2 * TAPS)), r, "valid")
752
+ i = np.arange(2 * TAPS + 1)
753
+ explained = float(np.sum(c * np.linalg.solve(acf[abs(i[:, None] - i)], c)))
754
+ fit += explained, float(np.sum(x * x)) - explained
755
+ if sum(map(len, rs)) < 0.5 * sum(b - a for a, b in spans):
756
+ return None
757
+ x, r = np.concatenate(xs), np.concatenate(rs)
758
+ g = float(np.sum(x * r) / np.sum(r * r))
759
+ unchanged = bool(np.array_equal(x, r))
760
+ steps = np.diff(lags)
761
+ # each voiced 20 ms of `ref` looked for within ±25 ms of its piece; where none matches, what
762
+ # came in its place: next to nothing, the 20 ms before it again, or something else
763
+ missing = {"frames": 0, "silence": 0, "repeat": 0, "other": 0}
764
+ for a, b, found in placed:
765
+ for f0 in range(a, b - PACKET + 1, PACKET):
766
+ r, pos = r_all[f0 : f0 + PACKET], f0 + found
767
+ if (
768
+ np.sum(r * r) < PACKET * (GATE / 2) ** 2
769
+ or not m_all[max(0, pos - 400) : pos + 560].all()
770
+ ):
771
+ continue
772
+ missing["frames"] += 1
773
+ seg = x_all[max(0, pos - 200) : pos + 360]
774
+ c = np.correlate(seg, r, "valid")
775
+ e = np.convolve(seg * seg, np.ones(PACKET), "valid")
776
+ if np.max(c / np.sqrt(e * np.sum(r * r) + 1e-9)) >= 0.5:
777
+ continue
778
+ x, before = x_all[pos : pos + PACKET], x_all[max(0, pos - PACKET) : pos]
779
+ if np.sum(x * x) < 1e-3 * g * g * np.sum(r * r):
780
+ missing["silence"] += 1
781
+ elif len(before) == PACKET and np.sum(x * before) >= 0.9 * np.sqrt(
782
+ np.sum(x * x) * np.sum(before**2)
783
+ ):
784
+ missing["repeat"] += 1
785
+ else:
786
+ missing["other"] += 1
787
+ return {
788
+ "gain_db": 20 * math.log10(abs(g)),
789
+ "left_db": math.inf
790
+ if unchanged
791
+ else 10 * math.log10(fit[0] / fit[1])
792
+ if fit.all()
793
+ else None,
794
+ "band_db": 10 * math.log10(band[0, 1] * band[1, 0] / (band[0, 0] * band[1, 1]))
795
+ if band.all()
796
+ else None,
797
+ "moves_ms": [float(s) / 8 for s in steps if abs(s) >= SHIFT],
798
+ "unchanged": unchanged,
799
+ "at": int(np.median(lags)),
800
+ "missing": missing,
801
+ }
802
+
803
+
804
+ def _audio_cells(hs: list[dict[str, Any]]) -> list[str]:
805
+ """unchanged, distortion, moved, gain and 3.4-4 kHz over `heard` results."""
806
+ from rtpbench import cases
807
+
808
+ left = [h["left_db"] for h in hs if h["left_db"] is not None]
809
+ bands = [h["band_db"] for h in hs if h["band_db"] is not None]
810
+ moved = [h for h in hs if h["moves_ms"]]
811
+ largest = max((abs(m) for h in moved for m in h["moves_ms"]), default=0)
812
+ return [
813
+ str(sum(h["unchanged"] for h in hs)),
814
+ cases._spread(left, ".1f").replace("inf", "exact") if left else "",
815
+ f"{len(moved)}, up to {largest:.0f} ms" if moved else "0",
816
+ cases._spread([h["gain_db"] for h in hs], "+.1f"),
817
+ cases._spread(bands, "+.1f") if bands else "",
818
+ ]
819
+
820
+
821
+ def downlink(out: Path) -> str:
822
+ """The engine's completed answers as the caller received them in the latency and barge-in
823
+ calls (`heard`), per profile."""
824
+ from rtpbench import analyze, cases
825
+
826
+ records = [json.loads(line) for line in (out / "realtime.jsonl").read_text().splitlines()]
827
+ formats = {r["session"]: r["format"] for r in records if r.get("format")}
828
+ answers = [
829
+ r
830
+ for r in records
831
+ if r.get("event") == "answer" and r.get("clip") and r.get("status") == "completed"
832
+ ]
833
+ lines = Lines()
834
+ refs: dict[tuple[str, str | None], list[float]] = {}
835
+ by: dict[str, list[dict[str, Any]]] = {}
836
+ for path in sorted(out.glob("*/*/*/caller.json")):
837
+ if path.parent.name not in ("latency", "barge-in"):
838
+ continue
839
+ st = cases.load(path.parent, path.parts[-4]).audio
840
+ if st is None:
841
+ continue
842
+ times = [t for t in st.times if t is not None]
843
+ ok = [t is not None for t in st.times for _ in range(160)]
844
+ for a in answers:
845
+ if not times[0] <= a["first_audio_ns"] <= times[-1]:
846
+ continue
847
+ k = next(
848
+ i for i, t in enumerate(st.times) if t is not None and t >= a["first_audio_ns"]
849
+ )
850
+ key = a["clip"], formats.get(a["session"])
851
+ if key not in refs:
852
+ refs[key] = sent(lines.clips[key[0]], key[1])
853
+ if (h := heard(refs[key], st.pcm[k * 160 :], ok[k * 160 :])) is not None:
854
+ by.setdefault(path.parts[-4], []).append(h)
855
+ if not by:
856
+ return ""
857
+ rows = [[p, str(len(hs)), *_audio_cells(hs)] for p, hs in by.items()]
858
+ head = [
859
+ "profile",
860
+ "answers",
861
+ "unchanged",
862
+ "distortion, dB",
863
+ "moved",
864
+ "gain, dB",
865
+ "3.4-4 kHz, dB",
866
+ ]
867
+ return (
868
+ "The mock engine's answers as the caller received them (completed answers in the "
869
+ "latency and barge-in calls), median [min, max] over answers. Each piece of speech is "
870
+ "aligned with what the mock sent; for 24 kHz audio, that is its interpolation through an "
871
+ "ideal 8 kHz converter, so the mock's own resampling is not counted. Samples in packets "
872
+ "the network lost are left out. unchanged: answers whose aligned speech came through "
873
+ "sample for sample (pauses are not compared). "
874
+ "distortion: the audio against what no linear filter of ±4 ms explains; a resampler or "
875
+ "band filter would leave G.711's own, about 37 dB. moved: answers where a piece came out "
876
+ "5 ms or more later or earlier than the one before (a pause lengthened or shortened, "
877
+ "audio inserted or dropped), and the largest move. gain: least squares. 3.4-4 kHz: that "
878
+ "band against 300-3400 Hz, relative to what was sent.\n\n" + analyze._table(head, rows)
879
+ )
880
+
881
+
882
+ def recording(out: Path, session: str) -> tuple[Any, Any, Any, float] | None:
883
+ """What a session received (`Session.keep`), as arrays: the samples at 8 kHz, the arrival
884
+ time of the append each sample came in, the 24 kHz samples if it came as pcm16 (else
885
+ empty), and the median audio per append, ms. None without a recording or with appends in
886
+ more than one format."""
887
+ import numpy as np
888
+
889
+ idx = out / "realtime" / f"{session}.idx"
890
+ if not idx.exists():
891
+ return None
892
+ rows = [line.split() for line in idx.read_text().splitlines()]
893
+ if not rows or len({r[2] for r in rows}) != 1:
894
+ return None
895
+ fmt, raw = rows[0][2], (out / "realtime" / f"{session}.raw").read_bytes()
896
+ per = 6 if fmt == "pcm16" else 1 # bytes per 8 kHz sample
897
+ sizes = [int(r[1]) for r in rows]
898
+ starts = np.repeat(
899
+ np.array([int(r[0]) for r in rows], dtype=np.int64), [n // per for n in sizes]
900
+ )
901
+ each = statistics.median(n / per / 8 for n in sizes)
902
+ if fmt == "pcm16":
903
+ pcm24 = np.frombuffer(raw[: sum(sizes) // 2 * 2], dtype="<i2")
904
+ return _to8k(np, pcm24), starts, pcm24, each
905
+ law = g711.PCMU if fmt == "pcmu" else g711.PCMA
906
+ pcm = np.array(g711.decode(law, raw[: sum(sizes)]), dtype=np.float64)
907
+ return pcm, starts, np.zeros(0, dtype="<i2"), each
908
+
909
+
910
+ def _arrivals(out: Path, session: str) -> tuple[int, int] | None:
911
+ """When a session's first and last recorded appends arrived."""
912
+ idx = out / "realtime" / f"{session}.idx"
913
+ rows = idx.read_text().splitlines() if idx.exists() else []
914
+ return (int(rows[0].split()[0]), int(rows[-1].split()[0])) if rows else None
915
+
916
+
917
+ def uplink(out: Path) -> str:
918
+ """The caller's lines in the latency and barge-in calls as this engine received them
919
+ (`heard` against what the caller sent, from its capture), per profile."""
920
+ import numpy as np
921
+
922
+ from rtpbench import analyze, cases
923
+
924
+ records = [json.loads(line) for line in (out / "realtime.jsonl").read_text().splitlines()]
925
+ sessions = [r["session"] for r in records if r.get("event") == "connect"]
926
+ spans_of = {s: span for s in sessions if (span := _arrivals(out, s)) is not None}
927
+ held: tuple[str, Any] = ("", None) # one session decoded at a time: they run to hundreds of MB
928
+ by: dict[str, list[dict[str, Any]]] = {}
929
+ for path in sorted(out.glob("*/*/*/caller.json")):
930
+ if path.parent.name not in ("latency", "barge-in"):
931
+ continue
932
+ w = cases.load(path.parent, path.parts[-4])
933
+ up = [(t, p) for t, p in w.up if (p.pt, p.ssrc) == (w.log["pt"], w.log["ssrc"])]
934
+ if not up:
935
+ continue
936
+ st = analyze.stream(up, w.log["pt"])
937
+ for turn in w.log["turns"]:
938
+ for spans in (turn.get("prompt_speech"), turn["speech"]):
939
+ if not spans:
940
+ continue
941
+ a, b = st.index(w.log["seq0"], spans[0][0]), st.index(w.log["seq0"], spans[-1][1])
942
+ said = st.time(a, True)
943
+ if said is None:
944
+ continue
945
+ session = next((s for s, (t0, t1) in spans_of.items() if t0 <= said <= t1), None)
946
+ if session is None:
947
+ continue
948
+ if held[0] != session:
949
+ held = session, recording(out, session)
950
+ if held[1] is None:
951
+ continue
952
+ pcm, starts, pcm24, each = held[1]
953
+ k = max(0, int(np.searchsorted(starts, said)) - 1600) # from 200 ms before
954
+ rx = pcm[k : k + b - a + 24000].tolist()
955
+ h = heard([float(s) for s in st.pcm[a:b]], rx, [True] * len(rx))
956
+ if h is None:
957
+ continue
958
+ at = k + h["at"]
959
+ if len(pcm24):
960
+ x = np.asarray(pcm24[3 * at : 3 * (at + b - a)], dtype=np.float64)
961
+ f = np.fft.rfftfreq(len(x), 1 / 24000)
962
+ p = np.abs(np.fft.rfft(x * np.hanning(len(x)))) ** 2
963
+ mid = p[(f >= 300) & (f < 3400)].sum()
964
+ h["above_db"] = 10 * math.log10(p[f >= 4000].sum() / mid)
965
+ h["full"] = int(np.sum(np.abs(x) >= 32767))
966
+ else:
967
+ x = np.asarray(pcm[at : at + b - a])
968
+ h["full"] = int(np.sum(np.abs(x) >= 32000))
969
+ h["append_ms"] = each
970
+ by.setdefault(path.parts[-4], []).append(h)
971
+ if not by:
972
+ return ""
973
+ rows = []
974
+ for profile, hs in by.items():
975
+ above = [h["above_db"] for h in hs if "above_db" in h]
976
+ full = [h["full"] for h in hs if h["full"]]
977
+ miss = {k: sum(h["missing"][k] for h in hs) for k in hs[0]["missing"]}
978
+ lost = miss["silence"] + miss["repeat"] + miss["other"]
979
+ kinds = ", ".join(f"{miss[k]} {k}" for k in ("silence", "repeat", "other") if miss[k])
980
+ rows.append(
981
+ [
982
+ profile,
983
+ str(len(hs)),
984
+ *_audio_cells(hs),
985
+ cases._spread(above, "+.1f") if above else "",
986
+ f"{len(full)}, up to {max(full)} samples" if full else "0",
987
+ f"{100 * lost / max(1, miss['frames']):.1f}%" + (f": {kinds}" if kinds else ""),
988
+ cases._spread([h["append_ms"] for h in hs], ".0f"),
989
+ ]
990
+ )
991
+ head = [
992
+ "profile",
993
+ "lines",
994
+ "unchanged",
995
+ "distortion, dB",
996
+ "moved",
997
+ "gain, dB",
998
+ "3.4-4 kHz, dB",
999
+ "above 4 kHz, dB",
1000
+ "full scale",
1001
+ "missing 20 ms",
1002
+ "appends, ms",
1003
+ ]
1004
+ return (
1005
+ "The caller's lines in the latency and barge-in calls as the mock engine received them, "
1006
+ "aligned piece by piece with what the caller sent (its own capture, before the network), "
1007
+ "median [min, max] over lines; the columns as for the engine's audio above. Lost packets "
1008
+ "are the target's to fill: what it filled them with counts as distortion, and what it "
1009
+ "left out as moved. above 4 kHz: energy above 4 kHz against 300-3400 Hz in 24 kHz audio "
1010
+ "(the caller sends none). full scale: lines with samples at full scale. missing 20 ms: the "
1011
+ "caller's voiced 20 ms with nothing like them within 25 ms in what the engine got, and "
1012
+ "what came in their place: silence, the 20 ms before again (repeat), or other (also "
1013
+ "the next audio, where a packet was left out: see moved). appends: the audio each "
1014
+ "append carried, the median of each call's.\n\n" + analyze._table(head, rows)
1015
+ )