@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.
@@ -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>;
@@ -32,6 +32,7 @@ export const KNOWN_METHODS = new Set([
32
32
  "bootstrap",
33
33
  "interactiveSetup",
34
34
  "run",
35
+ "fakeFunctions",
35
36
  "eval",
36
37
  "setRecordSecrets",
37
38
  "teardown",
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
  *
@@ -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
+ };