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/caller.py
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
"""Caller: speaks fixed turns (synthetic or from WAV files) over a real SIP call, or, for
|
|
2
|
+
barge-in, reacts to the agent: prompt, wait for the reply, cut in, wait for quiet."""
|
|
3
|
+
|
|
4
|
+
import bisect
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import secrets
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from rtpbench import bargein, g711, sip, speech, srtp, tls
|
|
17
|
+
from rtpbench.clock import CLOCK
|
|
18
|
+
from rtpbench.rtp import FRAME, PTIME_NS, SAMPLE_NS, Frame, Packet, Receiver, Sender
|
|
19
|
+
from rtpbench.voice import GATE, RATE, voice
|
|
20
|
+
|
|
21
|
+
LEAD_SECONDS = 1.0
|
|
22
|
+
UTTERANCE_RANGE = (0.8, 1.6)
|
|
23
|
+
GAP_SECONDS = 2.8 # longest delay (1.3 s) + response (0.8 s) + room for netem
|
|
24
|
+
QUIET_NS = 3_000_000_000 # agent silence that ends its turn (a real agent answers a barge-in)
|
|
25
|
+
REPLY_TIMEOUT_NS = 15_000_000_000
|
|
26
|
+
TURN_TIMEOUT_NS = 60_000_000_000
|
|
27
|
+
# The caller binds its SIP and RTP sockets from the advertised port on (RTPBENCH_ADVERTISE,
|
|
28
|
+
# HOST:PORT), so the router can forward them: a transfer's two calls take four. ADVERTISE_PORT is
|
|
29
|
+
# the first when the router's own address on the agent's network is advertised.
|
|
30
|
+
ADVERTISE_PORT, ADVERTISE_PORTS = 40000, 8
|
|
31
|
+
NO_MEDIA = (
|
|
32
|
+
"an agent that sends its audio to the address in our offer, not back to where ours comes "
|
|
33
|
+
"from, needs that address reachable: see rtpbench target --network and --advertise"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
Clip = tuple[str, list[int]]
|
|
37
|
+
Source = Callable[[int, int], bytes | Frame | None]
|
|
38
|
+
Labelled = tuple[str, list[int], list[tuple[int, int]]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def speech_utterances(seed: int) -> list[Clip]:
|
|
42
|
+
"""Every short corpus clip, in a seeded order unlike the agent's replies."""
|
|
43
|
+
clips = [(n, pcm) for n, pcm in speech.clips("tts-", "human-").items() if "-long" not in n]
|
|
44
|
+
random.Random(seed + 1).shuffle(clips)
|
|
45
|
+
return clips
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def interjections(seed: int) -> dict[str, list[Clip]]:
|
|
49
|
+
"""Corpus clips for each interjection kind, shuffled."""
|
|
50
|
+
out = {
|
|
51
|
+
"interrupt": speech_utterances(seed + 1),
|
|
52
|
+
"backchannel": [(n, p) for n, p in speech.clips("tts-").items() if n.endswith("-bc")],
|
|
53
|
+
"cough": list(speech.clips("cough-").items()),
|
|
54
|
+
}
|
|
55
|
+
for clips in out.values():
|
|
56
|
+
random.Random(seed).shuffle(clips)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def script(
|
|
61
|
+
turns: int,
|
|
62
|
+
seed: int,
|
|
63
|
+
utterances: list[list[int]] | None = None,
|
|
64
|
+
lead: float = LEAD_SECONDS,
|
|
65
|
+
gap: float = GAP_SECONDS,
|
|
66
|
+
) -> tuple[list[int], list[tuple[int, int]]]:
|
|
67
|
+
"""PCM for the whole call, and the [start, end) sample span of each turn's utterance."""
|
|
68
|
+
rng = random.Random(seed)
|
|
69
|
+
pcm, spans = [0] * round(lead * RATE), []
|
|
70
|
+
for i in range(turns):
|
|
71
|
+
start = len(pcm)
|
|
72
|
+
pcm += (
|
|
73
|
+
utterances[i % len(utterances)]
|
|
74
|
+
if utterances
|
|
75
|
+
else voice(rng.uniform(*UTTERANCE_RANGE), f0=140)
|
|
76
|
+
)
|
|
77
|
+
spans.append((start, len(pcm)))
|
|
78
|
+
pcm += [0] * round(gap * RATE)
|
|
79
|
+
return pcm, spans
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _udp(port: int) -> socket.socket:
|
|
83
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
84
|
+
try:
|
|
85
|
+
sock.bind(("0.0.0.0", port))
|
|
86
|
+
except OSError:
|
|
87
|
+
sock.close()
|
|
88
|
+
raise
|
|
89
|
+
return sock
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _bind[S](make: Callable[[int], S], ports: range) -> S:
|
|
93
|
+
"""A socket from `make` on the first of `ports` it can bind."""
|
|
94
|
+
for port in ports:
|
|
95
|
+
try:
|
|
96
|
+
return make(port)
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
raise sip.SipError(f"no free port in {ports.start}-{ports.stop - 1}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_uri(uri: str) -> tuple[str, int]:
|
|
103
|
+
hostport = uri.split("@", 1)[-1].split(";", 1)[0]
|
|
104
|
+
scheme, sep, rest = hostport.partition(":")
|
|
105
|
+
if sep and scheme.lower() in ("sip", "sips"): # no user part; schemes ignore case
|
|
106
|
+
hostport = rest
|
|
107
|
+
host, _, port = hostport.partition(":")
|
|
108
|
+
return host, int(port or (sip.TLS_PORT if sip.secure(uri) else 5060))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _tls(port: int, hostname: str) -> sip.Tls:
|
|
112
|
+
"""SIP over TLS from `port`, checking `hostname`'s certificate against the system's trusted
|
|
113
|
+
certificates, or against RTPBENCH_TLS/ca.pem if there is one; with RTPBENCH_TLS/server.crt
|
|
114
|
+
(and .key) it also takes connections the far end opens. The contexts log their session
|
|
115
|
+
keys to SSLKEYLOGFILE, which judging needs to read the capture."""
|
|
116
|
+
where = Path(os.environ.get("RTPBENCH_TLS", "/nonexistent"))
|
|
117
|
+
ca = where / "ca.pem"
|
|
118
|
+
client = ssl.create_default_context(cafile=ca if ca.exists() else None)
|
|
119
|
+
client.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
120
|
+
client.set_ciphers(tls.CIPHERS_12)
|
|
121
|
+
server = None
|
|
122
|
+
if (where / "server.crt").exists():
|
|
123
|
+
server = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
124
|
+
server.load_cert_chain(where / "server.crt", where / "server.key")
|
|
125
|
+
return sip.Tls(port, client, server, hostname)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Session:
|
|
129
|
+
"""One call: the SIP dialog and the RTP socket, from INVITE to BYE (sent to the URI's host,
|
|
130
|
+
or to `proxy`, a host[:port]; over UDP, or TCP with `tcp`).
|
|
131
|
+
|
|
132
|
+
The INVITE goes out at once; `wait` pumps SIP (retransmissions, answers to what the far end
|
|
133
|
+
sends) until a condition holds, so a script can act between messages while media flows.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
uri: str,
|
|
139
|
+
codecs: tuple[int, ...],
|
|
140
|
+
headers: list[tuple[str, str]] | None = None,
|
|
141
|
+
dtmf: bool = False,
|
|
142
|
+
proxy: str | None = None,
|
|
143
|
+
tcp: bool = False,
|
|
144
|
+
) -> None:
|
|
145
|
+
host, port = parse_uri(proxy or uri) # an outbound proxy takes it whatever its URI
|
|
146
|
+
# hosts as the router resolved them (RTPBENCH_RESOLVE, HOST=IP,...): a name on the
|
|
147
|
+
# agent's Docker network resolves only there, and netem needs the same address
|
|
148
|
+
names = os.environ.get("RTPBENCH_RESOLVE", "").split(",")
|
|
149
|
+
resolved = dict(n.split("=", 1) for n in names if "=" in n)
|
|
150
|
+
peer = (resolved.get(host, host), port)
|
|
151
|
+
if advertise := os.environ.get("RTPBENCH_ADVERTISE"):
|
|
152
|
+
local_ip, _, first = advertise.rpartition(":")
|
|
153
|
+
ports = range(int(first), int(first) + ADVERTISE_PORTS)
|
|
154
|
+
else:
|
|
155
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
156
|
+
probe.connect(peer)
|
|
157
|
+
local_ip, ports = probe.getsockname()[0], range(1) # port 0: any
|
|
158
|
+
sock: socket.socket | sip.Tcp
|
|
159
|
+
hop = sip.transport(proxy or uri) # the next hop's
|
|
160
|
+
tcp = tcp or hop == "TCP"
|
|
161
|
+
if hop == "TLS":
|
|
162
|
+
sock = _bind(lambda p: _tls(p, host), ports)
|
|
163
|
+
else:
|
|
164
|
+
sock = _bind(sip.Tcp, ports) if tcp else _bind(_udp, ports)
|
|
165
|
+
self.rtp = _bind(_udp, ports)
|
|
166
|
+
self.dialog = sip.Dialog.uac(sock, peer, uri, local_ip)
|
|
167
|
+
self.origin = (secrets.randbelow(1 << 31), 0) # SDP session id, version
|
|
168
|
+
# SRTP (RTPBENCH_SRTP: the suites to offer, in order): our master key per suite, and
|
|
169
|
+
# once answered, the suite and the contexts we send and hear with
|
|
170
|
+
self.offered = [s for s in os.environ.get("RTPBENCH_SRTP", "").split(",") if s]
|
|
171
|
+
self.keys = srtp.master() if self.offered else {}
|
|
172
|
+
self.suite: str | None = None
|
|
173
|
+
self.tx: srtp.Context | None = None
|
|
174
|
+
self.rx: srtp.Context | None = None
|
|
175
|
+
self.srtp_error = ""
|
|
176
|
+
self.held = False
|
|
177
|
+
self.dtmf: int | None = None # telephone-event payload type, if the answer has it
|
|
178
|
+
port = self.rtp.getsockname()[1]
|
|
179
|
+
offer = sip.sdp(
|
|
180
|
+
local_ip, port, list(codecs), origin=self.origin, dtmf=dtmf, crypto=self._crypto()
|
|
181
|
+
)
|
|
182
|
+
self.headers = headers
|
|
183
|
+
user, password = (os.environ.get(f"RTPBENCH_SIP_{k}") for k in ("USER", "PASSWORD"))
|
|
184
|
+
self.credentials = (user, password) if user and password else None
|
|
185
|
+
self.invite = self.dialog.request("INVITE", offer, headers)
|
|
186
|
+
self.invites = {self.invite.branch: self.invite}
|
|
187
|
+
self.final: sip.Message | None = None # final response to the INVITE
|
|
188
|
+
self.responses: list[sip.Message] = []
|
|
189
|
+
self.requests: list[sip.Message] = [] # from the far end
|
|
190
|
+
# methods whose next request from the far end goes unanswered, as if lost; those it lost
|
|
191
|
+
self.lose: set[str] = set()
|
|
192
|
+
self.lost: list[sip.Message] = []
|
|
193
|
+
self.remote_bye = False
|
|
194
|
+
self.addr, self.pt = ("", 0), 0
|
|
195
|
+
self.seq0, self.ssrc = secrets.randbelow(1 << 16), secrets.randbelow(1 << 32)
|
|
196
|
+
self.media: tuple[Sender, Receiver] | None = None
|
|
197
|
+
try:
|
|
198
|
+
self.dialog.send(self.invite)
|
|
199
|
+
except OSError as e: # over TCP the caller may retry over UDP; over TLS it cannot
|
|
200
|
+
if hop == "TLS":
|
|
201
|
+
raise sip.SipError(f"TLS to {peer[0]}:{peer[1]}: {type(e).__name__}: {e}") from e
|
|
202
|
+
how = {
|
|
203
|
+
TimeoutError: "connection timed out",
|
|
204
|
+
ConnectionRefusedError: "connection refused",
|
|
205
|
+
}
|
|
206
|
+
if not tcp or type(e) not in how:
|
|
207
|
+
raise
|
|
208
|
+
raise ConnectionError(f"no TCP listener on port {peer[1]} ({how[type(e)]})") from e
|
|
209
|
+
|
|
210
|
+
def pump(self, timeout: float) -> None:
|
|
211
|
+
"""Handle the next SIP message, if one comes within `timeout`."""
|
|
212
|
+
msg = self.dialog.recv(timeout)
|
|
213
|
+
if msg is None or msg.get("call-id") != self.dialog.call_id:
|
|
214
|
+
return
|
|
215
|
+
if not msg.status:
|
|
216
|
+
self._incoming(msg)
|
|
217
|
+
return
|
|
218
|
+
self.responses.append(msg)
|
|
219
|
+
if msg.cseq_method != "INVITE" or msg.status < 200:
|
|
220
|
+
return
|
|
221
|
+
initial = msg.branch == self.invite.branch
|
|
222
|
+
if msg.status >= 300:
|
|
223
|
+
if req := self.invites.get(msg.branch):
|
|
224
|
+
self.dialog.ack_failure(req, msg)
|
|
225
|
+
if initial and (
|
|
226
|
+
again := self._authorize(self.invite, msg, self.headers)
|
|
227
|
+
or self._interval(self.invite, msg)
|
|
228
|
+
):
|
|
229
|
+
self.invite = again
|
|
230
|
+
elif initial:
|
|
231
|
+
self.final = msg
|
|
232
|
+
return
|
|
233
|
+
if not initial:
|
|
234
|
+
req = self.invites.get(msg.branch)
|
|
235
|
+
if req is not None and req.body and self.offered: # the answer to our re-offer
|
|
236
|
+
self._srtp(sip.parse_sdp(msg.body))
|
|
237
|
+
self.dialog.ack(msg, self._answer(msg) if req is not None and not req.body else b"")
|
|
238
|
+
return
|
|
239
|
+
self.dialog.confirm(msg) # every 2xx, retransmissions included
|
|
240
|
+
if self.final is None:
|
|
241
|
+
self.final = msg
|
|
242
|
+
answer = sip.parse_sdp(msg.body)
|
|
243
|
+
self.addr = (answer.ip, answer.port)
|
|
244
|
+
self.pt = next((p for p in answer.pts if p in g711.NAMES), -1) # -1: answer() ends it
|
|
245
|
+
self.dtmf = answer.dtmf
|
|
246
|
+
if self.offered:
|
|
247
|
+
try:
|
|
248
|
+
self._srtp(answer)
|
|
249
|
+
except sip.SipError as e: # answer() ends it
|
|
250
|
+
self.srtp_error = str(e)
|
|
251
|
+
|
|
252
|
+
def _crypto(self, offer: sip.Sdp | None = None) -> list[str] | None:
|
|
253
|
+
"""a=crypto values for our next SDP, if we use SRTP: as an offer, every suite we offer
|
|
254
|
+
(the one in use, once there is one); as the answer to `offer`, its line for our suite."""
|
|
255
|
+
if not self.offered:
|
|
256
|
+
return None
|
|
257
|
+
if offer is None:
|
|
258
|
+
suites = [self.suite] if self.suite else self.offered
|
|
259
|
+
return [srtp.line(i + 1, s, self.keys[s]) for i, s in enumerate(suites)]
|
|
260
|
+
tag = self._srtp(offer)
|
|
261
|
+
assert self.suite is not None
|
|
262
|
+
return [srtp.line(tag, self.suite, self.keys[self.suite])]
|
|
263
|
+
|
|
264
|
+
def _srtp(self, sdp: sip.Sdp) -> int:
|
|
265
|
+
"""Take the agent's key from its SDP for the suite in use (or the first we offered that
|
|
266
|
+
it gives): we hear with it from now on. Its tag; SipError if it gives none of ours."""
|
|
267
|
+
usable = []
|
|
268
|
+
for value in sdp.crypto:
|
|
269
|
+
try:
|
|
270
|
+
usable.append(srtp.parse(value))
|
|
271
|
+
except ValueError:
|
|
272
|
+
continue
|
|
273
|
+
wanted = [self.suite] if self.suite else self.offered
|
|
274
|
+
tag, suite, key = next((c for c in usable if c[1] in wanted), (0, "", b""))
|
|
275
|
+
if not suite:
|
|
276
|
+
given = ", ".join(sdp.crypto) or f"no a=crypto, {sdp.proto}"
|
|
277
|
+
raise sip.SipError(
|
|
278
|
+
f"the agent's SDP has no SRTP suite of {', '.join(wanted)} ({given})"
|
|
279
|
+
)
|
|
280
|
+
if self.tx is None:
|
|
281
|
+
self.suite, self.tx = suite, srtp.Context(suite, self.keys[suite])
|
|
282
|
+
if self.rx is None or self.rx.master != key:
|
|
283
|
+
self.rx = srtp.Context(suite, key, self.rx.state if self.rx else None)
|
|
284
|
+
if self.media:
|
|
285
|
+
self.media[1].srtp = self.rx
|
|
286
|
+
return tag
|
|
287
|
+
|
|
288
|
+
def _authorize(
|
|
289
|
+
self,
|
|
290
|
+
req: sip.Message,
|
|
291
|
+
resp: sip.Message,
|
|
292
|
+
headers: list[tuple[str, str]] | None,
|
|
293
|
+
content_type: str = "application/sdp",
|
|
294
|
+
) -> sip.Message | None:
|
|
295
|
+
"""Send `req` again (a new CSeq) with credentials for the challenge in `resp`, if it is
|
|
296
|
+
one (401, 407), we have credentials (RTPBENCH_SIP_USER, RTPBENCH_SIP_PASSWORD) and `req`
|
|
297
|
+
did not carry them already; the new request."""
|
|
298
|
+
if resp.status not in (401, 407) or self.credentials is None:
|
|
299
|
+
return None
|
|
300
|
+
if req.get("authorization") or req.get("proxy-authorization"):
|
|
301
|
+
return None # these credentials were refused
|
|
302
|
+
name = "Authorization" if resp.status == 401 else "Proxy-Authorization"
|
|
303
|
+
asked = resp.all("www-authenticate" if resp.status == 401 else "proxy-authenticate")
|
|
304
|
+
value = sip.digest(asked, req.method, req.first.split()[1], *self.credentials)
|
|
305
|
+
hdrs = [*(headers or []), (name, value)]
|
|
306
|
+
again = self.dialog.request(req.method, req.body, hdrs, content_type)
|
|
307
|
+
if again.method == "INVITE":
|
|
308
|
+
self.invites[again.branch] = again
|
|
309
|
+
self.dialog.send(again)
|
|
310
|
+
return again
|
|
311
|
+
|
|
312
|
+
def _interval(self, req: sip.Message, resp: sip.Message) -> sip.Message | None:
|
|
313
|
+
"""Send the INVITE `req` again (a new CSeq) asking for the session interval the agent's
|
|
314
|
+
422 names (Min-SE), if it names one above what `req` asked (RFC 4028 6); the new
|
|
315
|
+
request."""
|
|
316
|
+
least, asked = resp.get("min-se").split(";")[0].strip(), req.get("session-expires")
|
|
317
|
+
number = asked.split(";")[0].strip()
|
|
318
|
+
if resp.status != 422 or not least.isdigit() or not number.isdigit():
|
|
319
|
+
return None
|
|
320
|
+
if int(least) <= int(number):
|
|
321
|
+
return None
|
|
322
|
+
kept = [
|
|
323
|
+
(k, v) for k, v in self.headers or [] if k.lower() not in ("session-expires", "min-se")
|
|
324
|
+
]
|
|
325
|
+
self.headers = [*kept, ("Session-Expires", least + asked[len(number) :]), ("Min-SE", least)]
|
|
326
|
+
again = self.dialog.request("INVITE", req.body, self.headers)
|
|
327
|
+
self.invites[again.branch] = again
|
|
328
|
+
self.dialog.send(again)
|
|
329
|
+
return again
|
|
330
|
+
|
|
331
|
+
def _answer(self, ok: sip.Message) -> bytes:
|
|
332
|
+
"""Answer to the offer in a 2xx to a re-INVITE without one (RFC 3261 14.1): the call's
|
|
333
|
+
codec if offered (else the first G.711 offered), the address it gives from now on."""
|
|
334
|
+
offer = sip.parse_sdp(ok.body)
|
|
335
|
+
if not offer.port:
|
|
336
|
+
return b"" # no offer in it: the agent got it wrong; the ACK has nothing to answer
|
|
337
|
+
if self.pt not in offer.pts:
|
|
338
|
+
self.pt = next((p for p in offer.pts if p in g711.NAMES), self.pt)
|
|
339
|
+
self.addr = (offer.ip, offer.port)
|
|
340
|
+
if self.media:
|
|
341
|
+
self.media[0].addr = self.addr
|
|
342
|
+
port = self.rtp.getsockname()[1]
|
|
343
|
+
direction = sip.ANSWER[offer.direction]
|
|
344
|
+
crypto = self._crypto(offer)
|
|
345
|
+
return sip.sdp(self.dialog.local_ip, port, [self.pt], direction, self.origin, crypto=crypto)
|
|
346
|
+
|
|
347
|
+
def _incoming(self, req: sip.Message) -> None:
|
|
348
|
+
if req.method in self.lose:
|
|
349
|
+
self.lose.discard(req.method)
|
|
350
|
+
self.lost.append(req)
|
|
351
|
+
return
|
|
352
|
+
self.requests.append(req)
|
|
353
|
+
if req.method == "ACK":
|
|
354
|
+
if self.offered and req.body: # the answer to our offer in a 2xx: its key
|
|
355
|
+
self._srtp(sip.parse_sdp(req.body))
|
|
356
|
+
return
|
|
357
|
+
if req.method == "INVITE": # a refresh, or a change we take as it comes
|
|
358
|
+
port = self.rtp.getsockname()[1]
|
|
359
|
+
try:
|
|
360
|
+
crypto = self._crypto(sip.parse_sdp(req.body) if req.body else None)
|
|
361
|
+
except sip.SipError: # SRTP without the suite in use: the session stays as it is
|
|
362
|
+
self.dialog.reply(req, 488, "Not Acceptable Here")
|
|
363
|
+
return
|
|
364
|
+
sdp = sip.sdp(self.dialog.local_ip, port, [self.pt], origin=self.origin, crypto=crypto)
|
|
365
|
+
self.dialog.reply(req, 200, "OK", sdp)
|
|
366
|
+
return
|
|
367
|
+
self.dialog.reply(req, *((202, "Accepted") if req.method == "REFER" else ()))
|
|
368
|
+
self.remote_bye |= req.method == "BYE"
|
|
369
|
+
|
|
370
|
+
def wait(self, cond: Callable[[], bool], timeout: float) -> bool:
|
|
371
|
+
"""Pump SIP until `cond()` or `timeout` seconds; whether `cond()` held."""
|
|
372
|
+
deadline = time.monotonic() + timeout
|
|
373
|
+
while not cond():
|
|
374
|
+
if (left := deadline - time.monotonic()) <= 0:
|
|
375
|
+
return False
|
|
376
|
+
self.pump(min(left, 0.05))
|
|
377
|
+
return True
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def ringing(self) -> bool:
|
|
381
|
+
"""A provisional response to the INVITE arrived (so it may be cancelled)."""
|
|
382
|
+
return any(r.branch == self.invite.branch and r.status < 200 for r in self.responses)
|
|
383
|
+
|
|
384
|
+
def cancel(self) -> None:
|
|
385
|
+
self.dialog.cancel(self.invite)
|
|
386
|
+
|
|
387
|
+
def answer(self, timeout: float = 32.0) -> sip.Message:
|
|
388
|
+
"""The 2xx to the INVITE (ACKed); raises on a failure, no answer, or an answer without
|
|
389
|
+
G.711 (after hanging up)."""
|
|
390
|
+
self.wait(lambda: self.final is not None, timeout)
|
|
391
|
+
if self.final is None:
|
|
392
|
+
raise sip.SipError("INVITE timed out")
|
|
393
|
+
if self.final.status >= 300:
|
|
394
|
+
raise sip.SipError(self.final.first)
|
|
395
|
+
if self.srtp_error:
|
|
396
|
+
self.request("BYE")
|
|
397
|
+
raise sip.SipError(self.srtp_error)
|
|
398
|
+
if self.pt < 0:
|
|
399
|
+
self.request("BYE")
|
|
400
|
+
pts = " ".join(map(str, sip.parse_sdp(self.final.body).pts))
|
|
401
|
+
raise sip.SipError(f"answer has no G.711 format (payload types {pts or 'none'})")
|
|
402
|
+
return self.final
|
|
403
|
+
|
|
404
|
+
def request(
|
|
405
|
+
self,
|
|
406
|
+
method: str,
|
|
407
|
+
body: bytes = b"",
|
|
408
|
+
headers: list[tuple[str, str]] | None = None,
|
|
409
|
+
content_type: str = "application/sdp",
|
|
410
|
+
) -> sip.Message | None:
|
|
411
|
+
"""Send an in-dialog request; its final response, or None after timer F (32 s)."""
|
|
412
|
+
req = self.dialog.request(method, body, headers, content_type)
|
|
413
|
+
if method == "INVITE":
|
|
414
|
+
self.invites[req.branch] = req
|
|
415
|
+
self.dialog.send(req)
|
|
416
|
+
|
|
417
|
+
def final() -> sip.Message | None:
|
|
418
|
+
return next(
|
|
419
|
+
(r for r in self.responses if r.branch == req.branch and r.status >= 200), None
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
self.wait(lambda: final() is not None, 64 * sip.T1)
|
|
423
|
+
if (resp := final()) is not None and (
|
|
424
|
+
again := self._authorize(req, resp, headers, content_type)
|
|
425
|
+
):
|
|
426
|
+
req = again
|
|
427
|
+
self.wait(lambda: final() is not None, 64 * sip.T1)
|
|
428
|
+
return final()
|
|
429
|
+
|
|
430
|
+
def reinvite(
|
|
431
|
+
self,
|
|
432
|
+
direction: str = "sendrecv",
|
|
433
|
+
headers: list[tuple[str, str]] | None = None,
|
|
434
|
+
conn: str | None = None,
|
|
435
|
+
offer: bool = True,
|
|
436
|
+
pt: int | None = None,
|
|
437
|
+
) -> sip.Message | None:
|
|
438
|
+
"""Offer the call's codec again, `direction` (sendonly, inactive: hold; sendrecv
|
|
439
|
+
unchanged: a refresh, same SDP version; "": no attribute); `conn` 0.0.0.0 holds the RFC
|
|
440
|
+
2543 way; `pt` offers that codec alone instead (a codec change: the media switches to it
|
|
441
|
+
on a 2xx); `offer` False sends no SDP (the 2xx offers, the ACK answers). The response."""
|
|
442
|
+
hold = direction not in ("sendrecv", "") or conn == "0.0.0.0"
|
|
443
|
+
if hold or self.held or pt is not None:
|
|
444
|
+
self.origin = (self.origin[0], self.origin[1] + 1)
|
|
445
|
+
self.held = hold
|
|
446
|
+
port = self.rtp.getsockname()[1]
|
|
447
|
+
codec = self.pt if pt is None else pt
|
|
448
|
+
sdp = sip.sdp(
|
|
449
|
+
self.dialog.local_ip,
|
|
450
|
+
port,
|
|
451
|
+
[codec],
|
|
452
|
+
direction,
|
|
453
|
+
self.origin,
|
|
454
|
+
conn=conn,
|
|
455
|
+
crypto=self._crypto(),
|
|
456
|
+
)
|
|
457
|
+
resp = self.request("INVITE", sdp if offer else b"", headers)
|
|
458
|
+
if pt is not None and resp is not None and resp.status < 300:
|
|
459
|
+
self.pt = pt
|
|
460
|
+
if self.media:
|
|
461
|
+
self.media[0].pt = pt
|
|
462
|
+
return resp
|
|
463
|
+
|
|
464
|
+
def start(self, source: Source, sink: Callable[[int, Packet], None]) -> None:
|
|
465
|
+
start = CLOCK.now() + PTIME_NS
|
|
466
|
+
self.media = (
|
|
467
|
+
Sender(self.rtp, self.addr, self.pt, source, start, self.seq0, self.ssrc, srtp=self.tx),
|
|
468
|
+
Receiver(self.rtp, sink, srtp=self.rx),
|
|
469
|
+
)
|
|
470
|
+
for t in self.media:
|
|
471
|
+
t.start()
|
|
472
|
+
|
|
473
|
+
def switch(self) -> None:
|
|
474
|
+
"""The caller's audio becomes a new RTP source mid-call, as when a gateway upstream swaps
|
|
475
|
+
media (random SSRC, seq and timestamp origin, marker); `meta` describes the new one."""
|
|
476
|
+
assert self.media is not None
|
|
477
|
+
sender = self.media[0]
|
|
478
|
+
ssrc, seq = secrets.randbelow(1 << 32), secrets.randbelow(1 << 16)
|
|
479
|
+
sender.switch(ssrc, seq, secrets.randbelow(1 << 32))
|
|
480
|
+
self.wait(lambda: sender.switched is not None, 1.0)
|
|
481
|
+
assert sender.switched is not None
|
|
482
|
+
# sample k of the call (from tick 0) is still in the packet with seq seq0 + k // 160
|
|
483
|
+
self.ssrc, self.seq0 = ssrc, (seq - sender.switched) & 0xFFFF
|
|
484
|
+
|
|
485
|
+
def hangup(self) -> None:
|
|
486
|
+
"""Stop sending, BYE unless the far end hung up, stop receiving."""
|
|
487
|
+
if self.media:
|
|
488
|
+
self.media[0].stop.set()
|
|
489
|
+
self.media[0].join()
|
|
490
|
+
if not self.remote_bye:
|
|
491
|
+
self.request("BYE")
|
|
492
|
+
if self.media:
|
|
493
|
+
self.media[1].stop.set()
|
|
494
|
+
self.media[1].join()
|
|
495
|
+
|
|
496
|
+
def run(
|
|
497
|
+
self,
|
|
498
|
+
source: Source,
|
|
499
|
+
sink: Callable[[int, Packet], None],
|
|
500
|
+
until: Callable[[int], bool],
|
|
501
|
+
) -> bool:
|
|
502
|
+
"""Media until `until(now_ns)`, then hang up. True if the far end hung up first."""
|
|
503
|
+
self.start(source, sink)
|
|
504
|
+
self.wait(lambda: self.remote_bye or until(CLOCK.now()), float("inf"))
|
|
505
|
+
self.hangup()
|
|
506
|
+
return self.remote_bye
|
|
507
|
+
|
|
508
|
+
def no_media(self, seconds: float) -> sip.SipError:
|
|
509
|
+
return sip.SipError(
|
|
510
|
+
f"no RTP from the agent in {seconds:.0f} s; our offer gave"
|
|
511
|
+
f" c=IN IP4 {self.dialog.local_ip}: {NO_MEDIA}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def meta(self, seed: int, remote_bye: bool) -> dict[str, Any]:
|
|
515
|
+
return {"pt": self.pt, "ssrc": self.ssrc, "seq0": self.seq0, "seed": seed} | {
|
|
516
|
+
"remote_bye": remote_bye,
|
|
517
|
+
"call_id": self.dialog.call_id,
|
|
518
|
+
"rtp_port": self.rtp.getsockname()[1],
|
|
519
|
+
"transport": self.dialog.transport,
|
|
520
|
+
**({"srtp": self.suite} if self.offered else {}),
|
|
521
|
+
**({"tls": sock.negotiated()} if isinstance(sock := self.dialog.sock, sip.Tls) else {}),
|
|
522
|
+
"send_errors": self.media[0].send_errors if self.media else [],
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def call(
|
|
527
|
+
uri: str,
|
|
528
|
+
turns: int = 20,
|
|
529
|
+
seed: int = 1,
|
|
530
|
+
utterances: list[Clip] | None = None,
|
|
531
|
+
lead: float = LEAD_SECONDS,
|
|
532
|
+
gap: float = GAP_SECONDS,
|
|
533
|
+
codecs: tuple[int, ...] = (g711.PCMU, g711.PCMA),
|
|
534
|
+
) -> dict[str, Any]:
|
|
535
|
+
"""Place the call; return where each turn's speech ended (packet and sample).
|
|
536
|
+
|
|
537
|
+
With utterances, each turn also carries its labelled speech spans as sample indices of the
|
|
538
|
+
stream (sample k is sample k % 160 of the packet with seq seq0 + k // 160).
|
|
539
|
+
"""
|
|
540
|
+
labels = [(n, speech.label(u)) for n, u in utterances] if utterances else []
|
|
541
|
+
# before the call: encoding takes about a second, and the agent's first packets find no way
|
|
542
|
+
# back through a NAT until ours open it
|
|
543
|
+
pcm, spans = script(turns, seed, [u for _, u in utterances or []], lead, gap)
|
|
544
|
+
encoded = {pt: g711.encode(pt, pcm) for pt in g711.NAMES}
|
|
545
|
+
s = Session(uri, codecs)
|
|
546
|
+
s.answer()
|
|
547
|
+
audio = encoded[s.pt]
|
|
548
|
+
gate = g711.gate(s.pt, GATE)
|
|
549
|
+
silence = bytes([g711.SILENCE[s.pt]]) * FRAME
|
|
550
|
+
frames = [audio[i : i + FRAME].ljust(FRAME, silence[:1]) for i in range(0, len(audio), FRAME)]
|
|
551
|
+
last: list[dict[str, Any]] = []
|
|
552
|
+
for i, (start, end) in enumerate(spans): # last sample the gate calls active, as the agent sees
|
|
553
|
+
m = gate.search(audio[start:end][::-1])
|
|
554
|
+
assert m is not None
|
|
555
|
+
idx = end - 1 - m.start()
|
|
556
|
+
rec: dict[str, Any] = {"end_seq": (s.seq0 + idx // FRAME) & 0xFFFF, "end_off": idx % FRAME}
|
|
557
|
+
if labels:
|
|
558
|
+
clip, speech_spans = labels[i % len(labels)]
|
|
559
|
+
rec |= {"clip": clip, "speech": [[a + start, b + start] for a, b in speech_spans]}
|
|
560
|
+
last.append(rec)
|
|
561
|
+
tick0 = CLOCK.now() + PTIME_NS # as `Session.start` schedules it
|
|
562
|
+
hangup_ns = tick0 + len(frames) * PTIME_NS
|
|
563
|
+
starts = [tick0 + start * SAMPLE_NS for start, _ in spans]
|
|
564
|
+
# an agent that has not sent a packet by the end of the first turn's reply time never will
|
|
565
|
+
silent_ns = tick0 + (spans[0][1] if spans else 0) * SAMPLE_NS + REPLY_TIMEOUT_NS
|
|
566
|
+
announce = Turns(len(spans))
|
|
567
|
+
heard = threading.Event()
|
|
568
|
+
|
|
569
|
+
def until(now: int) -> bool:
|
|
570
|
+
announce(bisect.bisect_right(starts, now))
|
|
571
|
+
return (not heard.is_set() and now >= silent_ns) or now >= hangup_ns
|
|
572
|
+
|
|
573
|
+
remote_bye = s.run(
|
|
574
|
+
lambda n, _t: frames[n] if n < len(frames) else silence, lambda _a, _p: heard.set(), until
|
|
575
|
+
)
|
|
576
|
+
if not heard.is_set(): # by the deadline, or the agent hung up first
|
|
577
|
+
raise s.no_media((CLOCK.now() - tick0) / 1e9)
|
|
578
|
+
return s.meta(seed, remote_bye) | {"turns": last}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class Turns:
|
|
582
|
+
"""Prints "turn k/n" as turn k starts, for the host's progress. Called from the SIP loop
|
|
583
|
+
that waits for the call to end, so off the threads that send and receive RTP."""
|
|
584
|
+
|
|
585
|
+
def __init__(self, n: int) -> None:
|
|
586
|
+
self.n, self.k = n, 0
|
|
587
|
+
|
|
588
|
+
def __call__(self, k: int) -> None:
|
|
589
|
+
if k > self.k:
|
|
590
|
+
self.k = k
|
|
591
|
+
print(f"turn {k}/{self.n}", flush=True)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class Media:
|
|
595
|
+
"""Caller audio as an RTP source, one clip at a time, and the agent's audio as a sink: when
|
|
596
|
+
the gate last heard the agent, and each packet's first active sample."""
|
|
597
|
+
|
|
598
|
+
def __init__(self, pt: int) -> None:
|
|
599
|
+
self.pt = pt
|
|
600
|
+
self.gate = g711.gate(pt, GATE)
|
|
601
|
+
self.silence = bytes([g711.SILENCE[pt]]) * FRAME
|
|
602
|
+
self.lock = threading.Lock()
|
|
603
|
+
self.audio, self.apos = b"", 0
|
|
604
|
+
self.agent_last = 0
|
|
605
|
+
|
|
606
|
+
def on_packet(self, arrival: int, pkt: Packet) -> None:
|
|
607
|
+
if pkt.pt != self.pt or not (first := self.gate.search(pkt.payload)):
|
|
608
|
+
return
|
|
609
|
+
last = self.gate.search(pkt.payload[::-1])
|
|
610
|
+
assert last is not None
|
|
611
|
+
with self.lock:
|
|
612
|
+
end = arrival + (len(pkt.payload) - 1 - last.start()) * SAMPLE_NS
|
|
613
|
+
self.heard(arrival + first.start() * SAMPLE_NS, end)
|
|
614
|
+
self.agent_last = max(self.agent_last, end)
|
|
615
|
+
|
|
616
|
+
def recodec(self, pt: int) -> None:
|
|
617
|
+
"""Speak and listen in another G.711 law from now on (a codec change)."""
|
|
618
|
+
with self.lock:
|
|
619
|
+
self.pt, self.gate = pt, g711.gate(pt, GATE)
|
|
620
|
+
self.silence = bytes([g711.SILENCE[pt]]) * FRAME
|
|
621
|
+
|
|
622
|
+
def heard(self, onset: int, end: int) -> None:
|
|
623
|
+
"""A packet with agent speech from `onset` to `end` arrived (under the lock)."""
|
|
624
|
+
|
|
625
|
+
def step(self, n: int, tick: int) -> None:
|
|
626
|
+
"""Nothing is playing at tick n: start a clip with `play`, or not (under the lock)."""
|
|
627
|
+
|
|
628
|
+
def finished(self, tick: int) -> None:
|
|
629
|
+
"""The clip just ended (under the lock)."""
|
|
630
|
+
|
|
631
|
+
def idle(self, n: int) -> bytes | Frame | None:
|
|
632
|
+
"""Tick n's payload when no clip plays: silence, None to send nothing, or a frame of
|
|
633
|
+
another payload type (under the lock)."""
|
|
634
|
+
return self.silence
|
|
635
|
+
|
|
636
|
+
def play(self, n: int, clip: tuple[str, bytes, list[tuple[int, int]]]) -> dict[str, Any]:
|
|
637
|
+
"""Start `clip` (name, encoded audio, labelled spans) at tick n; its spans in the stream."""
|
|
638
|
+
name, audio, spans = clip
|
|
639
|
+
self.audio, self.apos = audio, 0
|
|
640
|
+
start = n * FRAME
|
|
641
|
+
return {"clip": name, "speech": [[a + start, b + start] for a, b in spans]}
|
|
642
|
+
|
|
643
|
+
def source(self, n: int, tick: int) -> bytes | Frame | None:
|
|
644
|
+
with self.lock:
|
|
645
|
+
if not self.audio:
|
|
646
|
+
self.step(n, tick)
|
|
647
|
+
if not self.audio:
|
|
648
|
+
return self.idle(n)
|
|
649
|
+
out = self.audio[self.apos : self.apos + FRAME].ljust(FRAME, self.silence[:1])
|
|
650
|
+
self.apos += FRAME
|
|
651
|
+
if self.apos >= len(self.audio):
|
|
652
|
+
self.audio = b""
|
|
653
|
+
self.finished(tick)
|
|
654
|
+
return out
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
class Conversation(Media):
|
|
658
|
+
"""Barge-in script as an RTP source: each turn a prompt, then, once the agent's reply has
|
|
659
|
+
started, an interjection `at_s` later, then quiet before the next prompt."""
|
|
660
|
+
|
|
661
|
+
def __init__(
|
|
662
|
+
self,
|
|
663
|
+
pt: int,
|
|
664
|
+
script: list[tuple[Labelled, Labelled, dict[str, Any]]],
|
|
665
|
+
lead_ns: int,
|
|
666
|
+
) -> None:
|
|
667
|
+
super().__init__(pt)
|
|
668
|
+
self.script = [
|
|
669
|
+
((pn, g711.encode(pt, p), ps), (jn, g711.encode(pt, j), js), st)
|
|
670
|
+
for (pn, p, ps), (jn, j, js), st in script
|
|
671
|
+
]
|
|
672
|
+
self.lead_ns = lead_ns
|
|
673
|
+
self.turns: list[dict[str, Any]] = []
|
|
674
|
+
self.state, self.turn = "lead", 0
|
|
675
|
+
self.t0: int | None = None
|
|
676
|
+
self.since = 0 # when the current state began
|
|
677
|
+
self.wait_until = 0
|
|
678
|
+
self.onset: int | None = None # first agent sample heard after the prompt
|
|
679
|
+
self.done = False
|
|
680
|
+
|
|
681
|
+
def heard(self, onset: int, end: int) -> None:
|
|
682
|
+
if self.state == "reply" and self.onset is None and onset > self.since:
|
|
683
|
+
self.onset = onset
|
|
684
|
+
|
|
685
|
+
def finished(self, tick: int) -> None:
|
|
686
|
+
self.state = "reply" if self.state == "prompt" else "quiet"
|
|
687
|
+
self.since, self.onset = tick, None
|
|
688
|
+
|
|
689
|
+
def step(self, n: int, tick: int) -> None:
|
|
690
|
+
if self.t0 is None:
|
|
691
|
+
self.t0 = tick
|
|
692
|
+
if self.done: # the sender keeps ticking until the call notices
|
|
693
|
+
return
|
|
694
|
+
_, interjection, step = self.script[self.turn]
|
|
695
|
+
if self.state == "lead" and tick - self.t0 >= self.lead_ns:
|
|
696
|
+
self._prompt(n, tick)
|
|
697
|
+
elif self.state == "reply" and self.onset is not None:
|
|
698
|
+
self.state, self.wait_until = "barge", self.onset + round(step["at_s"] * 1e9)
|
|
699
|
+
elif self.state == "reply" and tick - self.since > REPLY_TIMEOUT_NS:
|
|
700
|
+
self.state, self.since = "quiet", tick
|
|
701
|
+
elif self.state == "barge" and tick >= self.wait_until:
|
|
702
|
+
self.turns[-1] |= {"kind": step["kind"], "at_s": step["at_s"]} | self.play(
|
|
703
|
+
n, interjection
|
|
704
|
+
)
|
|
705
|
+
self.state = "interject"
|
|
706
|
+
elif self.state == "quiet" and (
|
|
707
|
+
tick - max(self.agent_last, self.since) >= QUIET_NS
|
|
708
|
+
or tick - self.turns[-1]["tick_ns"] > TURN_TIMEOUT_NS
|
|
709
|
+
):
|
|
710
|
+
self.turn += 1
|
|
711
|
+
if self.turn == len(self.script):
|
|
712
|
+
self.done = True
|
|
713
|
+
else:
|
|
714
|
+
self._prompt(n, tick)
|
|
715
|
+
|
|
716
|
+
def _prompt(self, n: int, tick: int) -> None:
|
|
717
|
+
prompt, _, step = self.script[self.turn]
|
|
718
|
+
rec = self.play(n, prompt)
|
|
719
|
+
self.turns.append(
|
|
720
|
+
{
|
|
721
|
+
"prompt": rec["clip"],
|
|
722
|
+
"prompt_speech": rec["speech"],
|
|
723
|
+
"kind": step["kind"],
|
|
724
|
+
"tick_ns": tick,
|
|
725
|
+
}
|
|
726
|
+
)
|
|
727
|
+
self.state = "prompt"
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def converse(
|
|
731
|
+
uri: str,
|
|
732
|
+
turns: int = 20,
|
|
733
|
+
seed: int = 1,
|
|
734
|
+
prompts: list[Clip] | None = None,
|
|
735
|
+
lead: float = LEAD_SECONDS,
|
|
736
|
+
codecs: tuple[int, ...] = (g711.PCMU, g711.PCMA),
|
|
737
|
+
interrupts: list[Clip] | None = None,
|
|
738
|
+
) -> dict[str, Any]:
|
|
739
|
+
"""Barge-in call: `turns` prompts (corpus clips by default), each followed by the planned
|
|
740
|
+
interjection (`interrupts` replace the corpus interruptions). Each turn logs the
|
|
741
|
+
interjection's labelled spans as stream sample indices."""
|
|
742
|
+
prompts = prompts or speech_utterances(seed)
|
|
743
|
+
pool = interjections(seed)
|
|
744
|
+
if interrupts:
|
|
745
|
+
pool["interrupt"] = interrupts
|
|
746
|
+
used = dict.fromkeys(pool, 0)
|
|
747
|
+
labels: dict[str, list[tuple[int, int]]] = {}
|
|
748
|
+
|
|
749
|
+
def labelled(clip: Clip) -> Labelled:
|
|
750
|
+
if clip[0] not in labels:
|
|
751
|
+
labels[clip[0]] = speech.label(clip[1])
|
|
752
|
+
return clip[0], clip[1], labels[clip[0]]
|
|
753
|
+
|
|
754
|
+
lines = []
|
|
755
|
+
for i, step in enumerate(bargein.plan(seed, turns)):
|
|
756
|
+
clips = pool[step["kind"]]
|
|
757
|
+
said = clips[used[step["kind"]] % len(clips)]
|
|
758
|
+
lines.append((labelled(prompts[i % len(prompts)]), labelled(said), step))
|
|
759
|
+
used[step["kind"]] += 1
|
|
760
|
+
convs = {pt: Conversation(pt, lines, round(lead * 1e9)) for pt in g711.NAMES} # as in `call`
|
|
761
|
+
s = Session(uri, codecs)
|
|
762
|
+
s.answer()
|
|
763
|
+
conv = convs[s.pt]
|
|
764
|
+
announce = Turns(turns)
|
|
765
|
+
# as in `call`: no packet by the end of the first prompt's reply time, none at all
|
|
766
|
+
t0 = CLOCK.now() + PTIME_NS
|
|
767
|
+
silent_ns = t0 + round(lead * 1e9) + len(lines[0][0][1]) * SAMPLE_NS + REPLY_TIMEOUT_NS
|
|
768
|
+
heard = threading.Event()
|
|
769
|
+
|
|
770
|
+
def until(now: int) -> bool:
|
|
771
|
+
announce(len(conv.turns))
|
|
772
|
+
return (not heard.is_set() and now >= silent_ns) or conv.done
|
|
773
|
+
|
|
774
|
+
def sink(arrival: int, pkt: Packet) -> None:
|
|
775
|
+
heard.set()
|
|
776
|
+
conv.on_packet(arrival, pkt)
|
|
777
|
+
|
|
778
|
+
remote_bye = s.run(conv.source, sink, until)
|
|
779
|
+
if not heard.is_set(): # by the deadline, or the agent hung up first
|
|
780
|
+
raise s.no_media((CLOCK.now() - t0) / 1e9)
|
|
781
|
+
return s.meta(seed, remote_bye) | {"turns": conv.turns}
|