@specific.dev/spectest 0.131.0 → 0.131.1
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/dist/harness/phone.py +18 -11
- package/dist/harness/phone_recording.py +5 -1
- package/package.json +1 -1
- package/src/harness/phone.py +18 -11
- package/src/harness/phone_recording.py +5 -1
- package/src/phone.test.ts +15 -5
package/dist/harness/phone.py
CHANGED
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
import wave
|
|
15
15
|
import math
|
|
16
16
|
from array import array
|
|
17
|
-
from phone_recording import StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
17
|
+
from phone_recording import RecordingError, StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
18
18
|
|
|
19
19
|
# PJSIP writes directly to fd 1. Keep the protocol on a private duplicate.
|
|
20
20
|
protocol = os.fdopen(os.dup(1), "w", buffering=1)
|
|
@@ -179,22 +179,29 @@ class Call(pj.Call):
|
|
|
179
179
|
result = {"event": "recording", "sessionId": self.session_id,
|
|
180
180
|
"startedAtMs": self.started_at_ms, "duration": 0, "bytes": 0,
|
|
181
181
|
"contentType": "audio/webm", "peaks": []}
|
|
182
|
+
errors = [self.capture_error] if self.capture_error else []
|
|
182
183
|
try:
|
|
183
184
|
self.clock.stopTransmit(self.recorder)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
except Exception as error:
|
|
186
|
+
# Native error text can contain SIP identities or credentials.
|
|
187
|
+
errors.append(f"Could not disconnect recording clock ({type(error).__name__})")
|
|
188
|
+
if self.media is not None:
|
|
189
|
+
try:
|
|
190
|
+
self.media.stopTransmit(self.recorder)
|
|
191
|
+
self.media.stopTransmit(self.recipient_meter)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass # The remote peer may already have removed its port.
|
|
194
|
+
try:
|
|
195
|
+
# Always close the sink and reap the encoder, even if detach failed.
|
|
190
196
|
result.update(self.recorder.finish())
|
|
191
197
|
peaks = result["peaks"]
|
|
192
198
|
for side, track in self.recorder.tracks.items():
|
|
193
199
|
result[side] = (track + [0.0] * max(0, len(peaks) - len(track)))[:len(peaks)]
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
except Exception as error:
|
|
201
|
+
detail = str(error) if isinstance(error, RecordingError) else type(error).__name__
|
|
202
|
+
errors.append(f"Call recording could not be finalized: {detail}")
|
|
203
|
+
if errors:
|
|
204
|
+
result["error"] = "; ".join(errors)
|
|
198
205
|
protocol.write(json.dumps(result) + "\n")
|
|
199
206
|
|
|
200
207
|
|
|
@@ -10,6 +10,10 @@ MEDIA_RATE = 48000
|
|
|
10
10
|
PEAK_SAMPLES = MEDIA_RATE // 20
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
class RecordingError(RuntimeError):
|
|
14
|
+
"""SDK-authored capture diagnostics, safe to include in test results."""
|
|
15
|
+
|
|
16
|
+
|
|
13
17
|
class StreamingRecording:
|
|
14
18
|
def __init__(self, path):
|
|
15
19
|
self.path = path
|
|
@@ -85,7 +89,7 @@ class StreamingRecording:
|
|
|
85
89
|
self.encoder.kill()
|
|
86
90
|
self.writer.join(timeout=1)
|
|
87
91
|
if self.error:
|
|
88
|
-
raise
|
|
92
|
+
raise RecordingError(self.error)
|
|
89
93
|
return {
|
|
90
94
|
"duration": self.frames / MEDIA_RATE,
|
|
91
95
|
"bytes": os.path.getsize(self.path),
|
package/package.json
CHANGED
package/src/harness/phone.py
CHANGED
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
import wave
|
|
15
15
|
import math
|
|
16
16
|
from array import array
|
|
17
|
-
from phone_recording import StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
17
|
+
from phone_recording import RecordingError, StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
18
18
|
|
|
19
19
|
# PJSIP writes directly to fd 1. Keep the protocol on a private duplicate.
|
|
20
20
|
protocol = os.fdopen(os.dup(1), "w", buffering=1)
|
|
@@ -179,22 +179,29 @@ class Call(pj.Call):
|
|
|
179
179
|
result = {"event": "recording", "sessionId": self.session_id,
|
|
180
180
|
"startedAtMs": self.started_at_ms, "duration": 0, "bytes": 0,
|
|
181
181
|
"contentType": "audio/webm", "peaks": []}
|
|
182
|
+
errors = [self.capture_error] if self.capture_error else []
|
|
182
183
|
try:
|
|
183
184
|
self.clock.stopTransmit(self.recorder)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
except Exception as error:
|
|
186
|
+
# Native error text can contain SIP identities or credentials.
|
|
187
|
+
errors.append(f"Could not disconnect recording clock ({type(error).__name__})")
|
|
188
|
+
if self.media is not None:
|
|
189
|
+
try:
|
|
190
|
+
self.media.stopTransmit(self.recorder)
|
|
191
|
+
self.media.stopTransmit(self.recipient_meter)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass # The remote peer may already have removed its port.
|
|
194
|
+
try:
|
|
195
|
+
# Always close the sink and reap the encoder, even if detach failed.
|
|
190
196
|
result.update(self.recorder.finish())
|
|
191
197
|
peaks = result["peaks"]
|
|
192
198
|
for side, track in self.recorder.tracks.items():
|
|
193
199
|
result[side] = (track + [0.0] * max(0, len(peaks) - len(track)))[:len(peaks)]
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
except Exception as error:
|
|
201
|
+
detail = str(error) if isinstance(error, RecordingError) else type(error).__name__
|
|
202
|
+
errors.append(f"Call recording could not be finalized: {detail}")
|
|
203
|
+
if errors:
|
|
204
|
+
result["error"] = "; ".join(errors)
|
|
198
205
|
protocol.write(json.dumps(result) + "\n")
|
|
199
206
|
|
|
200
207
|
|
|
@@ -10,6 +10,10 @@ MEDIA_RATE = 48000
|
|
|
10
10
|
PEAK_SAMPLES = MEDIA_RATE // 20
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
class RecordingError(RuntimeError):
|
|
14
|
+
"""SDK-authored capture diagnostics, safe to include in test results."""
|
|
15
|
+
|
|
16
|
+
|
|
13
17
|
class StreamingRecording:
|
|
14
18
|
def __init__(self, path):
|
|
15
19
|
self.path = path
|
|
@@ -85,7 +89,7 @@ class StreamingRecording:
|
|
|
85
89
|
self.encoder.kill()
|
|
86
90
|
self.writer.join(timeout=1)
|
|
87
91
|
if self.error:
|
|
88
|
-
raise
|
|
92
|
+
raise RecordingError(self.error)
|
|
89
93
|
return {
|
|
90
94
|
"duration": self.frames / MEDIA_RATE,
|
|
91
95
|
"bytes": os.path.getsize(self.path),
|
package/src/phone.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ beforeAll(async () => {
|
|
|
16
16
|
runtime,
|
|
17
17
|
`#!/usr/bin/env bun
|
|
18
18
|
import { createInterface } from "node:readline";
|
|
19
|
-
let connected = false, directory, sessionId, started, clockRate = 1;
|
|
19
|
+
let connected = false, directory, sessionId, started, recordingError, clockRate = 1;
|
|
20
20
|
const offset = () => (Date.now() - started) / 1000 * clockRate;
|
|
21
21
|
function finish() {
|
|
22
22
|
if (!sessionId) return;
|
|
@@ -24,7 +24,7 @@ function finish() {
|
|
|
24
24
|
const bytes = Buffer.alloc(128);
|
|
25
25
|
bytes.set([0x1a,0x45,0xdf,0xa3]);
|
|
26
26
|
require("node:fs").writeFileSync(directory + "/" + sessionId + ".webm", bytes);
|
|
27
|
-
console.log(JSON.stringify({event:"recording", sessionId, startedAtMs:started, duration, contentType:"audio/webm", encodingFinalizeMs:1, bytes:bytes.length, peaks:[.2,.8], callerPeaks:[.2,0], recipientPeaks:[0,.8]}));
|
|
27
|
+
console.log(JSON.stringify({event:"recording", sessionId, startedAtMs:started, duration, contentType:"audio/webm", encodingFinalizeMs:1, bytes:bytes.length, peaks:[.2,.8], callerPeaks:[.2,0], recipientPeaks:[0,.8], error:recordingError}));
|
|
28
28
|
sessionId = undefined;
|
|
29
29
|
}
|
|
30
30
|
createInterface({ input: process.stdin }).on("line", async line => {
|
|
@@ -34,6 +34,9 @@ createInterface({ input: process.stdin }).on("line", async line => {
|
|
|
34
34
|
if (data.action === "open") directory = data.directory;
|
|
35
35
|
if (data.action === "call") {
|
|
36
36
|
sessionId = data.sessionId; started = Date.now();
|
|
37
|
+
recordingError = data.destination === "recording-failure"
|
|
38
|
+
? "Call recording could not be finalized: Phone recording encoder could not keep up with audio"
|
|
39
|
+
: undefined;
|
|
37
40
|
clockRate = data.destination === "clock-drift" ? 0.25 : 1;
|
|
38
41
|
console.log(JSON.stringify({event:"operation_started", id:data.id, offset:0}));
|
|
39
42
|
if (data.destination === "timeout") return;
|
|
@@ -412,10 +415,10 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
|
|
|
412
415
|
await writeFile(join(root, "spectest/index.ts"), `
|
|
413
416
|
import { defineEnvironment } from ${JSON.stringify(sdk)};
|
|
414
417
|
const env = defineEnvironment({ name: "phone-integration", services: {} });
|
|
415
|
-
const cases = ["success", "failure", "timeout"].map(outcome => env.test(outcome,
|
|
418
|
+
const cases = ["success", "failure", "timeout", "recording-failure"].map(outcome => env.test(outcome,
|
|
416
419
|
{ timeoutMs: outcome === "timeout" ? 350 : 2000 }, async ctx => {
|
|
417
420
|
const phone = await ctx.phone(${JSON.stringify(options)});
|
|
418
|
-
await phone.call("123");
|
|
421
|
+
await phone.call(outcome === "recording-failure" ? outcome : "123");
|
|
419
422
|
await phone.say("One. Two.");
|
|
420
423
|
await new Promise(r => setTimeout(r, 30));
|
|
421
424
|
if (outcome === "failure") throw new Error("application assertion failed");
|
|
@@ -428,7 +431,7 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
|
|
|
428
431
|
import { strict as assert } from "node:assert";
|
|
429
432
|
import { METHODS } from ${JSON.stringify(daemon)};
|
|
430
433
|
const loaded = await METHODS.load({});
|
|
431
|
-
assert.equal(loaded.cases.length,
|
|
434
|
+
assert.equal(loaded.cases.length, 4);
|
|
432
435
|
for (const [index, test] of loaded.cases.entries()) {
|
|
433
436
|
const result = await METHODS.run({caseId:test.id});
|
|
434
437
|
assert.equal(result.status, index === 0 ? "passed" : "failed", JSON.stringify(result));
|
|
@@ -436,6 +439,13 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
|
|
|
436
439
|
if (index === 2) assert.match(result.error.message, /timed out/);
|
|
437
440
|
assert.equal(result.phoneRecordings.length, 1);
|
|
438
441
|
const recording = result.phoneRecordings[0];
|
|
442
|
+
if (index === 3) {
|
|
443
|
+
const message = "Call recording could not be finalized: Phone recording encoder could not keep up with audio";
|
|
444
|
+
assert.equal(recording.error, message);
|
|
445
|
+
assert.equal(result.error.message, message);
|
|
446
|
+
await assert.rejects(METHODS.phoneRecordingChunk({caseId:recording.id}), /Unknown/);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
439
449
|
assert.equal(recording.error, undefined);
|
|
440
450
|
assert.ok(recording.duration > 0);
|
|
441
451
|
assert.deepEqual(recording.callerPeaks, [.2,0]);
|