@decartai/sdk 0.1.5 → 0.1.7
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 +64 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +7 -1
- package/dist/realtime/client.d.ts +8 -1
- package/dist/realtime/client.js +14 -3
- package/dist/realtime/config-realtime.js +56 -1
- package/dist/realtime/media-channel.js +1 -0
- package/dist/realtime/mirror-stream.js +41 -31
- package/dist/realtime/observability/connection-quality.d.ts +54 -0
- package/dist/realtime/observability/connection-quality.js +219 -0
- package/dist/realtime/observability/glass-to-glass.d.ts +41 -0
- package/dist/realtime/observability/glass-to-glass.js +229 -0
- package/dist/realtime/observability/pixel-marker.js +144 -0
- package/dist/realtime/observability/realtime-observability.js +51 -1
- package/dist/realtime/observability/telemetry-reporter.js +16 -9
- package/dist/realtime/observability/webrtc-stats.d.ts +9 -0
- package/dist/realtime/observability/webrtc-stats.js +2 -1
- package/dist/realtime/preflight.d.ts +59 -0
- package/dist/realtime/preflight.js +311 -0
- package/dist/realtime/signaling-channel.js +63 -32
- package/dist/realtime/stream-session.js +11 -5
- package/package.json +1 -1
- package/dist/realtime/initial-state-gate.js +0 -21
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { REALTIME_CONFIG } from "../config-realtime.js";
|
|
2
|
+
import { ConnectionQualityEvaluator } from "./connection-quality.js";
|
|
3
|
+
import { SeqTracker, createMarkerReader, createStampPump } from "./glass-to-glass.js";
|
|
2
4
|
import { createLiveKitStatsProvider } from "./livekit-stats-provider.js";
|
|
3
5
|
import { NullTelemetryReporter, TelemetryReporter } from "./telemetry-reporter.js";
|
|
4
6
|
import { WebRTCStatsCollector } from "./webrtc-stats.js";
|
|
@@ -13,8 +15,47 @@ var RealtimeObservability = class {
|
|
|
13
15
|
videoStalled = false;
|
|
14
16
|
stallStartMs = 0;
|
|
15
17
|
connectionBreakdown = null;
|
|
18
|
+
connectionQuality = new ConnectionQualityEvaluator();
|
|
19
|
+
/** Glass-to-glass: shared tracker, outgoing stamp pump, and incoming reader. Null unless `debugQuality`. */
|
|
20
|
+
seqTracker;
|
|
21
|
+
markerReader;
|
|
22
|
+
stampPump = null;
|
|
16
23
|
constructor(options) {
|
|
17
24
|
this.options = options;
|
|
25
|
+
this.seqTracker = options.debugQuality ? new SeqTracker() : null;
|
|
26
|
+
this.markerReader = this.seqTracker ? createMarkerReader(this.seqTracker) : null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Wrap the outgoing stream so each frame carries a marker, feeding the shared
|
|
30
|
+
* tracker that the reader matches against. Returns the stream to publish
|
|
31
|
+
* (unchanged when g2g is off or stamping can't start). Owned here so the
|
|
32
|
+
* writer and reader of the tracker share one lifecycle and survive reconnects.
|
|
33
|
+
*/
|
|
34
|
+
attachOutgoingStream(stream, fps) {
|
|
35
|
+
if (!this.seqTracker) return stream;
|
|
36
|
+
try {
|
|
37
|
+
this.stampPump = createStampPump(stream, {
|
|
38
|
+
tracker: this.seqTracker,
|
|
39
|
+
fps
|
|
40
|
+
});
|
|
41
|
+
return this.stampPump.stream;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
this.options.logger.warn("Failed to start glass-to-glass stamp pump; continuing without it", { error: error instanceof Error ? error.message : String(error) });
|
|
44
|
+
return stream;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Feed the remote video track to the marker reader (called from the media channel). */
|
|
48
|
+
attachRemoteVideoTrack(track) {
|
|
49
|
+
this.markerReader?.attach(track);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Mark the start of a connect attempt for glass-to-glass timing (resets the
|
|
53
|
+
* tracker and starts the TTFF clock). Called at the top of each connect/
|
|
54
|
+
* reconnect so TTFF measures the full setup→first-frame wait. No-op unless g2g
|
|
55
|
+
* measurement is on.
|
|
56
|
+
*/
|
|
57
|
+
markGlassToGlassStart() {
|
|
58
|
+
this.seqTracker?.markStart(performance.now());
|
|
18
59
|
}
|
|
19
60
|
diagnostic(name, data, timestamp = Date.now()) {
|
|
20
61
|
this.options.logger.debug(name, data);
|
|
@@ -99,7 +140,7 @@ var RealtimeObservability = class {
|
|
|
99
140
|
this.stopStats();
|
|
100
141
|
this.resetStallDetection();
|
|
101
142
|
this.statsCollectorSource = source;
|
|
102
|
-
if (!this.options.telemetryEnabled && !this.options.onStats) return;
|
|
143
|
+
if (!this.options.telemetryEnabled && !this.options.onStats && !this.options.onConnectionQuality) return;
|
|
103
144
|
this.statsCollector = new WebRTCStatsCollector();
|
|
104
145
|
this.statsCollector.start(source, (stats) => this.handleStats(stats));
|
|
105
146
|
}
|
|
@@ -122,16 +163,24 @@ var RealtimeObservability = class {
|
|
|
122
163
|
}
|
|
123
164
|
stop() {
|
|
124
165
|
this.stopStats();
|
|
166
|
+
this.stampPump?.dispose();
|
|
167
|
+
this.markerReader?.dispose();
|
|
125
168
|
this.telemetryReporter.stop();
|
|
126
169
|
this.telemetryReporter = new NullTelemetryReporter();
|
|
127
170
|
this.telemetryReporterReady = false;
|
|
128
171
|
this.pendingTelemetryDiagnostics.length = 0;
|
|
129
172
|
this.connectionBreakdown = null;
|
|
130
173
|
}
|
|
174
|
+
getConnectionQuality() {
|
|
175
|
+
return this.connectionQuality.current();
|
|
176
|
+
}
|
|
131
177
|
handleStats(stats) {
|
|
178
|
+
if (this.seqTracker) stats.glassToGlass = this.seqTracker.snapshot();
|
|
132
179
|
this.options.onStats?.(stats);
|
|
133
180
|
this.telemetryReporter.addStats(stats);
|
|
134
181
|
this.detectVideoStall(stats);
|
|
182
|
+
const report = this.connectionQuality.update(stats);
|
|
183
|
+
if (report) this.options.onConnectionQuality?.(report);
|
|
135
184
|
}
|
|
136
185
|
detectVideoStall(stats) {
|
|
137
186
|
const fps = stats.video?.framesPerSecond ?? 0;
|
|
@@ -167,6 +216,7 @@ var RealtimeObservability = class {
|
|
|
167
216
|
resetStallDetection() {
|
|
168
217
|
this.videoStalled = false;
|
|
169
218
|
this.stallStartMs = 0;
|
|
219
|
+
this.connectionQuality.reset();
|
|
170
220
|
}
|
|
171
221
|
};
|
|
172
222
|
//#endregion
|
|
@@ -2,6 +2,7 @@ import { VERSION } from "../../version.js";
|
|
|
2
2
|
import { buildAuthHeaders } from "../../shared/request.js";
|
|
3
3
|
import { REALTIME_CONFIG } from "../config-realtime.js";
|
|
4
4
|
//#region src/realtime/observability/telemetry-reporter.ts
|
|
5
|
+
const KEEPALIVE_MAX_BODY_BYTES = 60 * 1024;
|
|
5
6
|
/** No-op reporter that silently discards all data. Used when telemetry is disabled. */
|
|
6
7
|
var NullTelemetryReporter = class {
|
|
7
8
|
start() {}
|
|
@@ -43,14 +44,13 @@ var TelemetryReporter = class {
|
|
|
43
44
|
flush() {
|
|
44
45
|
this.sendReport();
|
|
45
46
|
}
|
|
46
|
-
/** Stop the reporter and
|
|
47
|
+
/** Stop the reporter and make one final best-effort flush. */
|
|
47
48
|
stop() {
|
|
48
49
|
if (this.intervalId !== null) {
|
|
49
50
|
clearInterval(this.intervalId);
|
|
50
51
|
this.intervalId = null;
|
|
51
52
|
}
|
|
52
|
-
this.
|
|
53
|
-
this.diagnosticsBuffer = [];
|
|
53
|
+
this.sendReport({ keepalive: true });
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
56
|
* Build a single chunk from the front of the buffers, respecting the configured report item cap.
|
|
@@ -74,7 +74,7 @@ var TelemetryReporter = class {
|
|
|
74
74
|
diagnostics: this.diagnosticsBuffer.splice(0, REALTIME_CONFIG.observability.telemetryMaxItemsPerReport)
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
sendReport() {
|
|
77
|
+
sendReport(options = {}) {
|
|
78
78
|
if (this.statsBuffer.length === 0 && this.diagnosticsBuffer.length === 0) return;
|
|
79
79
|
try {
|
|
80
80
|
const commonHeaders = {
|
|
@@ -84,15 +84,22 @@ var TelemetryReporter = class {
|
|
|
84
84
|
}),
|
|
85
85
|
"Content-Type": "application/json"
|
|
86
86
|
};
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let nextChunk = this.createReportChunk();
|
|
89
|
+
while (nextChunk !== null) {
|
|
90
|
+
chunks.push(nextChunk);
|
|
91
|
+
nextChunk = this.createReportChunk();
|
|
92
|
+
}
|
|
93
|
+
chunks.forEach((chunk, index) => {
|
|
94
|
+
const body = JSON.stringify(chunk);
|
|
95
|
+
const useKeepalive = options.keepalive && index === chunks.length - 1 && new TextEncoder().encode(body).byteLength <= KEEPALIVE_MAX_BODY_BYTES;
|
|
89
96
|
fetch(REALTIME_CONFIG.observability.telemetryUrl, {
|
|
90
97
|
method: "POST",
|
|
91
98
|
headers: commonHeaders,
|
|
92
|
-
body
|
|
99
|
+
body,
|
|
100
|
+
...useKeepalive ? { keepalive: true } : {}
|
|
93
101
|
}).catch(() => {});
|
|
94
|
-
|
|
95
|
-
}
|
|
102
|
+
});
|
|
96
103
|
} catch {}
|
|
97
104
|
}
|
|
98
105
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { G2GMetrics } from "./glass-to-glass.js";
|
|
2
|
+
|
|
1
3
|
//#region src/realtime/observability/webrtc-stats.d.ts
|
|
2
4
|
type WebRTCStats = {
|
|
3
5
|
timestamp: number;
|
|
@@ -129,6 +131,13 @@ type WebRTCStats = {
|
|
|
129
131
|
*/
|
|
130
132
|
selectedCandidatePairs: IceCandidatePair[];
|
|
131
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* True glass-to-glass latency + end-to-end drop signal, merged in by
|
|
136
|
+
* `RealtimeObservability` when the opt-in pixel-marker measurement is active
|
|
137
|
+
* (see glass-to-glass.ts). Null otherwise — the stats collector does not
|
|
138
|
+
* populate it.
|
|
139
|
+
*/
|
|
140
|
+
glassToGlass: G2GMetrics | null;
|
|
132
141
|
};
|
|
133
142
|
/** One side of an ICE candidate pair (sender or receiver). */
|
|
134
143
|
type IceCandidateInfo = {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { CustomModelDefinition, ModelDefinition } from "../shared/model.js";
|
|
2
|
+
import { ConnectionQuality } from "./observability/connection-quality.js";
|
|
3
|
+
|
|
4
|
+
//#region src/realtime/preflight.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SDK-only connectivity preflight — run before `realtime.connect()` to decide
|
|
8
|
+
* whether to show the integration. Spins up a throwaway `RTCPeerConnection`
|
|
9
|
+
* against public STUN (no session, no inference) to check whether WebRTC can
|
|
10
|
+
* leave the network over UDP and roughly how laggy the path is. It does not
|
|
11
|
+
* measure throughput — use the in-session `connectionQuality` signal for that.
|
|
12
|
+
*/
|
|
13
|
+
type ConnectivityTransport = "udp" | "relay" | "failed";
|
|
14
|
+
type ConnectivityMetrics = {
|
|
15
|
+
/** "udp" = direct UDP works · "relay" = will need TURN (unverified SDK-only) · "failed" = no connectivity. */
|
|
16
|
+
transport: ConnectivityTransport;
|
|
17
|
+
/** Approximate network round-trip time (ms) from time-to-first STUN candidate (or real RTT in deep mode), or null. */
|
|
18
|
+
rttMs: number | null;
|
|
19
|
+
/** Active-probe only: measured mid-stream (steady-state) glass-to-glass latency (ms), or null. */
|
|
20
|
+
g2gMs?: number | null;
|
|
21
|
+
/** Active-probe only: time-to-first-frame (ms) — startup latency to the first rendered model frame, or null. */
|
|
22
|
+
ttffMs?: number | null;
|
|
23
|
+
/** Active-probe only: end-to-end frame drop ratio (0–1), or null. */
|
|
24
|
+
g2gDropRatio?: number | null;
|
|
25
|
+
/** Active-probe only: server's view of upstream jitter (ms), or null. */
|
|
26
|
+
upstreamJitterMs?: number | null;
|
|
27
|
+
/** Active-probe only: server-reported upstream packet loss (0–1), or null. */
|
|
28
|
+
packetLoss?: number | null;
|
|
29
|
+
/** Active-probe only: number of glass-to-glass samples collected. */
|
|
30
|
+
sampleCount?: number;
|
|
31
|
+
};
|
|
32
|
+
type ConnectivityReport = {
|
|
33
|
+
/** Pre-connect quality on the same `good → critical` scale as the in-session signal — you decide what to do. */
|
|
34
|
+
quality: ConnectionQuality;
|
|
35
|
+
metrics: ConnectivityMetrics;
|
|
36
|
+
/** Human-readable explanations for any non-"good" verdict. */
|
|
37
|
+
reasons: string[];
|
|
38
|
+
};
|
|
39
|
+
type CheckConnectivityOptions = {
|
|
40
|
+
/** Override the ICE servers used for the probe. Defaults to public STUN. */
|
|
41
|
+
iceServers?: RTCIceServer[];
|
|
42
|
+
/** Abort candidate gathering after this long. Defaults to config. */
|
|
43
|
+
iceGatherTimeoutMs?: number;
|
|
44
|
+
/** Abort the probe early. */
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
/**
|
|
47
|
+
* Opt-in "deep" probe: instead of the STUN-only network check, briefly open a
|
|
48
|
+
* real session with a synthetic source, measure true glass-to-glass latency,
|
|
49
|
+
* then tear it down. Requires `model`. Costs a short GPU session.
|
|
50
|
+
*/
|
|
51
|
+
deep?: boolean;
|
|
52
|
+
/** Required when `deep`: the realtime model to probe (latency is model-specific). */
|
|
53
|
+
model?: ModelDefinition | CustomModelDefinition;
|
|
54
|
+
/** Deep-probe duration (ms). Defaults to config. */
|
|
55
|
+
durationMs?: number;
|
|
56
|
+
};
|
|
57
|
+
/** Realtime `connect` injected by the SDK root so the active probe can open a session. */
|
|
58
|
+
//#endregion
|
|
59
|
+
export { CheckConnectivityOptions, ConnectivityMetrics, ConnectivityReport, ConnectivityTransport };
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { resolveFpsNumber } from "../shared/model.js";
|
|
2
|
+
import { REALTIME_CONFIG } from "./config-realtime.js";
|
|
3
|
+
import { extractSignals, scoreLowerBetter, worst } from "./observability/connection-quality.js";
|
|
4
|
+
//#region src/realtime/preflight.ts
|
|
5
|
+
/** Extract the candidate type ("host" | "srflx" | "prflx" | "relay") from an ICE candidate. */
|
|
6
|
+
function candidateType(candidate) {
|
|
7
|
+
if (candidate.type) return candidate.type;
|
|
8
|
+
return /\btyp (\w+)/.exec(candidate.candidate)?.[1] ?? "";
|
|
9
|
+
}
|
|
10
|
+
async function gatherIceCandidates(iceServers, timeoutMs, signal, logger) {
|
|
11
|
+
if (signal?.aborted) return {
|
|
12
|
+
transport: "failed",
|
|
13
|
+
rttMs: null
|
|
14
|
+
};
|
|
15
|
+
const PC = globalThis?.RTCPeerConnection;
|
|
16
|
+
if (typeof PC !== "function") {
|
|
17
|
+
logger.warn("preflight: RTCPeerConnection unavailable in this environment");
|
|
18
|
+
return {
|
|
19
|
+
transport: "failed",
|
|
20
|
+
rttMs: null
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
let pc = null;
|
|
24
|
+
try {
|
|
25
|
+
pc = new PC({ iceServers });
|
|
26
|
+
pc.createDataChannel("decart-preflight");
|
|
27
|
+
let sawSrflx = false;
|
|
28
|
+
let sawOtherCandidate = false;
|
|
29
|
+
let firstSrflxAt = null;
|
|
30
|
+
const start = performance.now();
|
|
31
|
+
await new Promise((resolve) => {
|
|
32
|
+
let settled = false;
|
|
33
|
+
const finish = () => {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
resolve();
|
|
38
|
+
};
|
|
39
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
40
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
41
|
+
const peer = pc;
|
|
42
|
+
peer.onicecandidate = (event) => {
|
|
43
|
+
if (!event.candidate || event.candidate.candidate === "") {
|
|
44
|
+
finish();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (candidateType(event.candidate) === "srflx") {
|
|
48
|
+
sawSrflx = true;
|
|
49
|
+
if (firstSrflxAt === null) firstSrflxAt = performance.now();
|
|
50
|
+
} else sawOtherCandidate = true;
|
|
51
|
+
};
|
|
52
|
+
peer.onicegatheringstatechange = () => {
|
|
53
|
+
if (peer.iceGatheringState === "complete") finish();
|
|
54
|
+
};
|
|
55
|
+
peer.createOffer().then((offer) => peer.setLocalDescription(offer)).catch((error) => {
|
|
56
|
+
logger.warn("preflight: failed to create offer", { error: error instanceof Error ? error.message : String(error) });
|
|
57
|
+
finish();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
const rttMs = firstSrflxAt !== null ? Math.round(firstSrflxAt - start) : null;
|
|
61
|
+
let transport;
|
|
62
|
+
if (sawSrflx) transport = "udp";
|
|
63
|
+
else if (sawOtherCandidate) transport = "relay";
|
|
64
|
+
else transport = "failed";
|
|
65
|
+
return {
|
|
66
|
+
transport,
|
|
67
|
+
rttMs
|
|
68
|
+
};
|
|
69
|
+
} catch (error) {
|
|
70
|
+
logger.warn("preflight: connectivity probe threw", { error: error instanceof Error ? error.message : String(error) });
|
|
71
|
+
return {
|
|
72
|
+
transport: "failed",
|
|
73
|
+
rttMs: null
|
|
74
|
+
};
|
|
75
|
+
} finally {
|
|
76
|
+
try {
|
|
77
|
+
pc?.close();
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Map probe metrics to a connectivity quality verdict. Pure. */
|
|
82
|
+
function classifyConnectivity(metrics, thresholds) {
|
|
83
|
+
const reasons = [];
|
|
84
|
+
let quality;
|
|
85
|
+
if (metrics.transport === "failed") {
|
|
86
|
+
quality = "critical";
|
|
87
|
+
reasons.push("Could not establish any WebRTC connectivity (no ICE candidates gathered). Real-time streaming is unlikely to work on this network.");
|
|
88
|
+
} else if (metrics.transport === "relay") {
|
|
89
|
+
quality = "poor";
|
|
90
|
+
reasons.push("Direct UDP connectivity could not be confirmed; the session will need a TURN relay, which adds latency and can't be verified without starting a session.");
|
|
91
|
+
} else if (metrics.rttMs != null && metrics.rttMs > thresholds.marginalMs) {
|
|
92
|
+
quality = "poor";
|
|
93
|
+
reasons.push(`Network round-trip time is high (~${metrics.rttMs}ms > ${thresholds.marginalMs}ms); the real-time experience may feel laggy.`);
|
|
94
|
+
} else if (metrics.rttMs != null && metrics.rttMs > thresholds.goodMs) {
|
|
95
|
+
quality = "fair";
|
|
96
|
+
reasons.push(`Network round-trip time is elevated (~${metrics.rttMs}ms > ${thresholds.goodMs}ms).`);
|
|
97
|
+
} else quality = "good";
|
|
98
|
+
return {
|
|
99
|
+
quality,
|
|
100
|
+
metrics: {
|
|
101
|
+
transport: metrics.transport,
|
|
102
|
+
rttMs: metrics.rttMs
|
|
103
|
+
},
|
|
104
|
+
reasons
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Classify an active-probe result. Judges startup (TTFF) and steady-state
|
|
109
|
+
* (mid-stream glass-to-glass) latency separately — both are real experienced
|
|
110
|
+
* latency on different scales — and folds in drops + upstream loss. Falls back
|
|
111
|
+
* to RTT only when neither latency could be measured. Pure.
|
|
112
|
+
*/
|
|
113
|
+
function classifyActiveProbe(metrics, thresholds) {
|
|
114
|
+
const reasons = [];
|
|
115
|
+
if (metrics.transport === "failed") return {
|
|
116
|
+
quality: "critical",
|
|
117
|
+
metrics,
|
|
118
|
+
reasons: ["Could not establish a realtime session for the deep probe."]
|
|
119
|
+
};
|
|
120
|
+
const dims = [];
|
|
121
|
+
if (metrics.ttffMs != null) {
|
|
122
|
+
const t = thresholds.ttff;
|
|
123
|
+
const q = scoreLowerBetter(metrics.ttffMs, t.goodMs, t.fairMs, t.poorMs);
|
|
124
|
+
dims.push(q);
|
|
125
|
+
if (q !== "good") reasons.push(`Time to first frame is ~${(metrics.ttffMs / 1e3).toFixed(1)}s (good ≤ ${(t.goodMs / 1e3).toFixed(0)}s); the session is slow to start.`);
|
|
126
|
+
}
|
|
127
|
+
if (metrics.g2gMs != null) {
|
|
128
|
+
const g = thresholds.glassToGlass;
|
|
129
|
+
const q = scoreLowerBetter(metrics.g2gMs, g.goodMs, g.fairMs, g.poorMs);
|
|
130
|
+
dims.push(q);
|
|
131
|
+
if (q !== "good") reasons.push(`Mid-stream glass-to-glass latency is ~${metrics.g2gMs}ms (good ≤ ${g.goodMs}ms); the real-time experience may feel laggy.`);
|
|
132
|
+
}
|
|
133
|
+
if (metrics.ttffMs == null && metrics.g2gMs == null) if (metrics.rttMs != null) {
|
|
134
|
+
reasons.push("Could not measure glass-to-glass latency during the probe (no marker round-trip); using network RTT instead.");
|
|
135
|
+
dims.push(scoreLowerBetter(metrics.rttMs, thresholds.rtt.goodMs, thresholds.rtt.fairMs, thresholds.rtt.poorMs));
|
|
136
|
+
} else reasons.push("The probe connected but could not measure latency (no marker round-trip and no RTT sample).");
|
|
137
|
+
if (metrics.g2gDropRatio != null) {
|
|
138
|
+
const d = thresholds.g2gDrop;
|
|
139
|
+
const q = scoreLowerBetter(metrics.g2gDropRatio, d.good, d.fair, d.poor);
|
|
140
|
+
dims.push(q);
|
|
141
|
+
if (q !== "good") reasons.push(`End-to-end frame drop ratio is ${(metrics.g2gDropRatio * 100).toFixed(1)}% (good ≤ ${d.good * 100}%).`);
|
|
142
|
+
}
|
|
143
|
+
if (metrics.packetLoss != null) {
|
|
144
|
+
const l = thresholds.loss;
|
|
145
|
+
const q = scoreLowerBetter(metrics.packetLoss, l.good, l.fair, l.poor);
|
|
146
|
+
dims.push(q);
|
|
147
|
+
if (q !== "good") reasons.push(`Upstream packet loss is ${(metrics.packetLoss * 100).toFixed(1)}% (good ≤ ${l.good * 100}%).`);
|
|
148
|
+
}
|
|
149
|
+
if (dims.length === 0) return {
|
|
150
|
+
quality: "fair",
|
|
151
|
+
metrics,
|
|
152
|
+
reasons
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
quality: worst(...dims),
|
|
156
|
+
metrics,
|
|
157
|
+
reasons
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Derive active-probe metrics from the latest in-session stats sample. */
|
|
161
|
+
function activeMetricsFromStats(stats) {
|
|
162
|
+
if (!stats) return {
|
|
163
|
+
transport: "udp",
|
|
164
|
+
rttMs: null,
|
|
165
|
+
g2gMs: null,
|
|
166
|
+
ttffMs: null,
|
|
167
|
+
g2gDropRatio: null,
|
|
168
|
+
upstreamJitterMs: null,
|
|
169
|
+
packetLoss: null,
|
|
170
|
+
sampleCount: 0
|
|
171
|
+
};
|
|
172
|
+
const s = extractSignals(stats);
|
|
173
|
+
return {
|
|
174
|
+
transport: s.isRelayed ? "relay" : "udp",
|
|
175
|
+
rttMs: s.rttMs != null ? Math.round(s.rttMs) : null,
|
|
176
|
+
g2gMs: s.g2gMs,
|
|
177
|
+
ttffMs: s.ttffMs,
|
|
178
|
+
g2gDropRatio: s.g2gDropRatio,
|
|
179
|
+
upstreamJitterMs: s.upstreamJitterMs != null ? Math.round(s.upstreamJitterMs) : null,
|
|
180
|
+
packetLoss: s.fractionLost,
|
|
181
|
+
sampleCount: stats.glassToGlass?.sampleCount ?? 0
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Animated synthetic video source — no camera permission needed; content is
|
|
186
|
+
* irrelevant to the marker. Sized to the model's exact input dimensions so the
|
|
187
|
+
* server doesn't resize/crop the frame, which would move the bottom-left marker
|
|
188
|
+
* out of where the server reads it (breaking the round trip).
|
|
189
|
+
*/
|
|
190
|
+
function createSyntheticSource(width, height, fps) {
|
|
191
|
+
if (typeof document === "undefined") throw new Error("deep connectivity probe requires a DOM environment (document is undefined)");
|
|
192
|
+
const canvas = document.createElement("canvas");
|
|
193
|
+
canvas.width = width;
|
|
194
|
+
canvas.height = height;
|
|
195
|
+
const ctx = canvas.getContext("2d");
|
|
196
|
+
if (!ctx) throw new Error("deep connectivity probe: 2D canvas context unavailable");
|
|
197
|
+
if (typeof canvas.captureStream !== "function") throw new Error("deep connectivity probe: canvas.captureStream unavailable");
|
|
198
|
+
let rafHandle = null;
|
|
199
|
+
let frame = 0;
|
|
200
|
+
const draw = () => {
|
|
201
|
+
frame++;
|
|
202
|
+
ctx.fillStyle = `hsl(${frame % 360}, 60%, 50%)`;
|
|
203
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
204
|
+
ctx.fillStyle = "white";
|
|
205
|
+
ctx.fillRect(frame * 7 % canvas.width, 48, 96, 96);
|
|
206
|
+
rafHandle = requestAnimationFrame(draw);
|
|
207
|
+
};
|
|
208
|
+
rafHandle = requestAnimationFrame(draw);
|
|
209
|
+
const stream = canvas.captureStream(fps);
|
|
210
|
+
return {
|
|
211
|
+
stream,
|
|
212
|
+
dispose: () => {
|
|
213
|
+
if (rafHandle !== null) cancelAnimationFrame(rafHandle);
|
|
214
|
+
for (const track of stream.getTracks()) track.stop();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const ABORTED_DEEP_PROBE = {
|
|
219
|
+
quality: "fair",
|
|
220
|
+
metrics: {
|
|
221
|
+
transport: "failed",
|
|
222
|
+
rttMs: null,
|
|
223
|
+
g2gMs: null,
|
|
224
|
+
ttffMs: null,
|
|
225
|
+
g2gDropRatio: null,
|
|
226
|
+
upstreamJitterMs: null,
|
|
227
|
+
packetLoss: null,
|
|
228
|
+
sampleCount: 0
|
|
229
|
+
},
|
|
230
|
+
reasons: ["Deep connectivity probe aborted."]
|
|
231
|
+
};
|
|
232
|
+
/** Unblock `promise` early when `signal` aborts. */
|
|
233
|
+
function raceAbort(promise, signal) {
|
|
234
|
+
if (!signal) return promise;
|
|
235
|
+
if (signal.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
236
|
+
return Promise.race([promise, new Promise((_, reject) => {
|
|
237
|
+
signal.addEventListener("abort", () => reject(signal.reason ?? new DOMException("Aborted", "AbortError")), { once: true });
|
|
238
|
+
})]);
|
|
239
|
+
}
|
|
240
|
+
/** Resolve once enough g2g samples exist or the probe window elapses (never rejects). */
|
|
241
|
+
function waitForProbe(getLatest, minSamples, durationMs, signal) {
|
|
242
|
+
return new Promise((resolve) => {
|
|
243
|
+
const start = performance.now();
|
|
244
|
+
const tick = () => {
|
|
245
|
+
if (signal?.aborted) return resolve();
|
|
246
|
+
if ((getLatest()?.glassToGlass?.sampleCount ?? 0) >= minSamples) return resolve();
|
|
247
|
+
if (performance.now() - start >= durationMs) return resolve();
|
|
248
|
+
setTimeout(tick, 200);
|
|
249
|
+
};
|
|
250
|
+
setTimeout(tick, 200);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function runActiveProbe(args) {
|
|
254
|
+
const { connect, logger, model, durationMs, signal } = args;
|
|
255
|
+
const thresholds = REALTIME_CONFIG.observability.connectionQuality;
|
|
256
|
+
let source;
|
|
257
|
+
let client;
|
|
258
|
+
let latest = null;
|
|
259
|
+
if (signal?.aborted) return ABORTED_DEEP_PROBE;
|
|
260
|
+
try {
|
|
261
|
+
source = createSyntheticSource(model.width, model.height, resolveFpsNumber(model.fps));
|
|
262
|
+
const connectTask = connect(source.stream, {
|
|
263
|
+
model,
|
|
264
|
+
debugQuality: true,
|
|
265
|
+
onRemoteStream: () => {}
|
|
266
|
+
});
|
|
267
|
+
signal?.addEventListener("abort", () => connectTask.then((c) => c.disconnect()).catch(() => {}), { once: true });
|
|
268
|
+
client = await raceAbort(connectTask, signal);
|
|
269
|
+
client.on("stats", (stats) => {
|
|
270
|
+
latest = stats;
|
|
271
|
+
});
|
|
272
|
+
await waitForProbe(() => latest, REALTIME_CONFIG.preflight.active.minSamples, durationMs, signal);
|
|
273
|
+
if (signal?.aborted) return ABORTED_DEEP_PROBE;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (signal?.aborted) return ABORTED_DEEP_PROBE;
|
|
276
|
+
logger.warn("deep connectivity probe failed", { error: error instanceof Error ? error.message : String(error) });
|
|
277
|
+
return classifyActiveProbe({
|
|
278
|
+
transport: "failed",
|
|
279
|
+
rttMs: null,
|
|
280
|
+
g2gMs: null,
|
|
281
|
+
g2gDropRatio: null,
|
|
282
|
+
upstreamJitterMs: null,
|
|
283
|
+
packetLoss: null,
|
|
284
|
+
sampleCount: 0
|
|
285
|
+
}, thresholds);
|
|
286
|
+
} finally {
|
|
287
|
+
client?.disconnect();
|
|
288
|
+
source?.dispose();
|
|
289
|
+
}
|
|
290
|
+
return classifyActiveProbe(activeMetricsFromStats(latest), thresholds);
|
|
291
|
+
}
|
|
292
|
+
const DEFAULT_ICE_SERVERS = REALTIME_CONFIG.preflight.defaultStunUrls.map((urls) => ({ urls }));
|
|
293
|
+
const createPreflight = ({ logger, connect }) => {
|
|
294
|
+
const checkConnectivity = async (options = {}) => {
|
|
295
|
+
if (options.deep) {
|
|
296
|
+
if (!connect) throw new Error("deep connectivity probe is unavailable (realtime client not wired)");
|
|
297
|
+
if (!options.model) throw new Error("deep connectivity probe requires a `model` (latency is model-specific)");
|
|
298
|
+
return runActiveProbe({
|
|
299
|
+
connect,
|
|
300
|
+
logger,
|
|
301
|
+
model: options.model,
|
|
302
|
+
durationMs: options.durationMs ?? REALTIME_CONFIG.preflight.active.durationMs,
|
|
303
|
+
signal: options.signal
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return classifyConnectivity(await gatherIceCandidates(options.iceServers ?? DEFAULT_ICE_SERVERS, options.iceGatherTimeoutMs ?? REALTIME_CONFIG.preflight.iceGatherTimeoutMs, options.signal, logger), REALTIME_CONFIG.preflight.rtt);
|
|
307
|
+
};
|
|
308
|
+
return { checkConnectivity };
|
|
309
|
+
};
|
|
310
|
+
//#endregion
|
|
311
|
+
export { createPreflight };
|