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/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
|
+
)
|