@specific.dev/spectest 0.111.0 → 0.113.0
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.
- package/README.md +41 -0
- package/dist/components/replay-websocket.d.ts +29 -3
- package/dist/components/replay-websocket.js +157 -25
- package/dist/components/replayFake.d.ts +4 -1
- package/dist/daemon.js +30 -0
- package/dist/harness/fake-functions.d.ts +18 -0
- package/dist/harness/fake-functions.js +82 -0
- package/dist/harness/function-parameters.d.ts +9 -0
- package/dist/harness/function-parameters.js +42 -0
- package/dist/harness/phone.py +427 -0
- package/dist/harness/phone_recording.py +95 -0
- package/dist/harness/protocol.d.ts +1 -1
- package/dist/harness/protocol.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/phone.d.ts +59 -0
- package/dist/phone.js +343 -0
- package/dist/recorder.d.ts +10 -0
- package/package.json +2 -1
- package/src/components/replay-websocket.ts +154 -24
- package/src/components/replayFake-handler.test.ts +5 -0
- package/src/components/replayFake-websocket.test.ts +166 -0
- package/src/components/replayFake.ts +4 -1
- package/src/daemon.ts +28 -0
- package/src/fake-functions.test.ts +62 -0
- package/src/harness/fake-functions.test.ts +77 -0
- package/src/harness/fake-functions.ts +78 -0
- package/src/harness/function-parameters.test.ts +45 -0
- package/src/harness/function-parameters.ts +43 -0
- package/src/harness/phone.py +427 -0
- package/src/harness/phone_recording.py +95 -0
- package/src/harness/protocol.ts +2 -0
- package/src/index.ts +8 -0
- package/src/phone.test.ts +474 -0
- package/src/phone.ts +330 -0
- package/src/recorder.ts +1 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""SDK-owned JSON-lines adapter for the golden's PJSUA2 and Supertonic runtime.
|
|
2
|
+
|
|
3
|
+
SIP runs exclusively on this thread; synthesis runs in a background thread so
|
|
4
|
+
INVITE/BYE/retransmissions continue to be serviced while ONNX is busy.
|
|
5
|
+
"""
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import wave
|
|
15
|
+
import math
|
|
16
|
+
from array import array
|
|
17
|
+
from phone_recording import StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
18
|
+
|
|
19
|
+
# PJSIP writes directly to fd 1. Keep the protocol on a private duplicate.
|
|
20
|
+
protocol = os.fdopen(os.dup(1), "w", buffering=1)
|
|
21
|
+
os.dup2(2, 1)
|
|
22
|
+
import pjsua2 as pj
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configure_codecs(endpoint):
|
|
27
|
+
"""Offer every installed codec, preferring Opus then higher sample rates."""
|
|
28
|
+
def preference(codec):
|
|
29
|
+
name, rate, channels = codec.codecId.lower().split("/")
|
|
30
|
+
return (name == "opus", int(rate), channels == "1",
|
|
31
|
+
name in ("g722", "pcma", "pcmu"), codec.priority)
|
|
32
|
+
|
|
33
|
+
for index, codec in enumerate(sorted(endpoint.codecEnum2(), key=preference, reverse=True)):
|
|
34
|
+
endpoint.codecSetPriority(codec.codecId, max(1, 240 - index))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IncomingAudio(pj.AudioMediaPort):
|
|
38
|
+
"""Observe only received PCM; outgoing speech never resets this timer."""
|
|
39
|
+
def __init__(self):
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.last_voice = time.monotonic()
|
|
42
|
+
fmt = pj.MediaFormatAudio()
|
|
43
|
+
fmt.type = pj.PJMEDIA_TYPE_AUDIO
|
|
44
|
+
fmt.id = pj.PJMEDIA_FORMAT_L16
|
|
45
|
+
fmt.clockRate = 16000
|
|
46
|
+
fmt.channelCount = 1
|
|
47
|
+
fmt.bitsPerSample = 16
|
|
48
|
+
fmt.frameTimeUsec = 20000
|
|
49
|
+
self.createPort("incoming-silence", fmt)
|
|
50
|
+
|
|
51
|
+
def onFrameReceived(self, frame):
|
|
52
|
+
samples = array("h", bytes(frame.buf))
|
|
53
|
+
if samples and sum(sample * sample for sample in samples) / len(samples) > 250 ** 2:
|
|
54
|
+
self.last_voice = time.monotonic()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Recording(pj.AudioMediaPort):
|
|
58
|
+
"""The recording and every clip boundary share the same PCM sample clock.
|
|
59
|
+
|
|
60
|
+
The media clock can fall behind monotonic time under load (notably while
|
|
61
|
+
Python imports the TTS runtime). Wall time is not a position in the recording.
|
|
62
|
+
"""
|
|
63
|
+
def __init__(self, path):
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.frames = 0
|
|
66
|
+
self.lock = threading.Lock()
|
|
67
|
+
self.tracks = {"callerPeaks": [], "recipientPeaks": []}
|
|
68
|
+
self.audio = StreamingRecording(path)
|
|
69
|
+
self.closed = False
|
|
70
|
+
fmt = pj.MediaFormatAudio()
|
|
71
|
+
fmt.type = pj.PJMEDIA_TYPE_AUDIO
|
|
72
|
+
fmt.id = pj.PJMEDIA_FORMAT_L16
|
|
73
|
+
fmt.clockRate = MEDIA_RATE
|
|
74
|
+
fmt.channelCount = 1
|
|
75
|
+
fmt.bitsPerSample = 16
|
|
76
|
+
fmt.frameTimeUsec = 20000
|
|
77
|
+
self.createPort("call-recording", fmt)
|
|
78
|
+
|
|
79
|
+
def onFrameReceived(self, frame):
|
|
80
|
+
pcm = bytes(frame.buf)
|
|
81
|
+
with self.lock:
|
|
82
|
+
if not self.closed:
|
|
83
|
+
self.audio.write(pcm)
|
|
84
|
+
self.frames += len(pcm) // 2
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def offset(self):
|
|
88
|
+
with self.lock:
|
|
89
|
+
return self.frames / MEDIA_RATE
|
|
90
|
+
|
|
91
|
+
def observe(self, side, pcm):
|
|
92
|
+
samples = array("h", pcm)
|
|
93
|
+
if not samples:
|
|
94
|
+
return
|
|
95
|
+
peak = max(abs(min(samples)), abs(max(samples))) / 32768
|
|
96
|
+
with self.lock:
|
|
97
|
+
if self.closed:
|
|
98
|
+
return
|
|
99
|
+
# Meter ports follow the mixed sink in the conference bridge.
|
|
100
|
+
# Locate this 20ms frame using the recording's PCM clock, never wall time.
|
|
101
|
+
first = max(0, self.frames - len(samples)) // PEAK_SAMPLES
|
|
102
|
+
last = max(0, self.frames - 1) // PEAK_SAMPLES
|
|
103
|
+
peaks = self.tracks[side]
|
|
104
|
+
peaks.extend([0.0] * max(0, last + 1 - len(peaks)))
|
|
105
|
+
for index in range(first, last + 1):
|
|
106
|
+
peaks[index] = max(peaks[index], round(peak, 4))
|
|
107
|
+
|
|
108
|
+
def finish(self):
|
|
109
|
+
with self.lock:
|
|
110
|
+
self.closed = True
|
|
111
|
+
return self.audio.finish()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WaveformMeter(pj.AudioMediaPort):
|
|
115
|
+
"""Observe a single direction without creating another audio recording."""
|
|
116
|
+
def __init__(self, recorder, side):
|
|
117
|
+
super().__init__()
|
|
118
|
+
self.recorder, self.side = recorder, side
|
|
119
|
+
fmt = pj.MediaFormatAudio()
|
|
120
|
+
fmt.type = pj.PJMEDIA_TYPE_AUDIO
|
|
121
|
+
fmt.id = pj.PJMEDIA_FORMAT_L16
|
|
122
|
+
fmt.clockRate = MEDIA_RATE
|
|
123
|
+
fmt.channelCount = 1
|
|
124
|
+
fmt.bitsPerSample = 16
|
|
125
|
+
fmt.frameTimeUsec = 20000
|
|
126
|
+
self.createPort(side, fmt)
|
|
127
|
+
|
|
128
|
+
def onFrameReceived(self, frame):
|
|
129
|
+
self.recorder.observe(self.side, bytes(frame.buf))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Call(pj.Call):
|
|
133
|
+
def __init__(self, account, endpoint, directory, session_id):
|
|
134
|
+
super().__init__(account)
|
|
135
|
+
self.endpoint = endpoint
|
|
136
|
+
self.session_id = session_id
|
|
137
|
+
self.path = os.path.join(directory, session_id + ".webm")
|
|
138
|
+
self.sealed = False
|
|
139
|
+
self.capture_error = None
|
|
140
|
+
self.recorder = Recording(self.path)
|
|
141
|
+
self.caller_meter = WaveformMeter(self.recorder, "callerPeaks")
|
|
142
|
+
self.recipient_meter = WaveformMeter(self.recorder, "recipientPeaks")
|
|
143
|
+
# Keep the sink clocked even during silence, synthesis, and app waits.
|
|
144
|
+
self.clock = endpoint.audDevManager().getCaptureDevMedia()
|
|
145
|
+
self.clock.startTransmit(self.recorder)
|
|
146
|
+
self.started_at_ms = time.time() * 1000
|
|
147
|
+
self.started = time.monotonic()
|
|
148
|
+
self.connected = False
|
|
149
|
+
self.disconnected = False
|
|
150
|
+
self.status = 0
|
|
151
|
+
self.media = None
|
|
152
|
+
self.incoming = IncomingAudio()
|
|
153
|
+
|
|
154
|
+
def onCallState(self, _):
|
|
155
|
+
info = self.getInfo()
|
|
156
|
+
self.connected = info.state == pj.PJSIP_INV_STATE_CONFIRMED
|
|
157
|
+
self.disconnected = info.state == pj.PJSIP_INV_STATE_DISCONNECTED
|
|
158
|
+
self.status = info.lastStatusCode
|
|
159
|
+
|
|
160
|
+
def onCallMediaState(self, _):
|
|
161
|
+
self.media = None
|
|
162
|
+
info = self.getInfo()
|
|
163
|
+
for item in info.media:
|
|
164
|
+
if item.type == pj.PJMEDIA_TYPE_AUDIO and item.status == pj.PJSUA_CALL_MEDIA_ACTIVE:
|
|
165
|
+
self.media = self.getAudioMedia(item.index)
|
|
166
|
+
try:
|
|
167
|
+
self.media.startTransmit(self.recorder)
|
|
168
|
+
self.media.startTransmit(self.recipient_meter)
|
|
169
|
+
self.media.startTransmit(self.incoming)
|
|
170
|
+
except Exception:
|
|
171
|
+
self.capture_error = "Could not connect incoming call audio to the recorder"
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def finish_recording(self):
|
|
176
|
+
if self.sealed:
|
|
177
|
+
return
|
|
178
|
+
self.sealed = True
|
|
179
|
+
result = {"event": "recording", "sessionId": self.session_id,
|
|
180
|
+
"startedAtMs": self.started_at_ms, "duration": 0, "bytes": 0,
|
|
181
|
+
"contentType": "audio/webm", "peaks": []}
|
|
182
|
+
try:
|
|
183
|
+
self.clock.stopTransmit(self.recorder)
|
|
184
|
+
if self.media is not None:
|
|
185
|
+
try:
|
|
186
|
+
self.media.stopTransmit(self.recorder)
|
|
187
|
+
self.media.stopTransmit(self.recipient_meter)
|
|
188
|
+
except Exception:
|
|
189
|
+
pass # The remote peer may already have removed its port.
|
|
190
|
+
result.update(self.recorder.finish())
|
|
191
|
+
peaks = result["peaks"]
|
|
192
|
+
for side, track in self.recorder.tracks.items():
|
|
193
|
+
result[side] = (track + [0.0] * max(0, len(peaks) - len(track)))[:len(peaks)]
|
|
194
|
+
if self.capture_error:
|
|
195
|
+
result["error"] = self.capture_error
|
|
196
|
+
except Exception:
|
|
197
|
+
result["error"] = "Call recording could not be finalized"
|
|
198
|
+
protocol.write(json.dumps(result) + "\n")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Player(pj.AudioMediaPlayer):
|
|
202
|
+
def __init__(self):
|
|
203
|
+
super().__init__()
|
|
204
|
+
self.done = False
|
|
205
|
+
|
|
206
|
+
def onEof2(self):
|
|
207
|
+
self.done = True
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def synthesize(data, options, path):
|
|
211
|
+
import numpy as np
|
|
212
|
+
from supertonic import TTS
|
|
213
|
+
# Retain the loaded model for subsequent say calls on this phone.
|
|
214
|
+
if not hasattr(synthesize, "tts"):
|
|
215
|
+
synthesize.tts = TTS(model="supertonic-3", model_dir="/opt/spectest/phone/models",
|
|
216
|
+
auto_download=False, intra_op_num_threads=2, inter_op_num_threads=1)
|
|
217
|
+
tts = synthesize.tts
|
|
218
|
+
np.random.seed(0)
|
|
219
|
+
style = tts.get_voice_style(data.get("voice", options.get("voice", "F1")))
|
|
220
|
+
# The high-level synthesize() API splits paragraphs/sentences. Send the
|
|
221
|
+
# complete utterance to the model in one invocation instead.
|
|
222
|
+
from supertonic.config import AVAILABLE_LANGUAGES
|
|
223
|
+
language = data.get("language", "en")
|
|
224
|
+
if language not in AVAILABLE_LANGUAGES:
|
|
225
|
+
raise ValueError(f"Unsupported speech language: {language}")
|
|
226
|
+
text = data["text"]
|
|
227
|
+
valid, unsupported = tts.model.text_processor.validate_text(text)
|
|
228
|
+
if not valid:
|
|
229
|
+
raise ValueError(f"Speech contains {len(unsupported)} unsupported characters")
|
|
230
|
+
audio, duration = tts.model([text], style, total_step=5,
|
|
231
|
+
speed=data.get("speed", 1.05), lang=language)
|
|
232
|
+
audio = audio.reshape(-1)[:int(float(duration[0]) * tts.sample_rate)]
|
|
233
|
+
if len(audio) > tts.sample_rate * 60:
|
|
234
|
+
raise ValueError("Speech exceeds the 60 second limit; use a shorter utterance")
|
|
235
|
+
# PJSIP's WAV player accepts mono 16-bit PCM and resamples to the SIP codec.
|
|
236
|
+
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2")
|
|
237
|
+
with wave.open(path, "wb") as out:
|
|
238
|
+
out.setnchannels(1)
|
|
239
|
+
out.setsampwidth(2)
|
|
240
|
+
out.setframerate(tts.sample_rate)
|
|
241
|
+
out.writeframes(pcm.tobytes())
|
|
242
|
+
return {"duration": len(audio) / tts.sample_rate}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def main():
|
|
247
|
+
inbox = queue.Queue()
|
|
248
|
+
ended = threading.Event()
|
|
249
|
+
|
|
250
|
+
def read():
|
|
251
|
+
try:
|
|
252
|
+
for line in sys.stdin:
|
|
253
|
+
inbox.put(json.loads(line))
|
|
254
|
+
finally:
|
|
255
|
+
ended.set()
|
|
256
|
+
threading.Thread(target=read, daemon=True).start()
|
|
257
|
+
endpoint, account, call = None, None, None
|
|
258
|
+
options = {}
|
|
259
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
260
|
+
|
|
261
|
+
def pump():
|
|
262
|
+
if ended.is_set():
|
|
263
|
+
raise EOFError()
|
|
264
|
+
if endpoint:
|
|
265
|
+
endpoint.libHandleEvents(10)
|
|
266
|
+
if call and call.disconnected:
|
|
267
|
+
call.finish_recording()
|
|
268
|
+
elif call and time.monotonic() - call.started > 900:
|
|
269
|
+
call.capture_error = "Call exceeded the 15 minute recording limit"
|
|
270
|
+
call.hangup(pj.CallOpParam())
|
|
271
|
+
else:
|
|
272
|
+
time.sleep(0.01)
|
|
273
|
+
|
|
274
|
+
def wait(predicate, timeout, live=False):
|
|
275
|
+
deadline = time.monotonic() + timeout
|
|
276
|
+
while not predicate():
|
|
277
|
+
if live and call.disconnected:
|
|
278
|
+
raise ValueError(f"SIP call ended ({call.status})")
|
|
279
|
+
if time.monotonic() >= deadline:
|
|
280
|
+
raise ValueError("SIP operation timed out")
|
|
281
|
+
pump()
|
|
282
|
+
|
|
283
|
+
def hangup():
|
|
284
|
+
if call and not call.disconnected:
|
|
285
|
+
call.hangup(pj.CallOpParam())
|
|
286
|
+
wait(lambda: call.disconnected, 5)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
while not ended.is_set():
|
|
290
|
+
try:
|
|
291
|
+
data = inbox.get_nowait()
|
|
292
|
+
except queue.Empty:
|
|
293
|
+
pump()
|
|
294
|
+
continue
|
|
295
|
+
result = {}
|
|
296
|
+
try:
|
|
297
|
+
action = data["action"]
|
|
298
|
+
if call and action != "call":
|
|
299
|
+
protocol.write(json.dumps({"event": "operation_started", "id": data["id"],
|
|
300
|
+
"offset": call.recorder.offset}) + "\n")
|
|
301
|
+
if action == "open":
|
|
302
|
+
options = data
|
|
303
|
+
endpoint = pj.Endpoint()
|
|
304
|
+
endpoint.libCreate()
|
|
305
|
+
cfg = pj.EpConfig()
|
|
306
|
+
cfg.uaConfig.threadCnt = 0
|
|
307
|
+
cfg.uaConfig.mainThreadOnly = True
|
|
308
|
+
cfg.logConfig.level = 0
|
|
309
|
+
cfg.logConfig.consoleLevel = 0
|
|
310
|
+
cfg.medConfig.clockRate = MEDIA_RATE
|
|
311
|
+
endpoint.libInit(cfg)
|
|
312
|
+
transport = pj.TransportConfig()
|
|
313
|
+
transport.port = 0
|
|
314
|
+
kind = pj.PJSIP_TRANSPORT_TCP if data.get("transport") == "tcp" else pj.PJSIP_TRANSPORT_UDP
|
|
315
|
+
endpoint.transportCreate(kind, transport)
|
|
316
|
+
endpoint.libStart()
|
|
317
|
+
endpoint.audDevManager().setNullDev()
|
|
318
|
+
configure_codecs(endpoint)
|
|
319
|
+
ac = pj.AccountConfig()
|
|
320
|
+
ac.idUri = data["from"]
|
|
321
|
+
ac.sipConfig.proxies.append("sip:" + data["endpoint"] + ";lr;transport=" + data.get("transport", "udp"))
|
|
322
|
+
if data.get("username"):
|
|
323
|
+
ac.sipConfig.authCreds.append(pj.AuthCredInfo("digest", "*", data["username"], 0, data.get("password", "")))
|
|
324
|
+
account = pj.Account()
|
|
325
|
+
account.create(ac)
|
|
326
|
+
elif action == "call":
|
|
327
|
+
if call and not call.disconnected:
|
|
328
|
+
raise ValueError("Already in a call; hang up before dialing again")
|
|
329
|
+
if call:
|
|
330
|
+
call.finish_recording()
|
|
331
|
+
call = Call(account, endpoint, options["directory"], data["sessionId"])
|
|
332
|
+
protocol.write(json.dumps({"event": "operation_started", "id": data["id"],
|
|
333
|
+
"offset": 0}) + "\n")
|
|
334
|
+
uri = data["destination"]
|
|
335
|
+
if not uri.startswith("sip:"):
|
|
336
|
+
uri = "sip:" + uri + "@" + options["endpoint"]
|
|
337
|
+
params = pj.CallOpParam(True)
|
|
338
|
+
params.opt.audioCount = 1
|
|
339
|
+
params.opt.videoCount = 0
|
|
340
|
+
call.makeCall(uri, params)
|
|
341
|
+
wait(lambda: call.connected and call.media is not None, options.get("timeoutMs", 30000) / 1000, live=True)
|
|
342
|
+
elif action == "say":
|
|
343
|
+
if not call or not call.connected or call.media is None:
|
|
344
|
+
raise ValueError("No connected call; call() before say()")
|
|
345
|
+
with tempfile.TemporaryDirectory(prefix="spectest-phone-") as tmp:
|
|
346
|
+
path = os.path.join(tmp, "speech.wav")
|
|
347
|
+
future = executor.submit(synthesize, data, options, path)
|
|
348
|
+
wait(future.done, 110, live=True)
|
|
349
|
+
result = future.result()
|
|
350
|
+
player = Player()
|
|
351
|
+
player.createPlayer(path, pj.PJMEDIA_FILE_NO_LOOP)
|
|
352
|
+
media = call.media
|
|
353
|
+
# Mix what we actually send with the incoming stream in
|
|
354
|
+
# the same continuously clocked recorder.
|
|
355
|
+
speech_offset = call.recorder.offset
|
|
356
|
+
player.startTransmit(call.recorder)
|
|
357
|
+
player.startTransmit(call.caller_meter)
|
|
358
|
+
player.startTransmit(media)
|
|
359
|
+
# Report immediately so interrupted/failed playback also
|
|
360
|
+
# keeps its true start. This is relative to the recorder,
|
|
361
|
+
# measured in recorded PCM frames, not elapsed wall time.
|
|
362
|
+
protocol.write(json.dumps({"event": "speech_started", "id": data["id"],
|
|
363
|
+
"sessionId": call.session_id, "offset": speech_offset}) + "\n")
|
|
364
|
+
try:
|
|
365
|
+
wait(lambda: player.done, result["duration"] + 5, live=True)
|
|
366
|
+
result["offset"] = call.recorder.offset
|
|
367
|
+
finally:
|
|
368
|
+
player.stopTransmit(media)
|
|
369
|
+
if call.recorder:
|
|
370
|
+
player.stopTransmit(call.recorder)
|
|
371
|
+
player.stopTransmit(call.caller_meter)
|
|
372
|
+
del player
|
|
373
|
+
elif action == "wait_silence":
|
|
374
|
+
if not call or call.disconnected or call.media is None:
|
|
375
|
+
raise ValueError("No connected call; cannot wait for silence")
|
|
376
|
+
silence = float(data["silenceMs"]) / 1000
|
|
377
|
+
timeout = float(data["timeoutMs"]) / 1000
|
|
378
|
+
if not math.isfinite(silence) or not math.isfinite(timeout) or silence <= 0 or timeout <= 0:
|
|
379
|
+
raise ValueError("Silence duration and timeout must be positive")
|
|
380
|
+
# A fresh quiet window starts AFTER the application predicate
|
|
381
|
+
# succeeds; quiet heard before that does not satisfy this wait.
|
|
382
|
+
began = time.monotonic()
|
|
383
|
+
wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
|
|
384
|
+
timeout, live=True)
|
|
385
|
+
result["offset"] = call.recorder.offset
|
|
386
|
+
elif action == "position":
|
|
387
|
+
if not call:
|
|
388
|
+
raise ValueError("No call recording")
|
|
389
|
+
result["offset"] = call.recorder.offset
|
|
390
|
+
elif action == "hangup":
|
|
391
|
+
hangup()
|
|
392
|
+
if call:
|
|
393
|
+
call.finish_recording()
|
|
394
|
+
else:
|
|
395
|
+
raise ValueError("Unknown phone operation")
|
|
396
|
+
protocol.write(json.dumps({"id": data["id"], **result}) + "\n")
|
|
397
|
+
except EOFError:
|
|
398
|
+
raise
|
|
399
|
+
except Exception as error:
|
|
400
|
+
# PJSIP error.info() may include SIP identities; report status only.
|
|
401
|
+
message = str(error) if isinstance(error, ValueError) else f"Phone {data['action']} failed ({type(error).__name__})"
|
|
402
|
+
if call:
|
|
403
|
+
result["offset"] = call.recorder.offset
|
|
404
|
+
protocol.write(json.dumps({"id": data["id"], **result, "error": message}) + "\n")
|
|
405
|
+
except EOFError:
|
|
406
|
+
pass
|
|
407
|
+
finally:
|
|
408
|
+
if call and not call.disconnected:
|
|
409
|
+
try:
|
|
410
|
+
call.hangup(pj.CallOpParam())
|
|
411
|
+
for _ in range(20):
|
|
412
|
+
endpoint.libHandleEvents(10)
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
if call:
|
|
416
|
+
call.finish_recording()
|
|
417
|
+
call = None
|
|
418
|
+
if account:
|
|
419
|
+
account.shutdown()
|
|
420
|
+
account = None
|
|
421
|
+
if endpoint:
|
|
422
|
+
endpoint.libDestroy()
|
|
423
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
if __name__ == "__main__":
|
|
427
|
+
main()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Continuous Opus capture. No encoder I/O runs on the SIP media callback."""
|
|
2
|
+
from array import array
|
|
3
|
+
import os
|
|
4
|
+
import queue
|
|
5
|
+
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
MEDIA_RATE = 48000
|
|
10
|
+
PEAK_SAMPLES = MEDIA_RATE // 20
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StreamingRecording:
|
|
14
|
+
def __init__(self, path):
|
|
15
|
+
self.path = path
|
|
16
|
+
self.peaks = []
|
|
17
|
+
self.frames = 0
|
|
18
|
+
self.error = None
|
|
19
|
+
self.closed = threading.Event()
|
|
20
|
+
# Two seconds of 20ms PCM frames. A stalled encoder fails capture
|
|
21
|
+
# explicitly instead of blocking SIP or silently dropping audio.
|
|
22
|
+
self.pending = queue.Queue(maxsize=100)
|
|
23
|
+
self.encoder = subprocess.Popen([
|
|
24
|
+
os.environ.get("SPECTEST_PHONE_FFMPEG", "ffmpeg"),
|
|
25
|
+
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
|
26
|
+
"-f", "s16le", "-ar", str(MEDIA_RATE), "-ac", "1", "-i", "pipe:0",
|
|
27
|
+
"-map_metadata", "-1", "-c:a", "libopus", "-b:a", "48k",
|
|
28
|
+
"-vbr", "on", "-application", "audio", "-threads", "1",
|
|
29
|
+
"-f", "webm", path,
|
|
30
|
+
], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
31
|
+
self.writer = threading.Thread(target=self._encode, daemon=True)
|
|
32
|
+
self.writer.start()
|
|
33
|
+
|
|
34
|
+
def write(self, pcm):
|
|
35
|
+
if self.closed.is_set() or self.error:
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
self.pending.put_nowait(pcm)
|
|
39
|
+
except queue.Full:
|
|
40
|
+
self.error = "Phone recording encoder could not keep up with audio"
|
|
41
|
+
|
|
42
|
+
def _encode(self):
|
|
43
|
+
try:
|
|
44
|
+
while not self.closed.is_set() or not self.pending.empty():
|
|
45
|
+
try:
|
|
46
|
+
pcm = self.pending.get(timeout=0.02)
|
|
47
|
+
except queue.Empty:
|
|
48
|
+
continue
|
|
49
|
+
samples = array("h", pcm)
|
|
50
|
+
# Accumulate exact 50ms peaks before compression, including
|
|
51
|
+
# windows split across the media callback's 20ms frames.
|
|
52
|
+
offset = 0
|
|
53
|
+
while offset < len(samples):
|
|
54
|
+
index = self.frames // PEAK_SAMPLES
|
|
55
|
+
count = min(PEAK_SAMPLES - self.frames % PEAK_SAMPLES, len(samples) - offset)
|
|
56
|
+
part = samples[offset:offset + count]
|
|
57
|
+
peak = round(max(abs(min(part)), abs(max(part))) / 32768, 4)
|
|
58
|
+
if index == len(self.peaks):
|
|
59
|
+
self.peaks.append(peak)
|
|
60
|
+
else:
|
|
61
|
+
self.peaks[index] = max(self.peaks[index], peak)
|
|
62
|
+
offset += count
|
|
63
|
+
self.frames += count
|
|
64
|
+
self.encoder.stdin.write(pcm)
|
|
65
|
+
self.encoder.stdin.close()
|
|
66
|
+
if self.encoder.wait(timeout=2) != 0:
|
|
67
|
+
self.error = "Phone recording encoder failed"
|
|
68
|
+
except Exception:
|
|
69
|
+
self.error = "Phone recording encoder failed"
|
|
70
|
+
finally:
|
|
71
|
+
if self.encoder.poll() is None:
|
|
72
|
+
self.encoder.kill()
|
|
73
|
+
self.encoder.wait()
|
|
74
|
+
try:
|
|
75
|
+
self.encoder.stdin.close()
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def finish(self):
|
|
80
|
+
started = time.monotonic()
|
|
81
|
+
self.closed.set()
|
|
82
|
+
self.writer.join(timeout=5)
|
|
83
|
+
if self.writer.is_alive():
|
|
84
|
+
self.error = "Phone recording encoder did not finish"
|
|
85
|
+
self.encoder.kill()
|
|
86
|
+
self.writer.join(timeout=1)
|
|
87
|
+
if self.error:
|
|
88
|
+
raise RuntimeError(self.error)
|
|
89
|
+
return {
|
|
90
|
+
"duration": self.frames / MEDIA_RATE,
|
|
91
|
+
"bytes": os.path.getsize(self.path),
|
|
92
|
+
"peaks": self.peaks,
|
|
93
|
+
"contentType": "audio/webm",
|
|
94
|
+
"encodingFinalizeMs": round((time.monotonic() - started) * 1000, 1),
|
|
95
|
+
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
/** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
|
|
24
24
|
export declare const PROTOCOL_VERSION = 1;
|
|
25
25
|
/** Methods either side can send. */
|
|
26
|
-
export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "interactiveSetup" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
|
|
26
|
+
export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "interactiveSetup" | "run" | "fakeFunctions" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
|
|
27
27
|
/** Methods this build knows how to dispatch. A method outside this set still
|
|
28
28
|
* *parses* — it is answered with an error, never dropped. */
|
|
29
29
|
export declare const KNOWN_METHODS: ReadonlySet<string>;
|
package/dist/harness/protocol.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
2
|
+
import type { Phone, PhoneOptions } from "./phone.js";
|
|
1
3
|
import type { Android, AndroidApp } from "./android.js";
|
|
2
4
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
3
5
|
import { strict as nodeAssert } from "node:assert";
|
|
@@ -1283,6 +1285,12 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
|
|
|
1283
1285
|
* That is what makes "this tool is protected" a test.
|
|
1284
1286
|
*/
|
|
1285
1287
|
mcp(url: string, opts?: McpOptions): Promise<Mcp>;
|
|
1288
|
+
/**
|
|
1289
|
+
* Open a SIP phone connected to a service in the environment. Both sides
|
|
1290
|
+
* are recorded continuously for the whole call, including pauses between
|
|
1291
|
+
* operations. Calls close and recordings finalize at case end.
|
|
1292
|
+
*/
|
|
1293
|
+
phone(options: PhoneOptions): Promise<Phone>;
|
|
1286
1294
|
/**
|
|
1287
1295
|
* Open the headless browser. Backed by Chromium-over-CDP inside the VM.
|
|
1288
1296
|
*
|
package/dist/phone.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface PhoneOptions {
|
|
2
|
+
/** SIP server in the environment, e.g. sip:5060. */
|
|
3
|
+
endpoint: string;
|
|
4
|
+
/** Caller identity, e.g. sip:+46700000000@spectest.test. */
|
|
5
|
+
from: string;
|
|
6
|
+
transport?: "udp" | "tcp";
|
|
7
|
+
username?: string;
|
|
8
|
+
password?: string;
|
|
9
|
+
language?: string;
|
|
10
|
+
voice?: "M1" | "M2" | "M3" | "M4" | "M5" | "F1" | "F2" | "F3" | "F4" | "F5";
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CallOptions {
|
|
14
|
+
/** Poll until true or a nonempty transcript string, then wait for incoming silence.
|
|
15
|
+
* A returned string is displayed as this step's recipient response. */
|
|
16
|
+
waitFor?: () => boolean | string | Promise<boolean | string>;
|
|
17
|
+
}
|
|
18
|
+
export interface SpeechOptions extends CallOptions {
|
|
19
|
+
language?: string;
|
|
20
|
+
voice?: PhoneOptions["voice"];
|
|
21
|
+
speed?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface Phone {
|
|
24
|
+
/** Connect, optionally wait for a predicate then silence, and record one step. */
|
|
25
|
+
call(destination: string, options?: CallOptions): Promise<void>;
|
|
26
|
+
/** Send the complete utterance, optionally wait for a predicate then silence, and record one isolated step. */
|
|
27
|
+
say(text: string, options?: SpeechOptions): Promise<void>;
|
|
28
|
+
hangup(): Promise<void>;
|
|
29
|
+
/** Close the phone; also performed on success, failure, and test timeout. */
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/** Metadata only. Audio remains on disk until pulled through phoneRecordingChunk. */
|
|
33
|
+
export interface PhoneRecording {
|
|
34
|
+
id: string;
|
|
35
|
+
startedAtMs: number;
|
|
36
|
+
duration: number;
|
|
37
|
+
bytes: number;
|
|
38
|
+
contentType: "audio/webm";
|
|
39
|
+
encodingFinalizeMs?: number;
|
|
40
|
+
peaks: number[];
|
|
41
|
+
callerPeaks?: number[];
|
|
42
|
+
recipientPeaks?: number[];
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function phoneRecordingChunk(id: string, offset: unknown, limit: unknown): Promise<{
|
|
46
|
+
b64: string;
|
|
47
|
+
offset: number;
|
|
48
|
+
total: number;
|
|
49
|
+
}>;
|
|
50
|
+
export declare function releasePhoneRecording(id: string): Promise<{
|
|
51
|
+
released: boolean;
|
|
52
|
+
}>;
|
|
53
|
+
export declare function phoneScope(caseStart?: number, { record }?: {
|
|
54
|
+
record?: boolean | undefined;
|
|
55
|
+
}): {
|
|
56
|
+
open(options: PhoneOptions): Promise<Phone>;
|
|
57
|
+
close(): Promise<void>;
|
|
58
|
+
finish(): Promise<PhoneRecording[]>;
|
|
59
|
+
};
|