rtpbench 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. rtpbench/.dockerignore +4 -0
  2. rtpbench/Dockerfile +12 -0
  3. rtpbench/__init__.py +6 -0
  4. rtpbench/__main__.py +3 -0
  5. rtpbench/agent.py +746 -0
  6. rtpbench/analyze.py +520 -0
  7. rtpbench/bargein.py +272 -0
  8. rtpbench/bench.py +1069 -0
  9. rtpbench/caller.py +781 -0
  10. rtpbench/capture.py +33 -0
  11. rtpbench/carrier/compose.yaml +68 -0
  12. rtpbench/carrier/opensips.cfg +226 -0
  13. rtpbench/carrier/tls.cfg +25 -0
  14. rtpbench/carrier/tls.yaml +9 -0
  15. rtpbench/cases.py +1989 -0
  16. rtpbench/check.py +369 -0
  17. rtpbench/cli.py +616 -0
  18. rtpbench/clock.py +174 -0
  19. rtpbench/compose.yaml +55 -0
  20. rtpbench/corpus/README.md +55 -0
  21. rtpbench/corpus/cough-c1-0.wav +0 -0
  22. rtpbench/corpus/cough-c1-1.wav +0 -0
  23. rtpbench/corpus/cough-c2-0.wav +0 -0
  24. rtpbench/corpus/cough-c2-1.wav +0 -0
  25. rtpbench/corpus/cough-man-0.wav +0 -0
  26. rtpbench/corpus/cough-man-1.wav +0 -0
  27. rtpbench/corpus/cough-short-0.wav +0 -0
  28. rtpbench/corpus/cough-short-1.wav +0 -0
  29. rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
  30. rtpbench/corpus/human-en-dr-0.wav +0 -0
  31. rtpbench/corpus/human-en-dr-1.wav +0 -0
  32. rtpbench/corpus/human-en-sc-0.wav +0 -0
  33. rtpbench/corpus/human-en-sc-1.wav +0 -0
  34. rtpbench/corpus/human-en-sc-2.wav +0 -0
  35. rtpbench/corpus/human-es-f012-0.wav +0 -0
  36. rtpbench/corpus/human-es-f012-1.wav +0 -0
  37. rtpbench/corpus/human-es-f012-2.wav +0 -0
  38. rtpbench/corpus/human-es-f016-0.wav +0 -0
  39. rtpbench/corpus/human-es-f016-1.wav +0 -0
  40. rtpbench/corpus/human-es-f016-2.wav +0 -0
  41. rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
  42. rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
  43. rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
  44. rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
  45. rtpbench/corpus/lines/en/prompt-00.wav +0 -0
  46. rtpbench/corpus/lines/en/prompt-01.wav +0 -0
  47. rtpbench/corpus/lines/en/prompt-02.wav +0 -0
  48. rtpbench/corpus/lines/en/prompt-03.wav +0 -0
  49. rtpbench/corpus/lines/en/prompt-04.wav +0 -0
  50. rtpbench/corpus/lines/en/prompt-05.wav +0 -0
  51. rtpbench/corpus/lines/en/prompt-06.wav +0 -0
  52. rtpbench/corpus/lines/en/prompt-07.wav +0 -0
  53. rtpbench/corpus/lines/en/prompt-08.wav +0 -0
  54. rtpbench/corpus/lines/en/prompt-09.wav +0 -0
  55. rtpbench/corpus/lines/en/prompt.wav +0 -0
  56. rtpbench/corpus/lines/en/transfer.wav +0 -0
  57. rtpbench/corpus/lines/en/turn-00.wav +0 -0
  58. rtpbench/corpus/lines/en/turn-01.wav +0 -0
  59. rtpbench/corpus/lines/en/turn-02.wav +0 -0
  60. rtpbench/corpus/lines/en/turn-03.wav +0 -0
  61. rtpbench/corpus/lines/en/turn-04.wav +0 -0
  62. rtpbench/corpus/lines/en/turn-05.wav +0 -0
  63. rtpbench/corpus/lines/en/turn-06.wav +0 -0
  64. rtpbench/corpus/lines/en/turn-07.wav +0 -0
  65. rtpbench/corpus/lines/en/turn-08.wav +0 -0
  66. rtpbench/corpus/lines/en/turn-09.wav +0 -0
  67. rtpbench/corpus/lines/en/turn-10.wav +0 -0
  68. rtpbench/corpus/lines/en/turn-11.wav +0 -0
  69. rtpbench/corpus/tts-ald-bc.wav +0 -0
  70. rtpbench/corpus/tts-ald-long.wav +0 -0
  71. rtpbench/corpus/tts-ald-s03.wav +0 -0
  72. rtpbench/corpus/tts-ald-s07.wav +0 -0
  73. rtpbench/corpus/tts-ald-s11.wav +0 -0
  74. rtpbench/corpus/tts-cori-bc.wav +0 -0
  75. rtpbench/corpus/tts-cori-long.wav +0 -0
  76. rtpbench/corpus/tts-cori-s01.wav +0 -0
  77. rtpbench/corpus/tts-cori-s05.wav +0 -0
  78. rtpbench/corpus/tts-cori-s09.wav +0 -0
  79. rtpbench/corpus/tts-davefx-bc.wav +0 -0
  80. rtpbench/corpus/tts-davefx-long.wav +0 -0
  81. rtpbench/corpus/tts-davefx-s00.wav +0 -0
  82. rtpbench/corpus/tts-davefx-s04.wav +0 -0
  83. rtpbench/corpus/tts-davefx-s08.wav +0 -0
  84. rtpbench/corpus/tts-libritts0-bc.wav +0 -0
  85. rtpbench/corpus/tts-libritts0-long.wav +0 -0
  86. rtpbench/corpus/tts-libritts0-s02.wav +0 -0
  87. rtpbench/corpus/tts-libritts0-s06.wav +0 -0
  88. rtpbench/corpus/tts-libritts0-s10.wav +0 -0
  89. rtpbench/corpus/tts-libritts3-bc.wav +0 -0
  90. rtpbench/corpus/tts-libritts3-long.wav +0 -0
  91. rtpbench/corpus/tts-libritts3-s03.wav +0 -0
  92. rtpbench/corpus/tts-libritts3-s07.wav +0 -0
  93. rtpbench/corpus/tts-libritts3-s11.wav +0 -0
  94. rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
  95. rtpbench/corpus/tts-ljspeech-long.wav +0 -0
  96. rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
  97. rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
  98. rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
  99. rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
  100. rtpbench/corpus/tts-sharvardf-long.wav +0 -0
  101. rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
  102. rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
  103. rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
  104. rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
  105. rtpbench/corpus/tts-sharvardm-long.wav +0 -0
  106. rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
  107. rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
  108. rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
  109. rtpbench/detect.py +145 -0
  110. rtpbench/g711.py +82 -0
  111. rtpbench/mock/compose.yaml +18 -0
  112. rtpbench/pcap.py +106 -0
  113. rtpbench/progress.py +184 -0
  114. rtpbench/realtime.py +1015 -0
  115. rtpbench/report.py +450 -0
  116. rtpbench/rtp.py +166 -0
  117. rtpbench/sip.py +653 -0
  118. rtpbench/speech.py +100 -0
  119. rtpbench/speechcal.py +367 -0
  120. rtpbench/srtp.py +191 -0
  121. rtpbench/targets/aivc/aivc.ini +25 -0
  122. rtpbench/targets/aivc/compose.yaml +72 -0
  123. rtpbench/targets/aivc/constraints.txt +52 -0
  124. rtpbench/targets/aivc/opensips.cfg +45 -0
  125. rtpbench/targets/ava/ai-agent.yaml +275 -0
  126. rtpbench/targets/ava/ari.conf +7 -0
  127. rtpbench/targets/ava/compose.yaml +128 -0
  128. rtpbench/targets/ava/constraints.txt +75 -0
  129. rtpbench/targets/ava/extensions.conf +10 -0
  130. rtpbench/targets/ava/http.conf +4 -0
  131. rtpbench/targets/ava/pjsip.conf +19 -0
  132. rtpbench/targets/ava/websocket_client.conf +15 -0
  133. rtpbench/targets/livekit/agent.py +111 -0
  134. rtpbench/targets/livekit/compose.yaml +86 -0
  135. rtpbench/targets/livekit/constraints.txt +72 -0
  136. rtpbench/tls.py +284 -0
  137. rtpbench/uri/advertise.yaml +7 -0
  138. rtpbench/uri/network.yaml +14 -0
  139. rtpbench/voice.py +18 -0
  140. rtpbench-0.3.0.dist-info/METADATA +123 -0
  141. rtpbench-0.3.0.dist-info/RECORD +144 -0
  142. rtpbench-0.3.0.dist-info/WHEEL +4 -0
  143. rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
  144. rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
