@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 +10 -0
- package/dist/index.d.ts +2 -2
- package/dist/realtime/client.d.ts +6 -2
- package/dist/realtime/client.js +32 -118
- package/dist/realtime/{diagnostics.d.ts → observability/diagnostics.d.ts} +1 -1
- package/dist/realtime/observability/realtime-observability.js +109 -0
- package/dist/realtime/{telemetry-reporter.js → observability/telemetry-reporter.js} +3 -3
- package/dist/realtime/observability/webrtc-stats.d.ts +147 -0
- package/dist/realtime/observability/webrtc-stats.js +278 -0
- package/dist/realtime/subscribe-client.d.ts +3 -1
- package/dist/realtime/webrtc-connection.js +18 -19
- package/dist/realtime/webrtc-manager.js +24 -3
- package/package.json +1 -1
- package/dist/realtime/webrtc-stats.d.ts +0 -59
- package/dist/realtime/webrtc-stats.js +0 -154
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;
|
package/dist/realtime/client.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
2
|
-
import { buildAuthHeaders } from "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,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 };
|