@decartai/sdk 0.0.67 → 0.0.68

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 CHANGED
@@ -79,6 +79,16 @@ Options:
79
79
  - `"auto"` — mirror when the input track reports `facingMode: "user"` (mobile front cameras).
80
80
  - `true` — always mirror (e.g. desktop webcams).
81
81
 
82
+ #### Output resolution
83
+
84
+ ```ts
85
+ const realtimeClient = await client.realtime.connect(stream, {
86
+ model,
87
+ resolution: "1080p", // default: "720p"
88
+ // ...
89
+ });
90
+ ```
91
+
82
92
  ### Async Processing (Queue API)
83
93
 
84
94
  For video generation jobs, use the queue API to submit jobs and poll for results:
package/dist/index.d.ts CHANGED
@@ -5,11 +5,11 @@ import { ProcessClient } from "./process/client.js";
5
5
  import { JobStatus, JobStatusResponse, JobSubmitResponse, QueueJobResult, QueueSubmitAndPollOptions, QueueSubmitOptions } from "./queue/types.js";
6
6
  import { QueueClient } from "./queue/client.js";
7
7
  import { DecartSDKError, ERROR_CODES } from "./utils/errors.js";
8
- import { ConnectionPhase, DiagnosticEvent, DiagnosticEventName, DiagnosticEvents, IceCandidateEvent, IceStateEvent, PeerConnectionStateEvent, PhaseTimingEvent, ReconnectEvent, SelectedCandidatePairEvent, SignalingStateEvent, VideoStallEvent } from "./realtime/diagnostics.js";
9
8
  import { ConnectionState } from "./realtime/types.js";
9
+ import { ConnectionPhase, DiagnosticEvent, DiagnosticEventName, DiagnosticEvents, IceCandidateEvent, IceStateEvent, PeerConnectionStateEvent, PhaseTimingEvent, ReconnectEvent, SelectedCandidatePairEvent, SignalingStateEvent, VideoStallEvent } from "./realtime/observability/diagnostics.js";
10
+ import { WebRTCStats } from "./realtime/observability/webrtc-stats.js";
10
11
  import { SetInput } from "./realtime/methods.js";
11
12
  import { RealTimeSubscribeClient, SubscribeEvents, SubscribeOptions } from "./realtime/subscribe-client.js";
12
- import { WebRTCStats } from "./realtime/webrtc-stats.js";
13
13
  import { Events, RealTimeClient, RealTimeClientConnectOptions, RealTimeClientInitialState } from "./realtime/client.js";
14
14
  import { ModelState } from "./shared/types.js";
15
15
  import { CreateTokenOptions, CreateTokenResponse, TokensClient } from "./tokens/client.js";
@@ -1,9 +1,9 @@
1
1
  import { CustomModelDefinition, ModelDefinition } from "../shared/model.js";
2
2
  import { DecartSDKError } from "../utils/errors.js";
3
- import { DiagnosticEvent } from "./diagnostics.js";
4
3
  import { ConnectionState } from "./types.js";
4
+ import { DiagnosticEvent } from "./observability/diagnostics.js";
5
+ import { WebRTCStats } from "./observability/webrtc-stats.js";
5
6
  import { SetInput } from "./methods.js";
6
- import { WebRTCStats } from "./webrtc-stats.js";
7
7
  import { z } from "zod";
8
8
 
9
9
  //#region src/realtime/client.d.ts
@@ -37,6 +37,10 @@ declare const realTimeClientConnectOptionsSchema: z.ZodObject<{
37
37
  }, z.core.$strip>>;
38
38
  customizeOffer: z.ZodOptional<z.ZodCustom<z.core.$InferInnerFunctionTypeAsync<z.core.$ZodFunctionArgs, z.core.$ZodFunctionOut>, z.core.$InferInnerFunctionTypeAsync<z.core.$ZodFunctionArgs, z.core.$ZodFunctionOut>>>;
39
39
  mirror: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"auto">, z.ZodBoolean]>>;
40
+ resolution: z.ZodOptional<z.ZodEnum<{
41
+ "720p": "720p";
42
+ "1080p": "1080p";
43
+ }>>;
40
44
  }, z.core.$strip>;
