@specific.dev/spectest 0.110.0 → 0.112.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 +18 -3
- package/dist/harness/phone.py +427 -0
- package/dist/harness/phone_recording.py +95 -0
- package/dist/index.d.ts +20 -7
- package/dist/index.js +2 -2
- package/dist/phone.d.ts +59 -0
- package/dist/phone.js +343 -0
- package/dist/recorder.d.ts +10 -0
- package/package.json +1 -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 +18 -3
- package/src/harness/phone.py +427 -0
- package/src/harness/phone_recording.py +95 -0
- package/src/index.ts +17 -8
- package/src/interactive-setup.test.ts +2 -2
- 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
|
+
}
|
package/src/index.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
|
// Spectest SDK. The user's single `spectest/index.ts` calls
|
|
@@ -1959,6 +1961,12 @@ export interface TestContext<
|
|
|
1959
1961
|
* That is what makes "this tool is protected" a test.
|
|
1960
1962
|
*/
|
|
1961
1963
|
mcp(url: string, opts?: McpOptions): Promise<Mcp>;
|
|
1964
|
+
/**
|
|
1965
|
+
* Open a SIP phone connected to a service in the environment. Both sides
|
|
1966
|
+
* are recorded continuously for the whole call, including pauses between
|
|
1967
|
+
* operations. Calls close and recordings finalize at case end.
|
|
1968
|
+
*/
|
|
1969
|
+
phone(options: PhoneOptions): Promise<Phone>;
|
|
1962
1970
|
/**
|
|
1963
1971
|
* Open the headless browser. Backed by Chromium-over-CDP inside the VM.
|
|
1964
1972
|
*
|
|
@@ -2380,22 +2388,23 @@ export interface Project<
|
|
|
2380
2388
|
/**
|
|
2381
2389
|
* Prepare a new interactive environment, after normal setup and the shared
|
|
2382
2390
|
* warm-template snapshot. Runs on both cold starts and warm restores from
|
|
2383
|
-
* the dashboard or `spectest env start`, before
|
|
2391
|
+
* the dashboard (seed checkbox on) or `spectest env start --seed`, before
|
|
2392
|
+
* the environment is ready. Skipped when seeding is not requested.
|
|
2384
2393
|
* Never runs for tests, snapshot forks, or resuming a stopped environment.
|
|
2385
2394
|
* Uses the same context as `setup`; throw to fail the start.
|
|
2386
2395
|
*/
|
|
2387
|
-
|
|
2396
|
+
interactive?: { seed?: ProjectSetupFn<S, F> };
|
|
2388
2397
|
tests?: TestSuite<S, F>;
|
|
2389
2398
|
}
|
|
2390
2399
|
|
|
2391
2400
|
/**
|
|
2392
|
-
* Context handed to the project-level `setup` and `
|
|
2401
|
+
* Context handed to the project-level `setup` and `interactive.seed` hooks: the full
|
|
2393
2402
|
* {@link SpectestContext}, minus only what a timeline gives a test
|
|
2394
2403
|
* (testName, parent, browser/mobile/terminal). Everything a test can do
|
|
2395
2404
|
* to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
|
|
2396
2405
|
* `dnsName`, `certificate`, `startService`, and project-file access via
|
|
2397
2406
|
* `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
|
|
2398
|
-
* state is captured into the shared warm template; `
|
|
2407
|
+
* state is captured into the shared warm template; `interactive.seed`
|
|
2399
2408
|
* state belongs only to the new interactive environment.
|
|
2400
2409
|
*/
|
|
2401
2410
|
export interface ProjectSetupContext<
|
|
@@ -2445,8 +2454,8 @@ export interface ProjectOpts<
|
|
|
2445
2454
|
> {
|
|
2446
2455
|
/** Optional project-level setup. See `Project.setup`. */
|
|
2447
2456
|
setup?: ProjectSetupFn<S, F>;
|
|
2448
|
-
/** Optional interactive-only setup. See `Project.
|
|
2449
|
-
|
|
2457
|
+
/** Optional interactive-only setup. See `Project.interactive`. */
|
|
2458
|
+
interactive?: { seed?: ProjectSetupFn<S, F> };
|
|
2450
2459
|
/** Test cases for this project's suite. */
|
|
2451
2460
|
tests?: TestCase<unknown, S, F>[];
|
|
2452
2461
|
}
|
|
@@ -2511,7 +2520,7 @@ export interface DefinedEnvironment<
|
|
|
2511
2520
|
): TestGroup<P, S, F>;
|
|
2512
2521
|
/** Bundle this environment with a test suite into the project default
|
|
2513
2522
|
* export. Pass tests as a plain array for the common case, or an
|
|
2514
|
-
* options bag to attach `setup` or `
|
|
2523
|
+
* options bag to attach `setup` or `interactive.seed`. (Fakes are declared on
|
|
2515
2524
|
* `defineEnvironment`, not here.) */
|
|
2516
2525
|
project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
|
|
2517
2526
|
project(opts: ProjectOpts<S, F>): Project<S, F>;
|
|
@@ -2663,7 +2672,7 @@ export function defineEnvironment<
|
|
|
2663
2672
|
: (arg ?? {});
|
|
2664
2673
|
const proj: Project<S, F> = { environment: config };
|
|
2665
2674
|
if (opts.setup) proj.setup = opts.setup;
|
|
2666
|
-
if (opts.
|
|
2675
|
+
if (opts.interactive) proj.interactive = opts.interactive;
|
|
2667
2676
|
if (fakes) proj.fakes = fakes;
|
|
2668
2677
|
if (opts.tests && opts.tests.length > 0) {
|
|
2669
2678
|
// Explicit suite (single-file / legacy layout): the tests are listed
|
|
@@ -22,7 +22,7 @@ for (const withHook of [false, true]) {
|
|
|
22
22
|
}});
|
|
23
23
|
export default env.project({
|
|
24
24
|
setup: () => { calls.push("baseline"); },
|
|
25
|
-
${withHook ? `
|
|
25
|
+
${withHook ? `interactive: { seed: async (ctx) => {
|
|
26
26
|
calls.push("interactive");
|
|
27
27
|
console.log("seeding demo data");
|
|
28
28
|
await globalThis.seedGate;
|
|
@@ -35,7 +35,7 @@ for (const withHook of [false, true]) {
|
|
|
35
35
|
if (typeof result.exitCode !== "number" || !result.stdout.includes("seed demo")) {
|
|
36
36
|
throw new Error("exec result not plain: " + JSON.stringify(result));
|
|
37
37
|
}
|
|
38
|
-
},` : ""}
|
|
38
|
+
} },` : ""}
|
|
39
39
|
});
|
|
40
40
|
`);
|
|
41
41
|
const script = `
|