@decartai/sdk 0.1.4 → 0.1.6

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.
@@ -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 discard any buffered data. */
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.statsBuffer = [];
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
- let chunk = this.createReportChunk();
88
- while (chunk !== null) {
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: JSON.stringify(chunk)
99
+ body,
100
+ ...useKeepalive ? { keepalive: true } : {}
93
101
  }).catch(() => {});
94
- chunk = this.createReportChunk();
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 = {
@@ -268,7 +268,8 @@ var WebRTCStatsCollector = class {
268
268
  audio,
269
269
  outboundVideo,
270
270
  connection,
271
- remoteInbound
271
+ remoteInbound,
272
+ glassToGlass: null
272
273
  };
273
274
  }
274
275
  };
@@ -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 };