41
45
  type RealTimeClientConnectOptions = Omit<z.infer<typeof realTimeClientConnectOptionsSchema>, "model"> & {
42
46
  model: ModelDefinition | CustomModelDefinition;
@@ -4,10 +4,9 @@ import { modelStateSchema } from "../shared/types.js";
4
4
  import { createEventBuffer } from "./event-buffer.js";
5
5
  import { realtimeMethods } from "./methods.js";
6
6
  import { createMirroredStream, shouldMirrorTrack } from "./mirror-stream.js";
7
+ import { RealtimeObservability } from "./observability/realtime-observability.js";
7
8
  import { decodeSubscribeToken, encodeSubscribeToken } from "./subscribe-client.js";
8
- import { NullTelemetryReporter, TelemetryReporter } from "./telemetry-reporter.js";
9
9
  import { WebRTCManager } from "./webrtc-manager.js";
10
- import { WebRTCStatsCollector } from "./webrtc-stats.js";
11
10
  import { z } from "zod";
12
11
 
13
12
  //#region src/realtime/client.ts
@@ -58,14 +57,15 @@ const realTimeClientConnectOptionsSchema = z.object({
58
57
  onRemoteStream: z.custom((val) => typeof val === "function", { message: "onRemoteStream must be a function" }),
59
58
  initialState: realTimeClientInitialStateSchema.optional(),
60
59
  customizeOffer: createAsyncFunctionSchema(z.function()).optional(),
61
- mirror: z.union([z.literal("auto"), z.boolean()]).optional()
60
+ mirror: z.union([z.literal("auto"), z.boolean()]).optional(),
61
+ resolution: z.enum(["720p", "1080p"]).optional()
62
62
  });
63
63
  const createRealTimeClient = (opts) => {
64
64
  const { baseUrl, apiKey, integration, logger } = opts;
65
65
  const connect = async (stream, options) => {
66
66
  const parsedOptions = realTimeClientConnectOptionsSchema.safeParse(options);
67
67
  if (!parsedOptions.success) throw parsedOptions.error;
68
- const { onRemoteStream, initialState } = parsedOptions.data;
68
+ const { onRemoteStream, initialState, resolution } = parsedOptions.data;
69
69
  const mirror = parsedOptions.data.mirror ?? false;
70
70
  let inputStream = stream ?? new MediaStream();
71
71
  let mirroredStream;
@@ -79,8 +79,16 @@ const createRealTimeClient = (opts) => {
79
79
  logger.warn("Failed to mirror input stream; falling back to un-mirrored input", { error: error instanceof Error ? error.message : String(error) });
80
80
  }
81
81
  let webrtcManager;
82
- let telemetryReporter = new NullTelemetryReporter();
83
- let handleConnectionStateChange = null;
82
+ const { emitter: eventEmitter, emitOrBuffer, flush, stop } = createEventBuffer();
83
+ const observability = new RealtimeObservability({
84
+ telemetryEnabled: opts.telemetryEnabled,
85
+ apiKey,
86
+ model: options.model.name,
87
+ integration,
88
+ logger,
89
+ onDiagnostic: (event) => emitOrBuffer("diagnostic", event),
90
+ onStats: opts.telemetryEnabled ? (stats) => emitOrBuffer("stats", stats) : void 0
91
+ });
84
92
  try {
85
93
  const initialImage = initialState?.image ? await imageToBase64(initialState.image) : void 0;
86
94
  const initialPrompt = initialState?.prompt ? {
@@ -88,22 +96,15 @@ const createRealTimeClient = (opts) => {
88
96
  enhance: initialState.prompt.enhance
89
97
  } : void 0;
90
98
  const url = `${baseUrl}${options.model.urlPath}`;
91
- const { emitter: eventEmitter, emitOrBuffer, flush, stop } = createEventBuffer();
99
+ const resolutionQs = resolution ? `&resolution=${encodeURIComponent(resolution)}` : "";
92
100
  webrtcManager = new WebRTCManager({
93
- webrtcUrl: `${url}?api_key=${encodeURIComponent(apiKey)}&model=${encodeURIComponent(options.model.name)}`,
101
+ webrtcUrl: `${url}?api_key=${encodeURIComponent(apiKey)}&model=${encodeURIComponent(options.model.name)}${resolutionQs}`,
94
102
  integration,
95
103
  logger,
96
- onDiagnostic: (name, data) => {
97
- emitOrBuffer("diagnostic", {
98
- name,
99
- data
100
- });
101
- addTelemetryDiagnostic(name, data);
102
- },
104
+ observability,
103
105
  onRemoteStream,
104
106
  onConnectionStateChange: (state) => {
105
107
  emitOrBuffer("connectionChange", state);
106
- handleConnectionStateChange?.(state);
107
108
  },
108
109
  onError: (error) => {
109
110
  logger.error("WebRTC error", { error: error.message });
@@ -118,42 +119,10 @@ const createRealTimeClient = (opts) => {
118
119
  const manager = webrtcManager;
119
120
  let sessionId = null;
120
121
  let subscribeToken = null;
121
- const pendingTelemetryDiagnostics = [];
122
- let telemetryReporterReady = false;
123
- const addTelemetryDiagnostic = (name, data, timestamp = Date.now()) => {
124
- if (!opts.telemetryEnabled) return;
125
- if (!telemetryReporterReady) {
126
- pendingTelemetryDiagnostics.push({
127
- name,
128
- data,
129
- timestamp
130
- });
131
- return;
132
- }
133
- telemetryReporter.addDiagnostic({
134
- name,
135
- data,
136
- timestamp
137
- });
138
- };
139
122
  const sessionIdListener = (msg) => {
140
123
  subscribeToken = encodeSubscribeToken(msg.session_id, msg.server_ip, msg.server_port);
141
124
  sessionId = msg.session_id;
142
- if (opts.telemetryEnabled) {
143
- if (telemetryReporterReady) telemetryReporter.stop();
144
- const reporter = new TelemetryReporter({
145
- apiKey,
146
- sessionId: msg.session_id,
147
- model: options.model.name,
148
- integration,
149
- logger
150
- });
151
- reporter.start();
152
- telemetryReporter = reporter;
153
- telemetryReporterReady = true;
154
- for (const diagnostic of pendingTelemetryDiagnostics) telemetryReporter.addDiagnostic(diagnostic);
155
- pendingTelemetryDiagnostics.length = 0;
156
- }
125
+ observability.sessionStarted(msg.session_id);
157
126
  };
158
127
  manager.getWebsocketMessageEmitter().on("sessionId", sessionIdListener);
159
128
  const tickListener = (msg) => {
@@ -162,74 +131,13 @@ const createRealTimeClient = (opts) => {
162
131
  manager.getWebsocketMessageEmitter().on("generationTick", tickListener);
163
132
  await manager.connect(inputStream);
164
133
  const methods = realtimeMethods(manager, imageToBase64);
165
- let statsCollector = null;
166
- let statsCollectorPeerConnection = null;
167
- const STALL_FPS_THRESHOLD = .5;
168
- let videoStalled = false;
169
- let stallStartMs = 0;
170
- const startStatsCollection = () => {
171
- statsCollector?.stop();
172
- videoStalled = false;
173
- stallStartMs = 0;
174
- statsCollector = new WebRTCStatsCollector();
175
- const pc = manager.getPeerConnection();
176
- statsCollectorPeerConnection = pc;
177
- if (pc) statsCollector.start(pc, (stats) => {
178
- emitOrBuffer("stats", stats);
179
- telemetryReporter.addStats(stats);
180
- const fps = stats.video?.framesPerSecond ?? 0;
181
- if (!videoStalled && stats.video && fps < STALL_FPS_THRESHOLD) {
182
- videoStalled = true;
183
- stallStartMs = Date.now();
184
- emitOrBuffer("diagnostic", {
185
- name: "videoStall",
186
- data: {
187
- stalled: true,
188
- durationMs: 0
189
- }
190
- });
191
- addTelemetryDiagnostic("videoStall", {
192
- stalled: true,
193
- durationMs: 0
194
- }, stallStartMs);
195
- } else if (videoStalled && fps >= STALL_FPS_THRESHOLD) {
196
- const durationMs = Date.now() - stallStartMs;
197
- videoStalled = false;
198
- emitOrBuffer("diagnostic", {
199
- name: "videoStall",
200
- data: {
201
- stalled: false,
202
- durationMs
203
- }
204
- });
205
- addTelemetryDiagnostic("videoStall", {
206
- stalled: false,
207
- durationMs
208
- });
209
- }
210
- });
211
- return () => {
212
- statsCollector?.stop();
213
- statsCollector = null;
214
- statsCollectorPeerConnection = null;
215
- };
216
- };
217
- handleConnectionStateChange = (state) => {
218
- if (!opts.telemetryEnabled) return;
219
- if (state !== "connected" && state !== "generating") return;
220
- const peerConnection = manager.getPeerConnection();
221
- if (!peerConnection || peerConnection === statsCollectorPeerConnection) return;
222
- startStatsCollection();
223
- };
224
- if (opts.telemetryEnabled) startStatsCollection();
225
134
  const client = {
226
135
  set: methods.set,
227
136
  setPrompt: methods.setPrompt,
228
137
  isConnected: () => manager.isConnected(),
229
138
  getConnectionState: () => manager.getConnectionState(),
230
139
  disconnect: () => {
231
- statsCollector?.stop();
232
- telemetryReporter.stop();
140
+ observability.stop();
233
141
  stop();
234
142
  manager.cleanup();
235
143
  mirroredStream?.dispose();
@@ -251,7 +159,7 @@ const createRealTimeClient = (opts) => {
251
159
  flush();
252
160
  return client;
253
161
  } catch (error) {
254
- telemetryReporter.stop();
162
+ observability.stop();
255
163
  webrtcManager?.cleanup();
256
164
  mirroredStream?.dispose();
257
165
  throw error;
@@ -262,17 +170,21 @@ const createRealTimeClient = (opts) => {
262
170
  const subscribeUrl = `${baseUrl}/subscribe/${encodeURIComponent(sid)}?IP=${encodeURIComponent(ip)}&port=${encodeURIComponent(port)}&api_key=${encodeURIComponent(apiKey)}`;
263
171
  const { emitter: eventEmitter, emitOrBuffer, flush, stop } = createEventBuffer();
264
172
  let webrtcManager;
173
+ const observability = new RealtimeObservability({
174
+ telemetryEnabled: opts.telemetryEnabled,
175
+ apiKey,
176
+ integration,
177
+ logger,
178
+ onDiagnostic: (event) => emitOrBuffer("diagnostic", event),
179
+ onStats: opts.telemetryEnabled ? (stats) => emitOrBuffer("stats", stats) : void 0
180
+ });
181
+ observability.sessionStarted(sid);
265
182
  try {
266
183
  webrtcManager = new WebRTCManager({
267
184
  webrtcUrl: subscribeUrl,
268
185
  integration,
269
186
  logger,
270
- onDiagnostic: (name, data) => {
271
- emitOrBuffer("diagnostic", {
272
- name,
273
- data
274
- });
275
- },
187
+ observability,
276
188
  onRemoteStream: options.onRemoteStream,
277
189
  onConnectionStateChange: (state) => {
278
190
  emitOrBuffer("connectionChange", state);
@@ -288,6 +200,7 @@ const createRealTimeClient = (opts) => {
288
200
  isConnected: () => manager.isConnected(),
289
201
  getConnectionState: () => manager.getConnectionState(),
290
202
  disconnect: () => {
203
+ observability.stop();
291
204
  stop();
292
205
  manager.cleanup();
293
206
  },
@@ -297,6 +210,7 @@ const createRealTimeClient = (opts) => {
297
210
  flush();
298
211
  return client;
299
212
  } catch (error) {
213
+ observability.stop();
300
214
  webrtcManager?.cleanup();
301
215
  throw error;
302
216
  }
@@ -1,4 +1,4 @@
1
- //#region src/realtime/diagnostics.d.ts
1
+ //#region src/realtime/observability/diagnostics.d.ts
2
2
  /** Connection phase names for timing events. */
3
3
  type ConnectionPhase = "websocket" | "avatar-image" | "initial-prompt" | "webrtc-handshake" | "total";
4
4
  type PhaseTimingEvent = {
@@ -0,0 +1,109 @@
1
+ import { NullTelemetryReporter, TelemetryReporter } from "./telemetry-reporter.js";
2
+ import { WebRTCStatsCollector } from "./webrtc-stats.js";
3
+
4
+ //#region src/realtime/observability/realtime-observability.ts
5
+ const STALL_FPS_THRESHOLD = .5;
6
+ var RealtimeObservability = class {
7
+ telemetryReporter = new NullTelemetryReporter();
8
+ telemetryReporterReady = false;
9
+ pendingTelemetryDiagnostics = [];
10
+ statsCollector = null;
11
+ statsCollectorSource = null;
12
+ videoStalled = false;
13
+ stallStartMs = 0;
14
+ constructor(options) {
15
+ this.options = options;
16
+ }
17
+ diagnostic(name, data, timestamp = Date.now()) {
18
+ this.options.logger.debug(name, data);
19
+ this.options.onDiagnostic?.({
20
+ name,
21
+ data
22
+ });
23
+ this.addTelemetryDiagnostic(name, data, timestamp);
24
+ }
25
+ sessionStarted(sessionId) {
26
+ if (!this.options.telemetryEnabled) return;
27
+ if (this.telemetryReporterReady) this.telemetryReporter.stop();
28
+ const reporter = new TelemetryReporter({
29
+ apiKey: this.options.apiKey,
30
+ sessionId,
31
+ model: this.options.model,
32
+ integration: this.options.integration,
33
+ logger: this.options.logger
34
+ });
35
+ reporter.start();
36
+ this.telemetryReporter = reporter;
37
+ this.telemetryReporterReady = true;
38
+ for (const diagnostic of this.pendingTelemetryDiagnostics) this.telemetryReporter.addDiagnostic(diagnostic);
39
+ this.pendingTelemetryDiagnostics.length = 0;
40
+ }
41
+ setStatsProvider(source) {
42
+ if (!source) {
43
+ this.stopStats();
44
+ return;
45
+ }
46
+ if (source === this.statsCollectorSource) return;
47
+ this.stopStats();
48
+ this.statsCollectorSource = source;
49
+ if (!this.options.telemetryEnabled && !this.options.onStats) return;
50
+ this.statsCollector = new WebRTCStatsCollector();
51
+ this.statsCollector.start(source, (stats) => this.handleStats(stats));
52
+ }
53
+ stopStats() {
54
+ this.statsCollector?.stop();
55
+ this.statsCollector = null;
56
+ this.statsCollectorSource = null;
57
+ this.resetStallDetection();
58
+ }
59
+ stop() {
60
+ this.stopStats();
61
+ this.telemetryReporter.stop();
62
+ this.telemetryReporter = new NullTelemetryReporter();
63
+ this.telemetryReporterReady = false;
64
+ this.pendingTelemetryDiagnostics.length = 0;
65
+ }
66
+ handleStats(stats) {
67
+ this.options.onStats?.(stats);
68
+ this.telemetryReporter.addStats(stats);
69
+ this.detectVideoStall(stats);
70
+ }
71
+ detectVideoStall(stats) {
72
+ const fps = stats.video?.framesPerSecond ?? 0;
73
+ if (!this.videoStalled && stats.video && fps < STALL_FPS_THRESHOLD) {
74
+ this.videoStalled = true;
75
+ this.stallStartMs = Date.now();
76
+ this.diagnostic("videoStall", {
77
+ stalled: true,
78
+ durationMs: 0
79
+ }, this.stallStartMs);
80
+ } else if (this.videoStalled && fps >= STALL_FPS_THRESHOLD) {
81
+ const durationMs = Date.now() - this.stallStartMs;
82
+ this.videoStalled = false;
83
+ this.diagnostic("videoStall", {
84
+ stalled: false,
85
+ durationMs
86
+ });
87
+ }
88
+ }
89
+ addTelemetryDiagnostic(name, data, timestamp) {
90
+ if (!this.options.telemetryEnabled) return;
91
+ const diagnostic = {
92
+ name,
93
+ data,
94
+ timestamp
95
+ };
96
+ if (!this.telemetryReporterReady) {
97
+ this.pendingTelemetryDiagnostics.push(diagnostic);
98
+ return;
99
+ }
100
+ this.telemetryReporter.addDiagnostic(diagnostic);
101
+ }
102
+ resetStallDetection() {
103
+ this.videoStalled = false;
104
+ this.stallStartMs = 0;
105
+ }
106
+ };
107
+
108
+ //#endregion
109
+ export { RealtimeObservability };
@@ -1,7 +1,7 @@
1
- import { VERSION } from "../version.js";
2
- import { buildAuthHeaders } from "../shared/request.js";
1
+ import { VERSION } from "../../version.js";
2
+ import { buildAuthHeaders } from "../../shared/request.js";
3
3
 
4
- //#region src/realtime/telemetry-reporter.ts
4
+ //#region src/realtime/observability/telemetry-reporter.ts
5
5
  const DEFAULT_REPORT_INTERVAL_MS = 1e4;
6
6
  const TELEMETRY_URL = "https://platform.decart.ai/api/v1/telemetry";
7
7
  /**
@@ -0,0 +1,147 @@
1
+ //#region src/realtime/observability/webrtc-stats.d.ts
2
+ type WebRTCStats = {
3
+ timestamp: number;
4
+ video: {
5
+ framesDecoded: number;
6
+ framesDropped: number;
7
+ framesReceived: number;
8
+ keyFramesDecoded: number;
9
+ framesPerSecond: number;
10
+ frameWidth: number;
11
+ frameHeight: number;
12
+ bytesReceived: number;
13
+ packetsReceived: number;
14
+ packetsLost: number;
15
+ jitter: number;
16
+ /** Estimated inbound bitrate in bits/sec, computed from bytesReceived delta. */
17
+ bitrate: number;
18
+ freezeCount: number;
19
+ totalFreezesDuration: number;
20
+ /** Delta: packets lost since previous sample. */
21
+ packetsLostDelta: number;
22
+ /** Delta: frames dropped since previous sample. */
23
+ framesDroppedDelta: number;
24
+ /** Delta: freeze count since previous sample. */
25
+ freezeCountDelta: number;
26
+ /** Delta: freeze duration (seconds) since previous sample. */
27
+ freezeDurationDelta: number;
28
+ /** NACKs sent to the sender (requesting packet retransmission). */
29
+ nackCount: number;
30
+ nackCountDelta: number;
31
+ /** PLIs sent to the sender (full frame retransmission request). */
32
+ pliCount: number;
33
+ /** FIRs sent to the sender (forced intra-refresh request). */
34
+ firCount: number;
35
+ /**
36
+ * Average decode time (ms/frame), cumulative since stream start.
37
+ * Derived from totalDecodeTime/framesDecoded. `null` if the browser
38
+ * hasn't produced the underlying counters yet.
39
+ */
40
+ avgDecodeTimeMs: number | null;
41
+ /** Average jitter-buffer time (ms/frame emitted). Cumulative. */
42
+ avgJitterBufferMs: number | null;
43
+ /**
44
+ * Average total processing delay (ms/frame decoded) — from network
45
+ * receive to decoder output. Cumulative.
46
+ */
47
+ avgProcessingDelayMs: number | null;
48
+ /** Average inter-frame delay at the decoder (ms). */
49
+ avgInterFrameDelayMs: number | null;
50
+ /**
51
+ * Std-dev of inter-frame delay (ms), computed from
52
+ * totalInterFrameDelay + totalSquaredInterFrameDelay.
53
+ */
54
+ interFrameDelayStdDevMs: number | null;
55
+ /** Current target delay of the jitter buffer (ms). */
56
+ jitterBufferTargetDelayMs: number | null;
57
+ /** Current minimum delay of the jitter buffer (ms). */
58
+ jitterBufferMinimumDelayMs: number | null;
59
+ /** Which decoder the browser picked (e.g. "libvpx", "ExternalDecoder"). */
60
+ decoderImplementation: string;
61
+ } | null;
62
+ audio: {
63
+ bytesReceived: number;
64
+ packetsReceived: number;
65
+ packetsLost: number;
66
+ jitter: number;
67
+ /** Estimated inbound bitrate in bits/sec, computed from bytesReceived delta. */
68
+ bitrate: number;
69
+ /** Delta: packets lost since previous sample. */
70
+ packetsLostDelta: number;
71
+ } | null;
72
+ /** Outbound video track stats (from the local camera/screen share being sent). */
73
+ outboundVideo: {
74
+ /** Why the encoder is limiting quality: "none", "bandwidth", "cpu", or "other". */
75
+ qualityLimitationReason: string;
76
+ /** Cumulative time (seconds) spent in each quality limitation state. */
77
+ qualityLimitationDurations: Record<string, number>;
78
+ bytesSent: number;
79
+ packetsSent: number;
80
+ framesPerSecond: number;
81
+ frameWidth: number;
82
+ frameHeight: number;
83
+ /** Estimated outbound bitrate in bits/sec, computed from bytesSent delta. */
84
+ bitrate: number;
85
+ /** Encoder's current target bitrate in kbps (BWE output). */
86
+ targetBitrateKbps: number | null;
87
+ /** Average encode time per frame (ms), cumulative. */
88
+ avgEncodeTimeMs: number | null;
89
+ /** Average packet send delay (ms), cumulative. */
90
+ avgPacketSendDelayMs: number | null;
91
+ /** Average quantization parameter across encoded frames (lower is better). */
92
+ avgQp: number | null;
93
+ /** NACKs received from receiver (retransmission requests). */
94
+ nackCount: number;
95
+ /** PLIs received from receiver. */
96
+ pliCount: number;
97
+ /** FIRs received from receiver. */
98
+ firCount: number;
99
+ retransmittedBytesSent: number;
100
+ retransmittedPacketsSent: number;
101
+ /** Which encoder the browser picked (e.g. "libvpx", "SimulcastEncoderAdapter"). */
102
+ encoderImplementation: string;
103
+ } | null;
104
+ /**
105
+ * Remote-inbound stats — what the far end reports *about its reception
106
+ * of our outbound stream*. Answers "does the server think we're lossy?"
107
+ * independently of what we see locally. Populated from
108
+ * `remote-inbound-rtp` reports.
109
+ */
110
+ remoteInbound: {
111
+ fractionLost: number | null;
112
+ /** In seconds. */
113
+ jitter: number | null;
114
+ /** In seconds. Often more accurate than connection.currentRoundTripTime. */
115
+ roundTripTime: number | null;
116
+ } | null;
117
+ connection: {
118
+ /** Current round-trip time in seconds, or null if unavailable. */
119
+ currentRoundTripTime: number | null;
120
+ /** Available outgoing bitrate estimate in bits/sec, or null if unavailable. */
121
+ availableOutgoingBitrate: number | null;
122
+ /**
123
+ * Selected ICE candidate pairs (usually one per PC). Populated from
124
+ * the `candidate-pair` report with state="succeeded" plus the matching
125
+ * `local-candidate` / `remote-candidate` lookups. Lets diagnostic tools
126
+ * tell direct-UDP sessions from TURN-relayed ones — the path affects
127
+ * jitter and failure modes, so this is essential signal for
128
+ * benchmarking and incident triage.
129
+ */
130
+ selectedCandidatePairs: Array<{
131
+ local: IceCandidateInfo;
132
+ remote: IceCandidateInfo;
133
+ }>;
134
+ };
135
+ };
136
+ /** One side of an ICE candidate pair (sender or receiver). */
137
+ type IceCandidateInfo = {
138
+ /** "host" | "srflx" | "prflx" | "relay" */
139
+ candidateType: string;
140
+ /** IP (v4 or v6). May be `""` for mDNS-obfuscated host candidates. */
141
+ address: string;
142
+ port: number;
143
+ /** "udp" | "tcp" */
144
+ protocol: string;
145
+ };
146
+ //#endregion
147
+ export { WebRTCStats };
@@ -0,0 +1,278 @@
1
+ //#region src/realtime/observability/webrtc-stats.ts
2
+ const DEFAULT_INTERVAL_MS = 1e3;
3
+ const MIN_INTERVAL_MS = 500;
4
+ var WebRTCStatsCollector = class {
5
+ source = null;
6
+ intervalId = null;
7
+ prevBytesVideo = 0;
8
+ prevBytesAudio = 0;
9
+ prevBytesSentVideo = 0;
10
+ prevTimestamp = 0;
11
+ prevPacketsLostVideo = 0;
12
+ prevFramesDropped = 0;
13
+ prevFreezeCount = 0;
14
+ prevFreezeDuration = 0;
15
+ prevPacketsLostAudio = 0;
16
+ prevNackCountInbound = 0;
17
+ onStats = null;
18
+ intervalMs;
19
+ constructor(options = {}) {
20
+ this.intervalMs = Math.max(options.intervalMs ?? DEFAULT_INTERVAL_MS, MIN_INTERVAL_MS);
21
+ }
22
+ /** Attach to a stats provider and start polling. */
23
+ start(source, onStats) {
24
+ this.stop();
25
+ this.source = source;
26
+ this.onStats = onStats;
27
+ this.prevBytesVideo = 0;
28
+ this.prevBytesAudio = 0;
29
+ this.prevBytesSentVideo = 0;
30
+ this.prevTimestamp = 0;
31
+ this.prevPacketsLostVideo = 0;
32
+ this.prevFramesDropped = 0;
33
+ this.prevFreezeCount = 0;
34
+ this.prevFreezeDuration = 0;
35
+ this.prevPacketsLostAudio = 0;
36
+ this.prevNackCountInbound = 0;
37
+ this.intervalId = setInterval(() => this.collect(), this.intervalMs);
38
+ }
39
+ /** Stop polling and release resources. */
40
+ stop() {
41
+ if (this.intervalId !== null) {
42
+ clearInterval(this.intervalId);
43
+ this.intervalId = null;
44
+ }
45
+ this.source = null;
46
+ this.onStats = null;
47
+ }
48
+ isRunning() {
49
+ return this.intervalId !== null;
50
+ }
51
+ async collect() {
52
+ if (!this.source || !this.onStats) return;
53
+ try {
54
+ const rawStats = await this.source.getStats();
55
+ const stats = this.parse(rawStats);
56
+ this.onStats(stats);
57
+ } catch {
58
+ this.stop();
59
+ }
60
+ }
61
+ parse(rawStats) {
62
+ const now = performance.now();
63
+ const elapsed = this.prevTimestamp > 0 ? (now - this.prevTimestamp) / 1e3 : 0;
64
+ let video = null;
65
+ let audio = null;
66
+ let outboundVideo = null;
67
+ let remoteInbound = null;
68
+ const connection = {
69
+ currentRoundTripTime: null,
70
+ availableOutgoingBitrate: null,
71
+ selectedCandidatePairs: []
72
+ };
73
+ const succeededPairs = [];
74
+ rawStats.forEach((report) => {
75
+ if (report.type === "inbound-rtp" && report.kind === "video") {
76
+ const bytesReceived = report.bytesReceived ?? 0;
77
+ const bitrate = elapsed > 0 ? (bytesReceived - this.prevBytesVideo) * 8 / elapsed : 0;
78
+ this.prevBytesVideo = bytesReceived;
79
+ const r = report;
80
+ const packetsLost = r.packetsLost ?? 0;
81
+ const framesDropped = r.framesDropped ?? 0;
82
+ const freezeCount = r.freezeCount ?? 0;
83
+ const freezeDuration = r.totalFreezesDuration ?? 0;
84
+ const framesDecoded = r.framesDecoded ?? 0;
85
+ const nackCount = r.nackCount ?? 0;
86
+ const jbEmitted = r.jitterBufferEmittedCount ?? 0;
87
+ const totalDecodeTime = r.totalDecodeTime ?? 0;
88
+ const totalProcessingDelay = r.totalProcessingDelay ?? 0;
89
+ const totalInterFrameDelay = r.totalInterFrameDelay ?? 0;
90
+ const totalSquaredInterFrameDelay = r.totalSquaredInterFrameDelay ?? 0;
91
+ const jitterBufferDelay = r.jitterBufferDelay ?? 0;
92
+ const jitterBufferTargetDelay = r.jitterBufferTargetDelay ?? 0;
93
+ const jitterBufferMinimumDelay = r.jitterBufferMinimumDelay ?? 0;
94
+ const avgDecodeTimeMs = framesDecoded > 0 ? totalDecodeTime / framesDecoded * 1e3 : null;
95
+ const avgProcessingDelayMs = framesDecoded > 0 ? totalProcessingDelay / framesDecoded * 1e3 : null;
96
+ const avgInterFrameDelayMs = framesDecoded > 0 ? totalInterFrameDelay / framesDecoded * 1e3 : null;
97
+ const interFrameDelayStdDevMs = framesDecoded > 0 ? Math.sqrt(Math.max(0, totalSquaredInterFrameDelay / framesDecoded - (totalInterFrameDelay / framesDecoded) ** 2)) * 1e3 : null;
98
+ const avgJitterBufferMs = jbEmitted > 0 ? jitterBufferDelay / jbEmitted * 1e3 : null;
99
+ const jitterBufferTargetDelayMs = jbEmitted > 0 ? jitterBufferTargetDelay / jbEmitted * 1e3 : null;
100
+ const jitterBufferMinimumDelayMs = jbEmitted > 0 ? jitterBufferMinimumDelay / jbEmitted * 1e3 : null;
101
+ video = {
102
+ framesDecoded,
103
+ framesDropped,
104
+ framesReceived: r.framesReceived ?? 0,
105
+ keyFramesDecoded: r.keyFramesDecoded ?? 0,
106
+ framesPerSecond: r.framesPerSecond ?? 0,
107
+ frameWidth: r.frameWidth ?? 0,
108
+ frameHeight: r.frameHeight ?? 0,
109
+ bytesReceived,
110
+ packetsReceived: r.packetsReceived ?? 0,
111
+ packetsLost,
112
+ jitter: r.jitter ?? 0,
113
+ bitrate: Math.round(bitrate),
114
+ freezeCount,
115
+ totalFreezesDuration: freezeDuration,
116
+ packetsLostDelta: Math.max(0, packetsLost - this.prevPacketsLostVideo),
117
+ framesDroppedDelta: Math.max(0, framesDropped - this.prevFramesDropped),
118
+ freezeCountDelta: Math.max(0, freezeCount - this.prevFreezeCount),
119
+ freezeDurationDelta: Math.max(0, freezeDuration - this.prevFreezeDuration),
120
+ nackCount,
121
+ nackCountDelta: Math.max(0, nackCount - this.prevNackCountInbound),
122
+ pliCount: r.pliCount ?? 0,
123
+ firCount: r.firCount ?? 0,
124
+ avgDecodeTimeMs,
125
+ avgJitterBufferMs,
126
+ avgProcessingDelayMs,
127
+ avgInterFrameDelayMs,
128
+ interFrameDelayStdDevMs,
129
+ jitterBufferTargetDelayMs,
130
+ jitterBufferMinimumDelayMs,
131
+ decoderImplementation: r.decoderImplementation ?? ""
132
+ };
133
+ this.prevPacketsLostVideo = packetsLost;
134
+ this.prevFramesDropped = framesDropped;
135
+ this.prevFreezeCount = freezeCount;
136
+ this.prevFreezeDuration = freezeDuration;
137
+ this.prevNackCountInbound = nackCount;
138
+ }
139
+ if (report.type === "outbound-rtp" && report.kind === "video") {
140
+ const r = report;
141
+ const bytesSent = r.bytesSent ?? 0;
142
+ const packetsSent = r.packetsSent ?? 0;
143
+ const frameWidth = r.frameWidth ?? 0;
144
+ const frameHeight = r.frameHeight ?? 0;
145
+ const pixels = frameWidth * frameHeight;
146
+ const framesEncoded = r.framesEncoded ?? 0;
147
+ const totalEncodeTime = r.totalEncodeTime ?? 0;
148
+ const totalPacketSendDelay = r.totalPacketSendDelay ?? 0;
149
+ const qpSum = r.qpSum ?? 0;
150
+ const nackCount = r.nackCount ?? 0;
151
+ const pliCount = r.pliCount ?? 0;
152
+ const firCount = r.firCount ?? 0;
153
+ const retransmittedBytesSent = r.retransmittedBytesSent ?? 0;
154
+ const retransmittedPacketsSent = r.retransmittedPacketsSent ?? 0;
155
+ const targetBitrate = r.targetBitrate ?? null;
156
+ const avgEncodeTimeMs = framesEncoded > 0 ? totalEncodeTime / framesEncoded * 1e3 : null;
157
+ const avgPacketSendDelayMs = packetsSent > 0 ? totalPacketSendDelay / packetsSent * 1e3 : null;
158
+ const avgQp = framesEncoded > 0 ? qpSum / framesEncoded : null;
159
+ if (outboundVideo === null) outboundVideo = {
160
+ qualityLimitationReason: r.qualityLimitationReason ?? "none",
161
+ qualityLimitationDurations: r.qualityLimitationDurations ?? {},
162
+ bytesSent,
163
+ packetsSent,
164
+ framesPerSecond: r.framesPerSecond ?? 0,
165
+ frameWidth,
166
+ frameHeight,
167
+ bitrate: 0,
168
+ targetBitrateKbps: targetBitrate != null ? Math.round(targetBitrate / 1e3) : null,
169
+ avgEncodeTimeMs,
170
+ avgPacketSendDelayMs,
171
+ avgQp,
172
+ nackCount,
173
+ pliCount,
174
+ firCount,
175
+ retransmittedBytesSent,
176
+ retransmittedPacketsSent,
177
+ encoderImplementation: r.encoderImplementation ?? ""
178
+ };
179
+ else {
180
+ outboundVideo.bytesSent += bytesSent;
181
+ outboundVideo.packetsSent += packetsSent;
182
+ outboundVideo.nackCount += nackCount;
183
+ outboundVideo.pliCount += pliCount;
184
+ outboundVideo.firCount += firCount;
185
+ outboundVideo.retransmittedBytesSent += retransmittedBytesSent;
186
+ outboundVideo.retransmittedPacketsSent += retransmittedPacketsSent;
187
+ if (pixels > outboundVideo.frameWidth * outboundVideo.frameHeight) {
188
+ outboundVideo.frameWidth = frameWidth;
189
+ outboundVideo.frameHeight = frameHeight;
190
+ outboundVideo.framesPerSecond = r.framesPerSecond ?? 0;
191
+ outboundVideo.qualityLimitationReason = r.qualityLimitationReason ?? "none";
192
+ outboundVideo.qualityLimitationDurations = r.qualityLimitationDurations ?? {};
193
+ outboundVideo.targetBitrateKbps = targetBitrate != null ? Math.round(targetBitrate / 1e3) : null;
194
+ outboundVideo.avgEncodeTimeMs = avgEncodeTimeMs;
195
+ outboundVideo.avgPacketSendDelayMs = avgPacketSendDelayMs;
196
+ outboundVideo.avgQp = avgQp;
197
+ outboundVideo.encoderImplementation = r.encoderImplementation ?? "";
198
+ }
199
+ }
200
+ }
201
+ if (report.type === "remote-inbound-rtp" && report.kind === "video") {
202
+ const r = report;
203
+ remoteInbound = {
204
+ fractionLost: r.fractionLost ?? null,
205
+ jitter: r.jitter ?? null,
206
+ roundTripTime: r.roundTripTime ?? null
207
+ };
208
+ }
209
+ if (report.type === "inbound-rtp" && report.kind === "audio") {
210
+ const bytesReceived = report.bytesReceived ?? 0;
211
+ const bitrate = elapsed > 0 ? (bytesReceived - this.prevBytesAudio) * 8 / elapsed : 0;
212
+ this.prevBytesAudio = bytesReceived;
213
+ const r = report;
214
+ const audioPacketsLost = r.packetsLost ?? 0;
215
+ audio = {
216
+ bytesReceived,
217
+ packetsReceived: r.packetsReceived ?? 0,
218
+ packetsLost: audioPacketsLost,
219
+ jitter: r.jitter ?? 0,
220
+ bitrate: Math.round(bitrate),
221
+ packetsLostDelta: Math.max(0, audioPacketsLost - this.prevPacketsLostAudio)
222
+ };
223
+ this.prevPacketsLostAudio = audioPacketsLost;
224
+ }
225
+ if (report.type === "candidate-pair") {
226
+ const r = report;
227
+ if (r.state === "succeeded") {
228
+ connection.currentRoundTripTime = r.currentRoundTripTime ?? null;
229
+ connection.availableOutgoingBitrate = r.availableOutgoingBitrate ?? null;
230
+ const localId = r.localCandidateId;
231
+ const remoteId = r.remoteCandidateId;
232
+ if (localId && remoteId) succeededPairs.push({
233
+ localId,
234
+ remoteId
235
+ });
236
+ }
237
+ }
238
+ });
239
+ if (succeededPairs.length > 0) {
240
+ const toInfo = (id) => {
241
+ const c = rawStats.get(id);
242
+ if (!c) return null;
243
+ return {
244
+ candidateType: c.candidateType ?? "",
245
+ address: c.address ?? c.ip ?? "",
246
+ port: c.port ?? 0,
247
+ protocol: c.protocol ?? ""
248
+ };
249
+ };
250
+ for (const { localId, remoteId } of succeededPairs) {
251
+ const local = toInfo(localId);
252
+ const remote = toInfo(remoteId);
253
+ if (local && remote) connection.selectedCandidatePairs.push({
254
+ local,
255
+ remote
256
+ });
257
+ }
258
+ }
259
+ const ov = outboundVideo;
260
+ if (ov !== null) {
261
+ const outBitrate = elapsed > 0 ? (ov.bytesSent - this.prevBytesSentVideo) * 8 / elapsed : 0;
262
+ ov.bitrate = Math.max(0, Math.round(outBitrate));
263
+ this.prevBytesSentVideo = ov.bytesSent;
264
+ }
265
+ this.prevTimestamp = now;
266
+ return {
267
+ timestamp: Date.now(),
268
+ video,
269
+ audio,
270
+ outboundVideo,
271
+ connection,
272
+ remoteInbound
273
+ };
274
+ }
275
+ };
276
+
277
+ //#endregion
278
+ export { WebRTCStatsCollector };
@@ -1,6 +1,7 @@
1
1
  import { DecartSDKError } from "../utils/errors.js";
2
- import { DiagnosticEvent } from "./diagnostics.js";
3
2
  import { ConnectionState } from "./types.js";
3
+ import { DiagnosticEvent } from "./observability/diagnostics.js";
4
+ import { WebRTCStats } from "./observability/webrtc-stats.js";
4
5
 
5
6
  //#region src/realtime/subscribe-client.d.ts
6
7
 
@@ -8,6 +9,7 @@ type SubscribeEvents = {
8
9
  connectionChange: ConnectionState;
9
10
  error: DecartSDKError;
10
11
  diagnostic: DiagnosticEvent;
12
+ stats: WebRTCStats;
11
13
  };
12
14
  type RealTimeSubscribeClient = {
13
15
  isConnected: () => boolean;
@@ -4,17 +4,16 @@ import mitt from "mitt";
4
4
  //#region src/realtime/webrtc-connection.ts
5
5
  const ICE_SERVERS = [{ urls: "stun:stun.l.google.com:19302" }];
6
6
  const SETUP_TIMEOUT_MS = 3e4;
7
- const noopDiagnostic = () => {};
8
7
  var WebRTCConnection = class {
9
8
  pc = null;
10
9
  ws = null;
11
10
  localStream = null;
12
11
  connectionReject = null;
13
12
  logger;
14
- emitDiagnostic;
13
+ observability;
15
14
  state = "disconnected";
16
15
  websocketMessagesEmitter = mitt();
17
- constructor(callbacks = {}) {
16
+ constructor(callbacks) {
18
17
  this.callbacks = callbacks;
19
18
  this.logger = callbacks.logger ?? {
20
19
  debug() {},
@@ -22,7 +21,7 @@ var WebRTCConnection = class {
22
21
  warn() {},
23
22
  error() {}
24
23
  };
25
- this.emitDiagnostic = callbacks.onDiagnostic ?? noopDiagnostic;
24
+ this.observability = callbacks.observability;
26
25
  }
27
26
  getPeerConnection() {
28
27
  return this.pc;
@@ -46,7 +45,7 @@ var WebRTCConnection = class {
46
45
  this.ws = new WebSocket(wsUrl);
47
46
  this.ws.onopen = () => {
48
47
  clearTimeout(timer);
49
- this.emitDiagnostic("phaseTiming", {
48
+ this.observability.diagnostic("phaseTiming", {
50
49
  phase: "websocket",
51
50
  durationMs: performance.now() - wsStart,
52
51
  success: true
@@ -63,7 +62,7 @@ var WebRTCConnection = class {
63
62
  this.ws.onerror = () => {
64
63
  clearTimeout(timer);
65
64
  const error = /* @__PURE__ */ new Error("WebSocket error");
66
- this.emitDiagnostic("phaseTiming", {
65
+ this.observability.diagnostic("phaseTiming", {
67
66
  phase: "websocket",
68
67
  durationMs: performance.now() - wsStart,
69
68
  success: false,
@@ -85,7 +84,7 @@ var WebRTCConnection = class {
85
84
  prompt: this.callbacks.initialPrompt?.text,
86
85
  enhance: this.callbacks.initialPrompt?.enhance
87
86
  }), connectAbort]);
88
- this.emitDiagnostic("phaseTiming", {
87
+ this.observability.diagnostic("phaseTiming", {
89
88
  phase: "avatar-image",
90
89
  durationMs: performance.now() - imageStart,
91
90
  success: true
@@ -93,7 +92,7 @@ var WebRTCConnection = class {
93
92
  } else if (this.callbacks.initialPrompt) {
94
93
  const promptStart = performance.now();
95
94
  await Promise.race([this.sendInitialPrompt(this.callbacks.initialPrompt), connectAbort]);
96
- this.emitDiagnostic("phaseTiming", {
95
+ this.observability.diagnostic("phaseTiming", {
97
96
  phase: "initial-prompt",
98
97
  durationMs: performance.now() - promptStart,
99
98
  success: true
@@ -101,7 +100,7 @@ var WebRTCConnection = class {
101
100
  } else if (localStream) {
102
101
  const nullStart = performance.now();
103
102
  await Promise.race([this.setImageBase64(null, { prompt: null }), connectAbort]);
104
- this.emitDiagnostic("phaseTiming", {
103
+ this.observability.diagnostic("phaseTiming", {
105
104
  phase: "initial-prompt",
106
105
  durationMs: performance.now() - nullStart,
107
106
  success: true
@@ -113,7 +112,7 @@ var WebRTCConnection = class {
113
112
  const checkConnection = setInterval(() => {
114
113
  if (this.state === "connected" || this.state === "generating") {
115
114
  clearInterval(checkConnection);
116
- this.emitDiagnostic("phaseTiming", {
115
+ this.observability.diagnostic("phaseTiming", {
117
116
  phase: "webrtc-handshake",
118
117
  durationMs: performance.now() - handshakeStart,
119
118
  success: true
@@ -121,7 +120,7 @@ var WebRTCConnection = class {
121
120
  resolve();
122
121
  } else if (this.state === "disconnected") {
123
122
  clearInterval(checkConnection);
124
- this.emitDiagnostic("phaseTiming", {
123
+ this.observability.diagnostic("phaseTiming", {
125
124
  phase: "webrtc-handshake",
126
125
  durationMs: performance.now() - handshakeStart,
127
126
  success: false,
@@ -130,7 +129,7 @@ var WebRTCConnection = class {
130
129
  reject(/* @__PURE__ */ new Error("Connection lost during WebRTC handshake"));
131
130
  } else if (Date.now() >= deadline) {
132
131
  clearInterval(checkConnection);
133
- this.emitDiagnostic("phaseTiming", {
132
+ this.observability.diagnostic("phaseTiming", {
134
133
  phase: "webrtc-handshake",
135
134
  durationMs: performance.now() - handshakeStart,
136
135
  success: false,
@@ -141,7 +140,7 @@ var WebRTCConnection = class {
141
140
  }, 100);
142
141
  connectAbort.catch(() => clearInterval(checkConnection));
143
142
  }), connectAbort]);
144
- this.emitDiagnostic("phaseTiming", {
143
+ this.observability.diagnostic("phaseTiming", {
145
144
  phase: "total",
146
145
  durationMs: performance.now() - totalStart,
147
146
  success: true
@@ -219,7 +218,7 @@ var WebRTCConnection = class {
219
218
  case "ice-candidate":
220
219
  if (msg.candidate) {
221
220
  await this.pc.addIceCandidate(msg.candidate);
222
- this.emitDiagnostic("iceCandidate", {
221
+ this.observability.diagnostic("iceCandidate", {
223
222
  source: "remote",
224
223
  candidateType: msg.candidate.candidate?.match(/typ (\w+)/)?.[1] ?? "unknown",
225
224
  protocol: msg.candidate.candidate?.match(/udp|tcp/i)?.[0]?.toLowerCase() ?? "unknown"
@@ -332,7 +331,7 @@ var WebRTCConnection = class {
332
331
  type: "ice-candidate",
333
332
  candidate: e.candidate
334
333
  });
335
- if (e.candidate) this.emitDiagnostic("iceCandidate", {
334
+ if (e.candidate) this.observability.diagnostic("iceCandidate", {
336
335
  source: "local",
337
336
  candidateType: e.candidate.type ?? "unknown",
338
337
  protocol: e.candidate.protocol ?? "unknown",
@@ -344,7 +343,7 @@ var WebRTCConnection = class {
344
343
  this.pc.onconnectionstatechange = () => {
345
344
  if (!this.pc) return;
346
345
  const s = this.pc.connectionState;
347
- this.emitDiagnostic("peerConnectionStateChange", {
346
+ this.observability.diagnostic("peerConnectionStateChange", {
348
347
  state: s,
349
348
  previousState: prevPcState,
350
349
  timestampMs: performance.now()
@@ -359,7 +358,7 @@ var WebRTCConnection = class {
359
358
  this.pc.oniceconnectionstatechange = () => {
360
359
  if (!this.pc) return;
361
360
  const newIceState = this.pc.iceConnectionState;
362
- this.emitDiagnostic("iceStateChange", {
361
+ this.observability.diagnostic("iceStateChange", {
363
362
  state: newIceState,
364
363
  previousState: prevIceState,
365
364
  timestampMs: performance.now()
@@ -374,7 +373,7 @@ var WebRTCConnection = class {
374
373
  this.pc.onsignalingstatechange = () => {
375
374
  if (!this.pc) return;
376
375
  const newState = this.pc.signalingState;
377
- this.emitDiagnostic("signalingStateChange", {
376
+ this.observability.diagnostic("signalingStateChange", {
378
377
  state: newState,
379
378
  previousState: prevSignalingState,
380
379
  timestampMs: performance.now()
@@ -398,7 +397,7 @@ var WebRTCConnection = class {
398
397
  if (r.id === report.localCandidateId) localCandidate = r;
399
398
  if (r.id === report.remoteCandidateId) remoteCandidate = r;
400
399
  });
401
- if (localCandidate && remoteCandidate) this.emitDiagnostic("selectedCandidatePair", {
400
+ if (localCandidate && remoteCandidate) this.observability.diagnostic("selectedCandidatePair", {
402
401
  local: {
403
402
  candidateType: String(localCandidate.candidateType ?? "unknown"),
404
403
  protocol: String(localCandidate.protocol ?? "unknown"),
@@ -21,6 +21,7 @@ var WebRTCManager = class {
21
21
  connection;
22
22
  config;
23
23
  logger;
24
+ observability;
24
25
  localStream = null;
25
26
  subscribeMode = false;
26
27
  managerState = "disconnected";
@@ -28,6 +29,7 @@ var WebRTCManager = class {
28
29
  isReconnecting = false;
29
30
  intentionalDisconnect = false;
30
31
  reconnectGeneration = 0;
32
+ statsProviderConnection = null;
31
33
  constructor(config) {
32
34
  this.config = config;
33
35
  this.logger = config.logger ?? {
@@ -36,6 +38,7 @@ var WebRTCManager = class {
36
38
  warn() {},
37
39
  error() {}
38
40
  };
41
+ this.observability = config.observability;
39
42
  this.connection = new WebRTCConnection({
40
43
  onRemoteStream: config.onRemoteStream,
41
44
  onStateChange: (state) => this.handleConnectionStateChange(state),
@@ -46,7 +49,7 @@ var WebRTCManager = class {
46
49
  initialImage: config.initialImage,
47
50
  initialPrompt: config.initialPrompt,
48
51
  logger: this.logger,
49
- onDiagnostic: config.onDiagnostic
52
+ observability: this.observability
50
53
  });
51
54
  }
52
55
  emitState(state) {
@@ -56,15 +59,28 @@ var WebRTCManager = class {
56
59
  this.config.onConnectionStateChange?.(state);
57
60
  }
58
61
  }
62
+ syncStatsProvider() {
63
+ const pc = this.getPeerConnection();
64
+ const isLive = this.managerState === "connected" || this.managerState === "generating";
65
+ if (isLive && pc && pc !== this.statsProviderConnection) {
66
+ this.statsProviderConnection = pc;
67
+ this.observability.setStatsProvider(pc);
68
+ } else if (!isLive && this.statsProviderConnection) {
69
+ this.statsProviderConnection = null;
70
+ this.observability.setStatsProvider(null);
71
+ }
72
+ }
59
73
  handleConnectionStateChange(state) {
60
74
  if (this.intentionalDisconnect) {
61
75
  this.emitState("disconnected");
76
+ this.syncStatsProvider();
62
77
  return;
63
78
  }
64
79
  if (this.isReconnecting) {
65
80
  if (state === "connected" || state === "generating") {
66
81
  this.isReconnecting = false;
67
82
  this.emitState(state);
83
+ this.syncStatsProvider();
68
84
  }
69
85
  return;
70
86
  }
@@ -73,6 +89,7 @@ var WebRTCManager = class {
73
89
  return;
74
90
  }
75
91
  this.emitState(state);
92
+ this.syncStatsProvider();
76
93
  }
77
94
  async reconnect() {
78
95
  if (this.isReconnecting || this.intentionalDisconnect) return;
@@ -80,6 +97,8 @@ var WebRTCManager = class {
80
97
  const reconnectGeneration = ++this.reconnectGeneration;
81
98
  this.isReconnecting = true;
82
99
  this.emitState("reconnecting");
100
+ this.observability.setStatsProvider(null);
101
+ this.statsProviderConnection = null;
83
102
  const reconnectStart = performance.now();
84
103
  try {
85
104
  let attemptCount = 0;
@@ -101,7 +120,7 @@ var WebRTCManager = class {
101
120
  error: error.message,
102
121
  attempt: error.attemptNumber
103
122
  });
104
- this.config.onDiagnostic?.("reconnect", {
123
+ this.observability.diagnostic("reconnect", {
105
124
  attempt: error.attemptNumber,
106
125
  maxAttempts: RETRY_OPTIONS.retries + 1,
107
126
  durationMs: performance.now() - reconnectStart,
@@ -116,7 +135,7 @@ var WebRTCManager = class {
116
135
  return !PERMANENT_ERRORS.some((err) => msg.includes(err));
117
136
  }
118
137
  });
119
- this.config.onDiagnostic?.("reconnect", {
138
+ this.observability.diagnostic("reconnect", {
120
139
  attempt: attemptCount,
121
140
  maxAttempts: RETRY_OPTIONS.retries + 1,
122
141
  durationMs: performance.now() - reconnectStart,
@@ -166,6 +185,8 @@ var WebRTCManager = class {
166
185
  this.reconnectGeneration += 1;
167
186
  this.connection.cleanup();
168
187
  this.localStream = null;
188
+ this.statsProviderConnection = null;
189
+ this.observability.setStatsProvider(null);
169
190
  this.emitState("disconnected");
170
191
  }
171
192
  isConnected() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decartai/sdk",
3
- "version": "0.0.67",
3
+ "version": "0.0.68",
4
4
  "description": "Decart's JavaScript SDK",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,59 +0,0 @@
1
- //#region src/realtime/webrtc-stats.d.ts
2
- type WebRTCStats = {
3
- timestamp: number;
4
- video: {
5
- framesDecoded: number;
6
- framesDropped: number;
7
- framesPerSecond: number;
8
- frameWidth: number;
9
- frameHeight: number;
10
- bytesReceived: number;
11
- packetsReceived: number;
12
- packetsLost: number;
13
- jitter: number;
14
- /** Estimated inbound bitrate in bits/sec, computed from bytesReceived delta. */
15
- bitrate: number;
16
- freezeCount: number;
17
- totalFreezesDuration: number;
18
- /** Delta: packets lost since previous sample. */
19
- packetsLostDelta: number;
20
- /** Delta: frames dropped since previous sample. */
21
- framesDroppedDelta: number;
22
- /** Delta: freeze count since previous sample. */
23
- freezeCountDelta: number;
24
- /** Delta: freeze duration (seconds) since previous sample. */
25
- freezeDurationDelta: number;
26
- } | null;
27
- audio: {
28
- bytesReceived: number;
29
- packetsReceived: number;
30
- packetsLost: number;
31
- jitter: number;
32
- /** Estimated inbound bitrate in bits/sec, computed from bytesReceived delta. */
33
- bitrate: number;
34
- /** Delta: packets lost since previous sample. */
35
- packetsLostDelta: number;
36
- } | null;
37
- /** Outbound video track stats (from the local camera/screen share being sent). */
38
- outboundVideo: {
39
- /** Why the encoder is limiting quality: "none", "bandwidth", "cpu", or "other". */
40
- qualityLimitationReason: string;
41
- /** Cumulative time (seconds) spent in each quality limitation state. */
42
- qualityLimitationDurations: Record<string, number>;
43
- bytesSent: number;
44
- packetsSent: number;
45
- framesPerSecond: number;
46
- frameWidth: number;
47
- frameHeight: number;
48
- /** Estimated outbound bitrate in bits/sec, computed from bytesSent delta. */
49
- bitrate: number;
50
- } | null;
51
- connection: {
52
- /** Current round-trip time in seconds, or null if unavailable. */
53
- currentRoundTripTime: number | null;
54
- /** Available outgoing bitrate estimate in bits/sec, or null if unavailable. */
55
- availableOutgoingBitrate: number | null;
56
- };
57
- };
58
- //#endregion
59
- export { WebRTCStats };
@@ -1,154 +0,0 @@
1
- //#region src/realtime/webrtc-stats.ts
2
- const DEFAULT_INTERVAL_MS = 1e3;
3
- const MIN_INTERVAL_MS = 500;
4
- var WebRTCStatsCollector = class {
5
- pc = null;
6
- intervalId = null;
7
- prevBytesVideo = 0;
8
- prevBytesAudio = 0;
9
- prevBytesSentVideo = 0;
10
- prevTimestamp = 0;
11
- prevPacketsLostVideo = 0;
12
- prevFramesDropped = 0;
13
- prevFreezeCount = 0;
14
- prevFreezeDuration = 0;
15
- prevPacketsLostAudio = 0;
16
- onStats = null;
17
- intervalMs;
18
- constructor(options = {}) {
19
- this.intervalMs = Math.max(options.intervalMs ?? DEFAULT_INTERVAL_MS, MIN_INTERVAL_MS);
20
- }
21
- /** Attach to a peer connection and start polling. */
22
- start(pc, onStats) {
23
- this.stop();
24
- this.pc = pc;
25
- this.onStats = onStats;
26
- this.prevBytesVideo = 0;
27
- this.prevBytesAudio = 0;
28
- this.prevBytesSentVideo = 0;
29
- this.prevTimestamp = 0;
30
- this.prevPacketsLostVideo = 0;
31
- this.prevFramesDropped = 0;
32
- this.prevFreezeCount = 0;
33
- this.prevFreezeDuration = 0;
34
- this.prevPacketsLostAudio = 0;
35
- this.intervalId = setInterval(() => this.collect(), this.intervalMs);
36
- }
37
- /** Stop polling and release resources. */
38
- stop() {
39
- if (this.intervalId !== null) {
40
- clearInterval(this.intervalId);
41
- this.intervalId = null;
42
- }
43
- this.pc = null;
44
- this.onStats = null;
45
- }
46
- isRunning() {
47
- return this.intervalId !== null;
48
- }
49
- async collect() {
50
- if (!this.pc || !this.onStats) return;
51
- try {
52
- const rawStats = await this.pc.getStats();
53
- const stats = this.parse(rawStats);
54
- this.onStats(stats);
55
- } catch {
56
- this.stop();
57
- }
58
- }
59
- parse(rawStats) {
60
- const now = performance.now();
61
- const elapsed = this.prevTimestamp > 0 ? (now - this.prevTimestamp) / 1e3 : 0;
62
- let video = null;
63
- let audio = null;
64
- let outboundVideo = null;
65
- const connection = {
66
- currentRoundTripTime: null,
67
- availableOutgoingBitrate: null
68
- };
69
- rawStats.forEach((report) => {
70
- if (report.type === "inbound-rtp" && report.kind === "video") {
71
- const bytesReceived = report.bytesReceived ?? 0;
72
- const bitrate = elapsed > 0 ? (bytesReceived - this.prevBytesVideo) * 8 / elapsed : 0;
73
- this.prevBytesVideo = bytesReceived;
74
- const r = report;
75
- const packetsLost = r.packetsLost ?? 0;
76
- const framesDropped = r.framesDropped ?? 0;
77
- const freezeCount = r.freezeCount ?? 0;
78
- const freezeDuration = r.totalFreezesDuration ?? 0;
79
- video = {
80
- framesDecoded: r.framesDecoded ?? 0,
81
- framesDropped,
82
- framesPerSecond: r.framesPerSecond ?? 0,
83
- frameWidth: r.frameWidth ?? 0,
84
- frameHeight: r.frameHeight ?? 0,
85
- bytesReceived,
86
- packetsReceived: r.packetsReceived ?? 0,
87
- packetsLost,
88
- jitter: r.jitter ?? 0,
89
- bitrate: Math.round(bitrate),
90
- freezeCount,
91
- totalFreezesDuration: freezeDuration,
92
- packetsLostDelta: Math.max(0, packetsLost - this.prevPacketsLostVideo),
93
- framesDroppedDelta: Math.max(0, framesDropped - this.prevFramesDropped),
94
- freezeCountDelta: Math.max(0, freezeCount - this.prevFreezeCount),
95
- freezeDurationDelta: Math.max(0, freezeDuration - this.prevFreezeDuration)
96
- };
97
- this.prevPacketsLostVideo = packetsLost;
98
- this.prevFramesDropped = framesDropped;
99
- this.prevFreezeCount = freezeCount;
100
- this.prevFreezeDuration = freezeDuration;
101
- }
102
- if (report.type === "outbound-rtp" && report.kind === "video") {
103
- const r = report;
104
- const bytesSent = r.bytesSent ?? 0;
105
- const outBitrate = elapsed > 0 ? (bytesSent - this.prevBytesSentVideo) * 8 / elapsed : 0;
106
- this.prevBytesSentVideo = bytesSent;
107
- outboundVideo = {
108
- qualityLimitationReason: r.qualityLimitationReason ?? "none",
109
- qualityLimitationDurations: r.qualityLimitationDurations ?? {},
110
- bytesSent,
111
- packetsSent: r.packetsSent ?? 0,
112
- framesPerSecond: r.framesPerSecond ?? 0,
113
- frameWidth: r.frameWidth ?? 0,
114
- frameHeight: r.frameHeight ?? 0,
115
- bitrate: Math.round(outBitrate)
116
- };
117
- }
118
- if (report.type === "inbound-rtp" && report.kind === "audio") {
119
- const bytesReceived = report.bytesReceived ?? 0;
120
- const bitrate = elapsed > 0 ? (bytesReceived - this.prevBytesAudio) * 8 / elapsed : 0;
121
- this.prevBytesAudio = bytesReceived;
122
- const r = report;
123
- const audioPacketsLost = r.packetsLost ?? 0;
124
- audio = {
125
- bytesReceived,
126
- packetsReceived: r.packetsReceived ?? 0,
127
- packetsLost: audioPacketsLost,
128
- jitter: r.jitter ?? 0,
129
- bitrate: Math.round(bitrate),
130
- packetsLostDelta: Math.max(0, audioPacketsLost - this.prevPacketsLostAudio)
131
- };
132
- this.prevPacketsLostAudio = audioPacketsLost;
133
- }
134
- if (report.type === "candidate-pair") {
135
- const r = report;
136
- if (r.state === "succeeded") {
137
- connection.currentRoundTripTime = r.currentRoundTripTime ?? null;
138
- connection.availableOutgoingBitrate = r.availableOutgoingBitrate ?? null;
139
- }
140
- }
141
- });
142
- this.prevTimestamp = now;
143
- return {
144
- timestamp: Date.now(),
145
- video,
146
- audio,
147
- outboundVideo,
148
- connection
149
- };
150
- }
151
- };
152
-
153
- //#endregion
154
- export { WebRTCStatsCollector };