rtpbench/agent.py ADDED
@@ -0,0 +1,746 @@
1
+ """Reference agent: answers each caller turn a known delay after the caller stops talking.
2
+
3
+ The delay runs from the last active sample it received (kernel arrival time plus the sample's
4
+ offset in its packet) to the reply's onset (tick time plus offset), so the onset is placed to
5
+ the sample, not to the packet. The synthetic reply's onset is its sample 0; a speech clip's is
6
+ its first labelled speech sample, and whatever precedes it in the clip is sent before it.
7
+
8
+ With a barge-in plan, caller speech that starts while the agent is replying is an interjection,
9
+ never a turn: the agent stops (hard cut) the planned delay after the interjection's first
10
+ active sample arrived, or carries on, as the plan says.
11
+
12
+ In a conformance case the agent answers no turns; it does what the case's `Behaviour` says.
13
+ """
14
+
15
+ import random
16
+ import re
17
+ import secrets
18
+ import select
19
+ import socket
20
+ import ssl
21
+ import threading
22
+ import time
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from rtpbench import bargein, g711, sip, speech, srtp
29
+ from rtpbench.clock import CLOCK
30
+ from rtpbench.rtp import FRAME, PTIME_NS, SAMPLE_NS, Frame, Packet, Receiver, Sender
31
+ from rtpbench.voice import GATE, voice
32
+
33
+ HANGOVER_NS = 200_000_000 # silence that ends a caller turn
34
+ DELAY_RANGE = (0.3, 1.3) # seconds; the low end leaves 100 ms after the hangover to schedule
35
+ RESPONSE_SECONDS = 0.8
36
+ # Real speech pauses for up to 0.8 s inside a turn as the gate sees it, and clips lead in by up
37
+ # to 0.35 s before their labelled onset.
38
+ SPEECH_HANGOVER_NS = 1_000_000_000
39
+ SPEECH_DELAY_RANGE = (1.5, 2.5)
40
+
41
+ Reply = tuple[str, list[int], list[tuple[int, int]]] # name, PCM, labelled speech spans
42
+ DIGITS = "0123456789*#ABCD" # RFC 4733 events 0-15
43
+ EVENT_END_NS = 3 * PTIME_NS # no packet of an event this long: it ended (RFC 4733 2.5.2.2)
44
+ # where transfers go: the `operator` service, which answers (after ringing, and says a few words)
45
+ # unless the INVITE carries the BUSY header, which the transferee adds for a transfer meant to fail
46
+ OPERATOR = "sip:operator@operator"
47
+ BUSY = ("X-Operator", "busy")
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Behaviour:
52
+ """What the reference agent does in a conformance case (see `cases`)."""
53
+
54
+ ring_s: float = 0.0 # send 180 Ringing and answer this long after the INVITE
55
+ cancel: bool = True # a CANCEL while ringing ends the call (487); else answer anyway
56
+ # a copy of the INVITE while ringing: "replay" the latest response (RFC 3261 17.2.1); "reject"
57
+ # it 503 without a To-tag, as a stack that takes it for another call; "ignore" it
58
+ copies: str = "replay"
59
+ greet_s: float | None = None # greet this long after sending the 200 OK
60
+ greeting: str = "short" # a short corpus clip, or a long one (9-15 s)
61
+ linger_s: float = 0.0 # keep sending RTP this long after answering BYE
62
+ hold: bool = True # answer a re-INVITE's direction as offered; else sendrecv, never pause
63
+ clock: bool = True # RTP timestamps keep time while paused; else resume where they stopped
64
+ dtmf_s: float | None = None # answer a digit (RFC 4733 or INFO) with speech this long after
65
+ events: bool = True # telephone-event in the answer when offered; else leave it out
66
+ info: bool = True # take a DTMF INFO (200); else 405 Method Not Allowed
67
+ refer_s: float | None = None # blind REFER this long after the caller's first turn ends
68
+ # then: "complete" answers NOTIFYs and hangs up after a final one reporting 2xx; "always"
69
+ # hangs up after the final one whatever it reports; "stay" answers them and stays; "early"
70
+ # hangs up on the 202 and answers NOTIFYs 481
71
+ transfer: str = "complete"
72
+ retake_s: float | None = None # a transfer failed (final NOTIFY not 2xx): speak this long after
73
+ codec: int | None = None # answer with this payload type whatever the offer
74
+ only: int | None = None # take this G.711 law alone: 488 to an offer, or re-offer, without it
75
+ timer: bool = False # take session timers (RFC 4028) with the caller as refresher
76
+ expire: bool = True # with timer: BYE when the session expires unrefreshed
77
+ reply_s: float | None = None # answer the caller's first turn with speech this long after
78
+ # what a carrier may send (see `cases`); each False is a way real agents fail it
79
+ compact: bool = True # compact header names (RFC 3261 7.3.3); else 500 to an INVITE with them
80
+ lenient_sdp: bool = True # ignore `a=ssrc:<id>` without an attribute; else 488
81
+ late_offer: bool = True # offer in the 2xx to an INVITE without SDP; else 488
82
+ any_ptime: bool = True # hear audio packets of any length; else 20 ms ones only
83
+ any_ssrc: bool = True # hear the caller from any SSRC; else from the first one only
84
+ refresh: bool = True # keep sending after a re-INVITE; else stop (one-way audio)
85
+ zero_hold: bool = True # an offer with c=0.0.0.0 holds (RFC 3264 8.4); else keep sending
86
+ reoffer: bool = True # offer in the 2xx to a re-INVITE without SDP; else a 2xx without SDP
87
+ recodec: bool = True # take a re-INVITE's other G.711 law; else answer the old one (stale)
88
+ tcp: bool = True # listen on TCP too (RFC 3261 18: every element MUST); else UDP only
89
+ busy: bool = False # 486 Busy Here to the INVITE (the operator of a failed transfer)
90
+ # resend our own requests (REFER, BYE) until answered (timer E, RFC 3261 17.1.2.2); else once
91
+ retransmit: bool = True
92
+
93
+
94
+ def speech_replies(seed: int, directory: Path | None = None, long: bool = False) -> list[Reply]:
95
+ """Every WAV in `directory`, or the short corpus replies (`long`: barge-in ones), shuffled."""
96
+ if directory:
97
+ clips = [(p.stem, speech.read_wav(p)) for p in sorted(directory.glob("*.wav"))]
98
+ else:
99
+ corpus = speech.clips("tts-", "human-").items()
100
+ clips = [(n, pcm) for n, pcm in corpus if ("-long" in n) == long]
101
+ random.Random(seed).shuffle(clips)
102
+ return [(n, pcm, speech.label(pcm)) for n, pcm in clips]
103
+
104
+
105
+ class Responder:
106
+ def __init__(
107
+ self,
108
+ pt: int,
109
+ seq0: int,
110
+ delays: list[float],
111
+ replies: list[Reply] | None = None,
112
+ hangover_ns: int = HANGOVER_NS,
113
+ plan: list[dict[str, Any]] | None = None,
114
+ ) -> None:
115
+ self.pt, self.seq0, self.delays, self.hangover_ns = pt, seq0, delays, hangover_ns
116
+ self.plan = plan
117
+ self.gate = g711.gate(pt, GATE)
118
+ self.silence = bytes([g711.SILENCE[pt]]) * FRAME
119
+ replies = replies or [("synthetic", voice(RESPONSE_SECONDS, f0=210), [])]
120
+ self.replies = [(n, g711.encode(pt, pcm), spans) for n, pcm, spans in replies]
121
+ self.response = b""
122
+ self.lock = threading.Lock()
123
+ self.turns: list[dict[str, Any]] = []
124
+ self.speaking = False
125
+ self.end_ns, self.end_seq, self.end_off = 0, 0, 0
126
+ self.pending: dict[str, Any] | None = None
127
+ self.pos: int | None = None # response sample index at the start of the next tick
128
+ self.current: dict[str, Any] = {} # the turn being replied to
129
+ self.interjecting = False # the caller's current burst started while we replied
130
+ self.cut_ns: int | None = None
131
+ self.sending, self.clock = True, True # not sending: on hold
132
+ self.skipped, self.paused = 0, True # ticks not sent; marker on the next packet
133
+ self.events: int | None = None # telephone-event payload type
134
+ self.dtmf_ns: int | None = None # reply this long after a digit
135
+ self.digits: list[dict[str, Any]] = []
136
+ self.event_ts: int | None = None # RTP timestamp of the last event that ended
137
+ self.event_open: tuple[int, int, str] | None = None # ts, last arrival, digit: no end yet
138
+ self.ends: list[int] = [] # caller turn ends, when answering no turns
139
+
140
+ def speak(self, target_ns: int, reply: int, kind: str) -> None:
141
+ """Say `self.replies[reply]`, its labelled onset at `target_ns`."""
142
+ with self.lock:
143
+ turn = len(self.turns)
144
+ self.pending = {"turn": turn, "kind": kind, "reply": reply, "target_ns": target_ns}
145
+
146
+ def recodec(self, pt: int) -> None:
147
+ """Speak and hear the other G.711 law from now on (the caller changed codec)."""
148
+ replies = [(n, g711.encode(pt, g711.decode(self.pt, a)), s) for n, a, s in self.replies]
149
+ with self.lock:
150
+ self.response = g711.encode(pt, g711.decode(self.pt, self.response))
151
+ self.pt, self.replies, self.gate = pt, replies, g711.gate(pt, GATE)
152
+ self.silence = bytes([g711.SILENCE[pt]]) * FRAME
153
+
154
+ def digit(self, at_ns: int, digit: str, via: str) -> None:
155
+ """A digit came in (the key released): note it and, if the case says so, answer."""
156
+ self.digits.append({"digit": digit, "via": via, "at_ns": at_ns})
157
+ if self.dtmf_ns is not None:
158
+ reply = len(self.digits) % len(self.replies)
159
+ self.speak(at_ns + self.dtmf_ns, reply, "dtmf")
160
+
161
+ def on_packet(self, arrival: int, pkt: Packet) -> None:
162
+ if pkt.pt == self.events and len(pkt.payload) >= 4:
163
+ if pkt.ts == self.event_ts: # an event already taken
164
+ return
165
+ if pkt.payload[1] & 0x80: # end bit, first copy
166
+ self.event_ts, self.event_open = pkt.ts, None
167
+ self.digit(arrival, DIGITS[pkt.payload[0] % 16], "rfc4733")
168
+ else:
169
+ self.event_open = (pkt.ts, arrival, DIGITS[pkt.payload[0] % 16])
170
+ return
171
+ if pkt.pt != self.pt:
172
+ return
173
+ with self.lock:
174
+ if m := self.gate.search(pkt.payload[::-1]):
175
+ if not self.speaking and self.plan is not None:
176
+ self.interjecting = self.pos is not None
177
+ if self.interjecting and "barge" not in self.current:
178
+ self._interjection(arrival, pkt)
179
+ off = len(pkt.payload) - 1 - m.start()
180
+ if (end := arrival + off * SAMPLE_NS) > self.end_ns:
181
+ self.end_ns, self.end_seq, self.end_off = end, pkt.seq, off
182
+ self.speaking = True
183
+ elif self.speaking and arrival - self.end_ns >= self.hangover_ns:
184
+ self.speaking = False
185
+ if self.interjecting or not self.delays:
186
+ if not self.interjecting:
187
+ self.ends.append(self.end_ns)
188
+ self.interjecting = False
189
+ return
190
+ turn = len(self.turns) + (self.pending is not None)
191
+ delay_ns = round(self.delays[turn % len(self.delays)] * 1e9)
192
+ self.pending = {
193
+ "turn": turn,
194
+ "delay_ns": delay_ns,
195
+ "end_ns": self.end_ns,
196
+ "end_seq": self.end_seq,
197
+ "end_off": self.end_off,
198
+ "target_ns": self.end_ns + delay_ns,
199
+ }
200
+
201
+ def _interjection(self, arrival: int, pkt: Packet) -> None:
202
+ assert self.plan is not None
203
+ first = self.gate.search(pkt.payload)
204
+ assert first is not None
205
+ step = self.plan[self.current["turn"] % len(self.plan)]
206
+ trigger = arrival + first.start() * SAMPLE_NS
207
+ delay_ns = round(step["stop_delay_s"] * 1e9)
208
+ self.current["barge"] = {
209
+ "trigger_ns": trigger,
210
+ "trigger_seq": pkt.seq,
211
+ "trigger_off": first.start(),
212
+ "stop_delay_ns": delay_ns,
213
+ }
214
+ if step["stop"]:
215
+ self.cut_ns = trigger + delay_ns
216
+
217
+ def source(self, n: int, tick: int) -> bytes | Frame | None:
218
+ # an event whose end packets were all lost still happened: RFC 4733 2.5.2.2 ends it once
219
+ # packets stop (and extends a tone by no more than three packet interarrival times)
220
+ if (e := self.event_open) is not None and tick - e[1] > EVENT_END_NS:
221
+ self.event_ts, self.event_open = e[0], None
222
+ self.digit(e[1] + EVENT_END_NS, e[2], "rfc4733")
223
+ with self.lock:
224
+ out = self._audio(n - self.skipped, tick)
225
+ if not self.sending:
226
+ self.skipped += 1
227
+ self.paused = True
228
+ return None
229
+ marker, self.paused = self.paused, False
230
+ if self.clock:
231
+ return out # timestamp n * 160: the clock ran on while paused
232
+ return Frame(self.pt, out, (n - self.skipped) * FRAME, marker)
233
+
234
+ def _audio(self, n: int, tick: int) -> bytes:
235
+ """Tick n's payload (under the lock); n counts packets sent, as seq does."""
236
+ p = self.pending
237
+ pick = p.get("reply", p["turn"]) if p else 0
238
+ name, audio, spans = self.replies[pick % len(self.replies)] if p else ("", b"", [])
239
+ lead = spans[0][0] if spans else 0
240
+ # a start in the last half sample rounds to the next tick's sample 0, not to 160
241
+ off = round((p["target_ns"] - tick) / SAMPLE_NS) - lead if p else FRAME
242
+ if self.pos is None and p is not None and off < FRAME:
243
+ off = max(0, off)
244
+ start = n * FRAME + off # stream sample index (from seq0) of the clip's sample 0
245
+ onset = start + lead
246
+ p |= {
247
+ "onset_seq": (self.seq0 + onset // FRAME) & 0xFFFF,
248
+ "onset_off": onset % FRAME,
249
+ "tick_ns": tick,
250
+ }
251
+ if spans:
252
+ p |= {
253
+ "clip": name,
254
+ "start": start,
255
+ "speech": [[a + start, b + start] for a, b in spans],
256
+ }
257
+ self.turns.append(p)
258
+ self.pending, self.pos, self.response, self.current = None, -off, audio, p
259
+ if self.pos is None:
260
+ return self.silence
261
+ pos, self.pos = self.pos, self.pos + FRAME
262
+ if self.pos >= len(self.response):
263
+ self.pos = None
264
+ head = self.silence[: max(0, -pos)]
265
+ out = (head + self.response[max(0, pos) : pos + FRAME]).ljust(FRAME, self.silence[:1])
266
+ if self.cut_ns is not None and (cut := round((self.cut_ns - tick) / SAMPLE_NS)) < FRAME:
267
+ cut = max(0, cut)
268
+ self.current["barge"]["cut"] = n * FRAME + cut # first sample not sent
269
+ self.pos = None
270
+ out = out[:cut] + self.silence[cut:]
271
+ if self.pos is None:
272
+ self.cut_ns = None
273
+ return out
274
+
275
+
276
+ def _savp(sdp: sip.Sdp) -> bool:
277
+ return sdp.proto == "RTP/SAVP"
278
+
279
+
280
+ def _usable(sdp: sip.Sdp) -> list[tuple[int, str, bytes]]:
281
+ """The a=crypto lines of `sdp` this agent can take, in the offer's order."""
282
+ out = []
283
+ for value in sdp.crypto:
284
+ try:
285
+ out.append(srtp.parse(value))
286
+ except ValueError:
287
+ continue
288
+ return out
289
+
290
+
291
+ def _local_ip(peer: tuple[str, int]) -> str:
292
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
293
+ s.connect(peer)
294
+ return str(s.getsockname()[0])
295
+
296
+
297
+ class Uas:
298
+ """The reference agent's SIP side of one call: answers (after ringing, if the case says so),
299
+ resends its final response to the INVITE until ACKed, and takes CANCEL and BYE as the case
300
+ says. `sock`: the transport the INVITE came on (a UDP socket or `sip.Tcp`)."""
301
+
302
+ def __init__(
303
+ self,
304
+ sock: socket.socket | sip.Tcp,
305
+ rtp: socket.socket,
306
+ case: Behaviour,
307
+ responder: Callable[[int, int], Responder],
308
+ greeting: int,
309
+ ) -> None:
310
+ self.sock, self.rtp, self.case = sock, rtp, case
311
+ self.make, self.greeting = responder, greeting
312
+ self.to_tag = secrets.token_hex(4)
313
+ self.invite: sip.Message | None = None
314
+ self.addr = ("", 0)
315
+ self.last = b"" # latest response to the INVITE, resent if the INVITE is
316
+ self.unacked = b"" # final response to the INVITE, resent until ACKed
317
+ self.resend_at, self.interval = 0.0, sip.T1
318
+ self.answer_at: float | None = None
319
+ self.reinvite = "" # branch of the latest re-INVITE
320
+ self.origin = [secrets.randbelow(1 << 31), 0] # SDP session id, version
321
+ self.end_at: float | None = None
322
+ self.done = False
323
+ self.responder: Responder | None = None
324
+ self.pt, self.dtmf = 0, False
325
+ self.late = False # the INVITE had no offer: ours goes in the 2xx, theirs in the ACK
326
+ self.seq0 = 0
327
+ self.heard_ssrc: int | None = None
328
+ self.replied = False
329
+ self.dialog: sip.Dialog | None = None # for requests of our own (REFER, BYE)
330
+ self.refer: sip.Message | None = None
331
+ self.bye: sip.Message | None = None
332
+ self.expire_at: float | None = None # session timer: BYE then, unless refreshed
333
+ self.sender: Sender | None = None
334
+ self.threads: list[Sender | Receiver] = []
335
+ self.meta: dict[str, Any] = {}
336
+ # SRTP when the caller offers it (SDES): our master key per suite; the suite we took,
337
+ # what we send with and what we hear with
338
+ self.keys = srtp.master()
339
+ self.suite: str | None = None
340
+ self.tx: srtp.Context | None = None
341
+ self.rx: srtp.Context | None = None
342
+
343
+ def _send(self, resp: sip.Message, final: bool = False) -> None:
344
+ data = resp.render()
345
+ self.sock.sendto(data, self.addr)
346
+ if resp.cseq_method == "INVITE":
347
+ self.last = data
348
+ if final:
349
+ self.unacked, self.resend_at, self.interval = (
350
+ data,
351
+ time.monotonic() + sip.T1,
352
+ sip.T1,
353
+ )
354
+
355
+ def handle(
356
+ self, msg: sip.Message, addr: tuple[str, int], via: socket.socket | sip.Tcp | None = None
357
+ ) -> None:
358
+ """A message from `addr`, on `via` (the call's transport if None)."""
359
+ inv = self.invite
360
+ if msg.status:
361
+ self._response(msg)
362
+ return
363
+ if inv is None and msg.method == "INVITE":
364
+ self.addr, self.sock = addr, via or self.sock
365
+ self._invite(msg)
366
+ elif inv is None or msg.get("call-id") != inv.get("call-id"):
367
+ if msg.method not in ("ACK", "INVITE"):
368
+ self.sock.sendto(sip.response(msg, 481, "Call Does Not Exist").render(), addr)
369
+ elif msg.method == "INVITE" and msg.branch in (inv.branch, self.reinvite): # a copy
370
+ ringing = self.answer_at is not None and msg.branch == inv.branch
371
+ if ringing and self.case.copies == "reject":
372
+ self.sock.sendto(sip.response(msg, 503, "Service Unavailable").render(), addr)
373
+ elif not (ringing and self.case.copies == "ignore"):
374
+ self.sock.sendto(self.last, addr)
375
+ elif msg.method == "INVITE":
376
+ self._reinvite(msg)
377
+ elif msg.method == "ACK":
378
+ self.unacked = b""
379
+ if self.late and self.sender is None and self.dialog and msg.body:
380
+ answer = sip.parse_sdp(msg.body)
381
+ if (pt := next((p for p in answer.pts if p in g711.NAMES), self.pt)) != self.pt:
382
+ self.pt = pt
383
+ self._prepare(answer)
384
+ self._media(answer)
385
+ elif msg.body and self.suite: # the answer to our offer in a 2xx: the caller's key
386
+ self._crypto(sip.parse_sdp(msg.body))
387
+ # ACK for a 487: the call never was (one for a 503 to a copy, while ringing, is not)
388
+ self.done |= self.responder is None and self.answer_at is None
389
+ elif msg.method == "CANCEL":
390
+ self._send(sip.response(msg, 200, "OK", self.to_tag))
391
+ if self.answer_at is not None and self.case.cancel: # still ringing
392
+ self.answer_at = None
393
+ self._send(sip.response(inv, 487, "Request Terminated", self.to_tag), final=True)
394
+ self.end_at = time.monotonic() + 64 * sip.T1
395
+ elif msg.method == "BYE":
396
+ self._send(sip.response(msg, 200, "OK", self.to_tag))
397
+ self.end_at = time.monotonic() + self.case.linger_s
398
+ elif msg.method == "NOTIFY":
399
+ if self.case.transfer == "early":
400
+ self._send(sip.response(msg, 481, "Subscription Does Not Exist", self.to_tag))
401
+ return
402
+ self._send(sip.response(msg, 200, "OK", self.to_tag))
403
+ final = "terminated" in msg.get("subscription-state")
404
+ ok = msg.body[8:9] == b"2" # sipfrag "SIP/2.0 2xx"
405
+ if final and (
406
+ self.case.transfer == "always" or (ok and self.case.transfer == "complete")
407
+ ):
408
+ self._hang_up()
409
+ elif final and not ok and self.case.retake_s is not None and (r := self.responder):
410
+ retake = round(self.case.retake_s * 1e9)
411
+ r.speak(CLOCK.now() + retake, (self.greeting + 1) % len(r.replies), "retake")
412
+ elif msg.method == "INFO" and not self.case.info: # RFC 3261 21.4.6: Allow lists ours
413
+ allow = [("Allow", "INVITE, ACK, CANCEL, BYE, OPTIONS, NOTIFY")]
414
+ self._send(sip.response(msg, 405, "Method Not Allowed", self.to_tag, extra=allow))
415
+ elif msg.method == "INFO" and "dtmf-relay" in msg.get("content-type").lower():
416
+ self._send(sip.response(msg, 200, "OK", self.to_tag))
417
+ body = dict(
418
+ line.partition("=")[::2] for line in msg.body.decode(errors="replace").split()
419
+ )
420
+ if self.responder and (signal := body.get("Signal", "").strip()):
421
+ self.responder.digit(CLOCK.now(), signal, "info")
422
+ else:
423
+ self._send(sip.response(msg, 200, "OK", self.to_tag))
424
+
425
+ def _response(self, msg: sip.Message) -> None:
426
+ if self.dialog is None:
427
+ return
428
+ self.dialog.answered(msg)
429
+ taken = self.refer is not None and msg.branch == self.refer.branch and msg.status == 202
430
+ if self.bye and msg.branch == self.bye.branch and msg.status >= 200:
431
+ self.end_at = time.monotonic() + (5.0 if self.case.transfer == "early" else 0.0)
432
+ elif taken and self.case.transfer == "early": # blind: done once the REFER is taken
433
+ self._hang_up()
434
+
435
+ def _hang_up(self) -> None:
436
+ assert self.dialog is not None
437
+ if self.bye is None:
438
+ self.bye = self.dialog.request("BYE")
439
+ self.dialog.send(self.bye)
440
+
441
+ def _invite(self, msg: sip.Message) -> None:
442
+ self.invite = msg
443
+ self._send(sip.response(msg, 100, "Trying"))
444
+ self.late = not msg.body and self.case.late_offer
445
+ compact = any(k.lower() in sip._COMPACT for k, _ in msg.headers)
446
+ malformed = re.search(rb"^a=ssrc:\d+\r?$", msg.body, re.MULTILINE)
447
+ if self.case.busy:
448
+ self._send(sip.response(msg, 486, "Busy Here", self.to_tag), final=True)
449
+ self.end_at = time.monotonic() + 64 * sip.T1
450
+ elif compact and not self.case.compact: # a parser that knows the long names only
451
+ self._send(sip.response(msg, 500, "Server Internal Error", self.to_tag), final=True)
452
+ self.end_at = time.monotonic() + 64 * sip.T1
453
+ elif not self.late and (
454
+ (malformed and not self.case.lenient_sdp)
455
+ or not [pt for pt in sip.parse_sdp(msg.body).pts if pt in g711.NAMES]
456
+ or self.case.only not in (None, *sip.parse_sdp(msg.body).pts)
457
+ or (_savp(sip.parse_sdp(msg.body)) and not _usable(sip.parse_sdp(msg.body)))
458
+ ):
459
+ self._send(sip.response(msg, 488, "Not Acceptable Here", self.to_tag), final=True)
460
+ self.end_at = time.monotonic() + 64 * sip.T1
461
+ elif self.case.ring_s:
462
+ self._send(sip.response(msg, 180, "Ringing", self.to_tag))
463
+ self.answer_at = time.monotonic() + self.case.ring_s
464
+ else:
465
+ self._answer()
466
+
467
+ def _answer(self) -> None:
468
+ """Answer with the session (or, to an INVITE without one, an offer of both laws, the
469
+ media starting on the ACK's answer)."""
470
+ assert self.invite is not None
471
+ offer = sip.parse_sdp(self.invite.body)
472
+ pt = next((pt for pt in offer.pts if pt in g711.NAMES), g711.PCMU)
473
+ self.pt = next(c for c in (self.case.codec, self.case.only, pt) if c is not None)
474
+ self.dtmf = offer.dtmf is not None and self.case.events
475
+ self._prepare(offer) # before the 2xx: it takes a while (encodes every clip)
476
+ self._send(self._ok(self.invite, "sendrecv"), final=True)
477
+ self.dialog = sip.Dialog.uas(
478
+ self.sock, self.addr, self.invite, _local_ip(self.addr), self.to_tag
479
+ )
480
+ if not self.late:
481
+ self._media(offer)
482
+
483
+ def _prepare(self, remote: sip.Sdp) -> None:
484
+ self.seq0 = secrets.randbelow(1 << 16)
485
+ self.responder = r = self.make(self.pt, self.seq0)
486
+ r.clock, r.events = self.case.clock, remote.dtmf
487
+ r.dtmf_ns = None if self.case.dtmf_s is None else round(self.case.dtmf_s * 1e9)
488
+
489
+ def _crypto(self, offer: sip.Sdp | None) -> list[str] | None:
490
+ """a=crypto values for our SDP: the answer to an SRTP `offer` (its first suite we take,
491
+ or the one in use), our offer of the suite in use when there is no offer, else none."""
492
+ if offer is None:
493
+ return [srtp.line(1, self.suite, self.keys[self.suite])] if self.suite else None
494
+ if not _savp(offer):
495
+ return None
496
+ # the suite stays the call's: a re-INVITE without it is refused (`_reinvite`)
497
+ pick = next((c for c in _usable(offer) if c[1] == (self.suite or c[1])), None)
498
+ if pick is None:
499
+ return None
500
+ tag, suite, key = pick
501
+ if self.tx is None:
502
+ self.suite, self.tx = suite, srtp.Context(suite, self.keys[suite])
503
+ if self.rx is None or self.rx.master != key:
504
+ self.rx = srtp.Context(suite, key, self.rx.state if self.rx else None)
505
+ for t in self.threads:
506
+ if isinstance(t, Receiver):
507
+ t.srtp = self.rx
508
+ return [srtp.line(tag, suite, self.keys[suite])]
509
+
510
+ def _media(self, remote: sip.Sdp) -> None:
511
+ """Start sending to and hearing the far end of the negotiated session."""
512
+ r = self.responder
513
+ assert r is not None
514
+ ssrc = secrets.randbelow(1 << 32)
515
+ start = CLOCK.now() + PTIME_NS
516
+ addr = (remote.ip, remote.port)
517
+ self.sender = Sender(
518
+ self.rtp, addr, self.pt, r.source, start, self.seq0, ssrc, srtp=self.tx
519
+ )
520
+ self.threads = [self.sender, Receiver(self.rtp, self._hear, srtp=self.rx)]
521
+ for t in self.threads:
522
+ t.start()
523
+ if self.case.greet_s is not None:
524
+ r.speak(CLOCK.now() + round(self.case.greet_s * 1e9), self.greeting, "greeting")
525
+ self.meta = {"pt": self.pt, "ssrc": ssrc, "seq0": self.seq0, "srtp": self.suite}
526
+
527
+ def _hear(self, arrival: int, pkt: Packet) -> None:
528
+ """The caller's RTP, as far as this agent's receiver takes it."""
529
+ if pkt.pt == self.pt and len(pkt.payload) != FRAME and not self.case.any_ptime:
530
+ return
531
+ self.heard_ssrc = pkt.ssrc if self.heard_ssrc is None else self.heard_ssrc
532
+ if pkt.ssrc != self.heard_ssrc and not self.case.any_ssrc:
533
+ return
534
+ assert self.responder is not None
535
+ self.responder.on_packet(arrival, pkt)
536
+
537
+ def _ok(self, req: sip.Message, direction: str) -> sip.Message:
538
+ local = _local_ip(self.addr)
539
+ assert self.invite is not None
540
+ transport = getattr(self.sock, "transport", "UDP")
541
+ sips = self.invite.first.split()[1].lower().startswith("sips:")
542
+ contact = sip.contact("agent", local, self.sock.getsockname()[1], transport, sips)
543
+ extra = [("Contact", contact)]
544
+ origin = (self.origin[0], self.origin[1])
545
+ pts = list(g711.NAMES) if self.late and self.sender is None else [self.pt]
546
+ crypto = self._crypto(sip.parse_sdp(req.body) if req.body else None)
547
+ body = sip.sdp(
548
+ local, self.rtp.getsockname()[1], pts, direction, origin, self.dtmf, crypto=crypto
549
+ )
550
+ self.origin[1] += 1
551
+ if self.case.timer and (expires := req.get("session-expires")):
552
+ # RFC 4028 9: keep the interval and the caller as refresher, so Require: timer
553
+ interval = int(expires.split(";")[0])
554
+ extra += [("Require", "timer"), ("Supported", "timer")]
555
+ extra.append(("Session-Expires", f"{interval};refresher=uac"))
556
+ if self.case.expire: # 10: BYE min(32 s, a third) before it would expire
557
+ self.expire_at = time.monotonic() + interval - min(32, interval / 3)
558
+ return sip.response(req, 200, "OK", self.to_tag, body, extra)
559
+
560
+ def _reinvite(self, msg: sip.Message) -> None:
561
+ """Answer the offered direction (or sendrecv, ignoring hold); pause sending while the
562
+ answer says the agent does not send."""
563
+ offer = sip.parse_sdp(msg.body)
564
+ self.reinvite = msg.branch # a retransmission gets the same answer
565
+ if (self.suite and _savp(offer) and all(c[1] != self.suite for c in _usable(offer))) or (
566
+ msg.body and self.case.only not in (None, *offer.pts)
567
+ ):
568
+ self._send(sip.response(msg, 488, "Not Acceptable Here", self.to_tag), final=True)
569
+ return
570
+ direction = sip.ANSWER[offer.direction] if self.case.hold else "sendrecv"
571
+ zero = offer.ip == "0.0.0.0" # RFC 3264 8.4: send nothing
572
+ law = next((pt for pt in offer.pts if pt in g711.NAMES), self.pt)
573
+ if self.responder:
574
+ if law != self.pt and self.case.recodec:
575
+ self.pt = law
576
+ self.responder.recodec(law)
577
+ if self.sender:
578
+ self.sender.pt = law
579
+ with self.responder.lock:
580
+ sending = direction in ("sendrecv", "sendonly") and self.case.refresh
581
+ self.responder.sending = sending and not (zero and self.case.zero_hold)
582
+ if offer.port and self.sender and not zero:
583
+ self.sender.addr = (offer.ip, offer.port)
584
+ ok = self._ok(msg, direction)
585
+ if not msg.body and not self.case.reoffer:
586
+ ok.body, ok.headers = b"", [h for h in ok.headers if h[0] != "Content-Type"]
587
+ self._send(ok, final=True)
588
+
589
+ def tick(self) -> None:
590
+ now = time.monotonic()
591
+ if self.dialog and self.case.retransmit:
592
+ self.dialog.retransmit()
593
+ if self.refer is None and self._since_turn(self.case.refer_s):
594
+ self._transfer()
595
+ if self.case.reply_s is not None and not self.replied and (r := self.responder) and r.ends:
596
+ self.replied = True
597
+ r.speak(r.ends[0] + round(self.case.reply_s * 1e9), self.greeting + 1, "reply")
598
+ if self.expire_at is not None and now >= self.expire_at:
599
+ self.expire_at = None
600
+ self._hang_up()
601
+ if self.answer_at is not None and now >= self.answer_at:
602
+ self.answer_at = None
603
+ self._answer()
604
+ if self.unacked and now >= self.resend_at:
605
+ self.sock.sendto(self.unacked, self.addr)
606
+ self.interval = min(self.interval * 2, sip.T2)
607
+ self.resend_at = now + self.interval
608
+ if self.end_at is not None and now >= self.end_at:
609
+ self.done = True
610
+
611
+ def _since_turn(self, seconds: float | None) -> bool:
612
+ """The caller's first turn ended at least `seconds` ago."""
613
+ r = self.responder
614
+ if r is None or not r.ends or seconds is None:
615
+ return False
616
+ return CLOCK.now() >= r.ends[0] + seconds * 1e9
617
+
618
+ def _transfer(self) -> None:
619
+ """Blind transfer (RFC 5589): REFER the caller to an operator."""
620
+ assert self.dialog is not None
621
+ local = self.dialog.local_ip
622
+ headers = [("Refer-To", f"<{OPERATOR}>"), ("Referred-By", f"<sip:agent@{local}>")]
623
+ self.refer = self.dialog.request("REFER", headers=headers)
624
+ self.dialog.send(self.refer)
625
+
626
+ def stop(self) -> None:
627
+ for t in self.threads:
628
+ t.stop.set()
629
+ for t in self.threads:
630
+ t.join()
631
+
632
+
633
+ def serve(
634
+ ready: Path,
635
+ sip_port: int = 5060,
636
+ rtp_port: int = 40000,
637
+ seed: int = 1,
638
+ real: bool = False,
639
+ replies: Path | None = None,
640
+ barge: bool = False,
641
+ case: Behaviour | None = None,
642
+ tls: Path | None = None,
643
+ ) -> dict[str, Any]:
644
+ """Answer one call; return its per-turn ground truth. Touches `ready` once listening.
645
+
646
+ `real`: reply with speech (corpus clips, or the WAVs in `replies`), not the synthetic voice.
647
+ `barge`: reply with the long corpus clips and follow the barge-in plan.
648
+ `case`: answer no turns; behave as a conformance case asks (short then long corpus clips).
649
+ `tls`: a directory with server.crt and server.key; SIP over TLS on 5061 too.
650
+ """
651
+ real = real or barge
652
+ rng = random.Random(seed)
653
+ delays = [rng.uniform(*(SPEECH_DELAY_RANGE if real else DELAY_RANGE)) for _ in range(1000)]
654
+ clips = speech_replies(seed, replies, long=barge) if real else None
655
+ greeting = 0
656
+ if case:
657
+ delays, short = [], speech_replies(seed)
658
+ clips, greeting = (
659
+ short + speech_replies(seed, long=True),
660
+ len(short) * (case.greeting == "long"),
661
+ )
662
+ plan = bargein.plan(seed) if barge else None
663
+ # case callers speak real speech too, whose pauses would end a turn mid-sentence
664
+ hangover = SPEECH_HANGOVER_NS if real or case else HANGOVER_NS
665
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
666
+ sock.bind(("0.0.0.0", sip_port))
667
+ streams: list[sip.Tcp] = [sip.Tcp(sip_port)] if (case or Behaviour()).tcp else []
668
+ if tls:
669
+ ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
670
+ ctx.load_cert_chain(tls / "server.crt", tls / "server.key")
671
+ streams.append(sip.Tls(sip.TLS_PORT, server=ctx))
672
+ rtp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
673
+ rtp.bind(("0.0.0.0", rtp_port))
674
+ ready.touch()
675
+
676
+ def responder(pt: int, seq0: int) -> Responder:
677
+ return Responder(pt, seq0, delays, clips, hangover, plan)
678
+
679
+ call = Uas(sock, rtp, case or Behaviour(), responder, greeting)
680
+ sock.settimeout(0)
681
+ for t in streams:
682
+ t.settimeout(0)
683
+ while not call.done:
684
+ ready_socks = select.select(
685
+ [sock, *(s for t in streams for s in t.sockets())], [], [], 0.02
686
+ )
687
+ try:
688
+ if sock in ready_socks[0]:
689
+ data, addr = sock.recvfrom(65535)
690
+ call.handle(sip.parse(data), addr, sock)
691
+ except BlockingIOError:
692
+ pass
693
+ for t in streams: # each, even unwoken: a message may wait read already
694
+ try:
695
+ data, addr = t.recvfrom()
696
+ except TimeoutError:
697
+ continue
698
+ call.handle(sip.parse(data), addr, t)
699
+ call.tick()
700
+ call.stop()
701
+ r = call.responder
702
+ return call.meta | {
703
+ "seed": seed,
704
+ "turns": r.turns if r else [],
705
+ "digits": r.digits if r else [],
706
+ "send_errors": call.sender.send_errors if call.sender else [],
707
+ }
708
+
709
+
710
+ def operator(ready: Path, sip_port: int = 5060) -> None:
711
+ """The transfer target: takes any number of calls, each on its own RTP socket; rings for a
712
+ second, answers and says a few words, or is busy when the INVITE asks (BUSY). Runs until
713
+ killed; touches `ready` once listening."""
714
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
715
+ sock.bind(("0.0.0.0", sip_port))
716
+ sock.settimeout(0.02)
717
+ clips = speech_replies(1)[:2] # encoded per call: a greeting is all it says
718
+ calls: dict[str, Uas] = {}
719
+ ready.touch()
720
+ while True:
721
+ try:
722
+ data, addr = sock.recvfrom(65535)
723
+ except TimeoutError:
724
+ pass
725
+ else:
726
+ msg = sip.parse(data)
727
+ call_id = msg.get("call-id")
728
+ if call_id not in calls and msg.method == "INVITE" and not sip.tag(msg.get("to")):
729
+ busy = msg.get(BUSY[0]).lower() == BUSY[1]
730
+ case = Behaviour(busy=True) if busy else Behaviour(ring_s=1.0, greet_s=0.5)
731
+ rtp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
732
+ rtp.bind(("0.0.0.0", 0))
733
+ calls[call_id] = Uas(
734
+ sock, rtp, case, lambda pt, seq0: Responder(pt, seq0, [], clips), 0
735
+ )
736
+ print(f"operator: {call_id} {'busy' if busy else 'answers'}", flush=True)
737
+ if call := calls.get(call_id):
738
+ call.handle(msg, addr)
739
+ elif msg.method not in ("", "ACK"):
740
+ sock.sendto(sip.response(msg, 481, "Call Does Not Exist").render(), addr)
741
+ for call_id, call in list(calls.items()):
742
+ call.tick()
743
+ if call.done:
744
+ call.stop()
745
+ call.rtp.close()
746
+ del calls[call_id]