@agatx/serenada-core 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ConsoleLogger.d.ts +6 -0
- package/dist/ConsoleLogger.d.ts.map +1 -0
- package/dist/ConsoleLogger.js +21 -0
- package/dist/ConsoleLogger.js.map +1 -0
- package/dist/RoomWatcher.d.ts +34 -0
- package/dist/RoomWatcher.d.ts.map +1 -0
- package/dist/RoomWatcher.js +103 -0
- package/dist/RoomWatcher.js.map +1 -0
- package/dist/SerenadaCore.d.ts +47 -0
- package/dist/SerenadaCore.d.ts.map +1 -0
- package/dist/SerenadaCore.js +141 -0
- package/dist/SerenadaCore.js.map +1 -0
- package/dist/SerenadaDiagnostics.d.ts +49 -0
- package/dist/SerenadaDiagnostics.d.ts.map +1 -0
- package/dist/SerenadaDiagnostics.js +421 -0
- package/dist/SerenadaDiagnostics.js.map +1 -0
- package/dist/SerenadaServerProvider.d.ts +48 -0
- package/dist/SerenadaServerProvider.d.ts.map +1 -0
- package/dist/SerenadaServerProvider.js +296 -0
- package/dist/SerenadaServerProvider.js.map +1 -0
- package/dist/SerenadaSession.d.ts +180 -0
- package/dist/SerenadaSession.d.ts.map +1 -0
- package/dist/SerenadaSession.js +1082 -0
- package/dist/SerenadaSession.js.map +1 -0
- package/dist/SignalingProvider.d.ts +132 -0
- package/dist/SignalingProvider.d.ts.map +1 -0
- package/dist/SignalingProvider.js +50 -0
- package/dist/SignalingProvider.js.map +1 -0
- package/dist/api/roomApi.d.ts +2 -0
- package/dist/api/roomApi.d.ts.map +1 -0
- package/dist/api/roomApi.js +14 -0
- package/dist/api/roomApi.js.map +1 -0
- package/dist/cameraModes.d.ts +13 -0
- package/dist/cameraModes.d.ts.map +1 -0
- package/dist/cameraModes.js +35 -0
- package/dist/cameraModes.js.map +1 -0
- package/dist/configValidation.d.ts +10 -0
- package/dist/configValidation.d.ts.map +1 -0
- package/dist/configValidation.js +24 -0
- package/dist/configValidation.js.map +1 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +65 -0
- package/dist/constants.js.map +1 -0
- package/dist/formatError.d.ts +3 -0
- package/dist/formatError.d.ts.map +1 -0
- package/dist/formatError.js +7 -0
- package/dist/formatError.js.map +1 -0
- package/dist/iceServers.d.ts +2 -0
- package/dist/iceServers.d.ts.map +1 -0
- package/dist/iceServers.js +21 -0
- package/dist/iceServers.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/computeLayout.d.ts +81 -0
- package/dist/layout/computeLayout.d.ts.map +1 -0
- package/dist/layout/computeLayout.js +380 -0
- package/dist/layout/computeLayout.js.map +1 -0
- package/dist/media/AudioLevelMonitor.d.ts +51 -0
- package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
- package/dist/media/AudioLevelMonitor.js +179 -0
- package/dist/media/AudioLevelMonitor.js.map +1 -0
- package/dist/media/MediaEngine.d.ts +137 -0
- package/dist/media/MediaEngine.d.ts.map +1 -0
- package/dist/media/MediaEngine.js +1224 -0
- package/dist/media/MediaEngine.js.map +1 -0
- package/dist/media/callStats.d.ts +16 -0
- package/dist/media/callStats.d.ts.map +1 -0
- package/dist/media/callStats.js +214 -0
- package/dist/media/callStats.js.map +1 -0
- package/dist/media/localVideoRecovery.d.ts +16 -0
- package/dist/media/localVideoRecovery.d.ts.map +1 -0
- package/dist/media/localVideoRecovery.js +14 -0
- package/dist/media/localVideoRecovery.js.map +1 -0
- package/dist/recoveryStorage.d.ts +33 -0
- package/dist/recoveryStorage.d.ts.map +1 -0
- package/dist/recoveryStorage.js +88 -0
- package/dist/recoveryStorage.js.map +1 -0
- package/dist/serverUrls.d.ts +8 -0
- package/dist/serverUrls.d.ts.map +1 -0
- package/dist/serverUrls.js +65 -0
- package/dist/serverUrls.js.map +1 -0
- package/dist/signaling/SignalingEngine.d.ts +126 -0
- package/dist/signaling/SignalingEngine.d.ts.map +1 -0
- package/dist/signaling/SignalingEngine.js +720 -0
- package/dist/signaling/SignalingEngine.js.map +1 -0
- package/dist/signaling/payloads.d.ts +76 -0
- package/dist/signaling/payloads.d.ts.map +1 -0
- package/dist/signaling/payloads.js +160 -0
- package/dist/signaling/payloads.js.map +1 -0
- package/dist/signaling/roomStatuses.d.ts +9 -0
- package/dist/signaling/roomStatuses.d.ts.map +1 -0
- package/dist/signaling/roomStatuses.js +71 -0
- package/dist/signaling/roomStatuses.js.map +1 -0
- package/dist/signaling/transportConfig.d.ts +3 -0
- package/dist/signaling/transportConfig.d.ts.map +1 -0
- package/dist/signaling/transportConfig.js +27 -0
- package/dist/signaling/transportConfig.js.map +1 -0
- package/dist/signaling/transports/index.d.ts +13 -0
- package/dist/signaling/transports/index.d.ts.map +1 -0
- package/dist/signaling/transports/index.js +11 -0
- package/dist/signaling/transports/index.js.map +1 -0
- package/dist/signaling/transports/sse.d.ts +26 -0
- package/dist/signaling/transports/sse.d.ts.map +1 -0
- package/dist/signaling/transports/sse.js +131 -0
- package/dist/signaling/transports/sse.js.map +1 -0
- package/dist/signaling/transports/types.d.ts +17 -0
- package/dist/signaling/transports/types.d.ts.map +1 -0
- package/dist/signaling/transports/types.js +2 -0
- package/dist/signaling/transports/types.js.map +1 -0
- package/dist/signaling/transports/ws.d.ts +21 -0
- package/dist/signaling/transports/ws.d.ts.map +1 -0
- package/dist/signaling/transports/ws.js +93 -0
- package/dist/signaling/transports/ws.js.map +1 -0
- package/dist/signaling/types.d.ts +53 -0
- package/dist/signaling/types.d.ts.map +1 -0
- package/dist/signaling/types.js +2 -0
- package/dist/signaling/types.js.map +1 -0
- package/dist/types.d.ts +279 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
- package/src/ConsoleLogger.ts +14 -0
- package/src/RoomWatcher.ts +127 -0
- package/src/SerenadaCore.ts +163 -0
- package/src/SerenadaDiagnostics.ts +485 -0
- package/src/SerenadaServerProvider.ts +362 -0
- package/src/SerenadaSession.ts +1258 -0
- package/src/SignalingProvider.ts +207 -0
- package/src/api/roomApi.ts +16 -0
- package/src/cameraModes.ts +34 -0
- package/src/configValidation.ts +35 -0
- package/src/constants.ts +77 -0
- package/src/formatError.ts +5 -0
- package/src/iceServers.ts +20 -0
- package/src/index.ts +155 -0
- package/src/layout/computeLayout.ts +639 -0
- package/src/media/AudioLevelMonitor.ts +190 -0
- package/src/media/MediaEngine.ts +1183 -0
- package/src/media/callStats.ts +260 -0
- package/src/media/localVideoRecovery.ts +39 -0
- package/src/recoveryStorage.ts +101 -0
- package/src/serverUrls.ts +69 -0
- package/src/signaling/SignalingEngine.ts +762 -0
- package/src/signaling/payloads.ts +215 -0
- package/src/signaling/roomStatuses.ts +89 -0
- package/src/signaling/transportConfig.ts +30 -0
- package/src/signaling/transports/index.ts +26 -0
- package/src/signaling/transports/sse.ts +146 -0
- package/src/signaling/transports/types.ts +19 -0
- package/src/signaling/transports/ws.ts +108 -0
- package/src/signaling/types.ts +68 -0
- package/src/types.ts +299 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { CallStats, SerenadaLogger } from '../types.js';
|
|
2
|
+
|
|
3
|
+
interface MediaTotals {
|
|
4
|
+
inboundPacketsReceived: number;
|
|
5
|
+
inboundPacketsLost: number;
|
|
6
|
+
inboundBytes: number;
|
|
7
|
+
inboundJitterSumSeconds: number;
|
|
8
|
+
inboundJitterCount: number;
|
|
9
|
+
inboundJitterBufferDelaySeconds: number;
|
|
10
|
+
inboundJitterBufferEmittedCount: number;
|
|
11
|
+
inboundConcealedSamples: number;
|
|
12
|
+
inboundTotalSamples: number;
|
|
13
|
+
inboundFpsSum: number;
|
|
14
|
+
inboundFpsCount: number;
|
|
15
|
+
inboundFrameWidth: number;
|
|
16
|
+
inboundFrameHeight: number;
|
|
17
|
+
inboundFramesDecoded: number;
|
|
18
|
+
inboundFreezeCount: number;
|
|
19
|
+
inboundFreezeDurationSeconds: number;
|
|
20
|
+
outboundPacketsSent: number;
|
|
21
|
+
outboundBytes: number;
|
|
22
|
+
outboundPacketsRetransmitted: number;
|
|
23
|
+
remoteInboundPacketsLost: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StatsSample {
|
|
27
|
+
timestampMs: number;
|
|
28
|
+
audioRxBytes: number;
|
|
29
|
+
audioTxBytes: number;
|
|
30
|
+
videoRxBytes: number;
|
|
31
|
+
videoTxBytes: number;
|
|
32
|
+
videoFramesDecoded: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FreezeSample {
|
|
36
|
+
timestampMs: number;
|
|
37
|
+
freezeCount: number;
|
|
38
|
+
freezeDurationSeconds: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const createMediaTotals = (): MediaTotals => ({
|
|
42
|
+
inboundPacketsReceived: 0, inboundPacketsLost: 0, inboundBytes: 0,
|
|
43
|
+
inboundJitterSumSeconds: 0, inboundJitterCount: 0,
|
|
44
|
+
inboundJitterBufferDelaySeconds: 0, inboundJitterBufferEmittedCount: 0,
|
|
45
|
+
inboundConcealedSamples: 0, inboundTotalSamples: 0,
|
|
46
|
+
inboundFpsSum: 0, inboundFpsCount: 0,
|
|
47
|
+
inboundFrameWidth: 0, inboundFrameHeight: 0, inboundFramesDecoded: 0,
|
|
48
|
+
inboundFreezeCount: 0, inboundFreezeDurationSeconds: 0,
|
|
49
|
+
outboundPacketsSent: 0, outboundBytes: 0, outboundPacketsRetransmitted: 0,
|
|
50
|
+
remoteInboundPacketsLost: 0,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const asStatMap = (stat: RTCStats): Record<string, unknown> => stat as unknown as Record<string, unknown>;
|
|
54
|
+
const getStatNumber = (stat: RTCStats, key: string): number | null => {
|
|
55
|
+
const value = asStatMap(stat)[key];
|
|
56
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
57
|
+
};
|
|
58
|
+
const getStatString = (stat: RTCStats, key: string): string | null => {
|
|
59
|
+
const value = asStatMap(stat)[key];
|
|
60
|
+
return typeof value === 'string' ? value : null;
|
|
61
|
+
};
|
|
62
|
+
const getStatBoolean = (stat: RTCStats, key: string): boolean | null => {
|
|
63
|
+
const value = asStatMap(stat)[key];
|
|
64
|
+
return typeof value === 'boolean' ? value : null;
|
|
65
|
+
};
|
|
66
|
+
const getMediaKind = (stat: RTCStats): 'audio' | 'video' | null => {
|
|
67
|
+
const kind = getStatString(stat, 'kind') ?? getStatString(stat, 'mediaType');
|
|
68
|
+
return kind === 'audio' || kind === 'video' ? kind : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const calculateBitrateKbps = (previousBytes: number, currentBytes: number, elapsedSeconds: number): number | null => {
|
|
72
|
+
if (elapsedSeconds <= 0 || currentBytes < previousBytes) return null;
|
|
73
|
+
return (currentBytes - previousBytes) * 8 / elapsedSeconds / 1000;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const ratioPercent = (numerator: number, denominator: number): number | null => {
|
|
77
|
+
if (denominator <= 0) return null;
|
|
78
|
+
return (numerator / denominator) * 100;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export class CallStatsCollector {
|
|
82
|
+
private statsSample: StatsSample | null = null;
|
|
83
|
+
private freezeSamples: FreezeSample[] = [];
|
|
84
|
+
private timer: number | null = null;
|
|
85
|
+
private _stats: CallStats | null = null;
|
|
86
|
+
private onChange: (() => void) | null = null;
|
|
87
|
+
private logger?: SerenadaLogger;
|
|
88
|
+
|
|
89
|
+
constructor(logger?: SerenadaLogger) {
|
|
90
|
+
this.logger = logger;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get stats(): CallStats | null { return this._stats; }
|
|
94
|
+
|
|
95
|
+
start(getPeerConnections: () => RTCPeerConnection[], onChange: () => void): void {
|
|
96
|
+
this.stop();
|
|
97
|
+
this.onChange = onChange;
|
|
98
|
+
this.timer = window.setInterval(() => {
|
|
99
|
+
void this.poll(getPeerConnections());
|
|
100
|
+
}, 1000);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
stop(): void {
|
|
104
|
+
if (this.timer !== null) { window.clearInterval(this.timer); this.timer = null; }
|
|
105
|
+
this._stats = null;
|
|
106
|
+
this.statsSample = null;
|
|
107
|
+
this.freezeSamples = [];
|
|
108
|
+
this.onChange = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
reset(): void {
|
|
112
|
+
this.statsSample = null;
|
|
113
|
+
this.freezeSamples = [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async poll(pcs: RTCPeerConnection[]): Promise<void> {
|
|
117
|
+
if (pcs.length === 0) { this._stats = null; return; }
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const reports = await Promise.all(pcs.map(pc => pc.getStats()));
|
|
121
|
+
const statsById = new Map<string, RTCStats>();
|
|
122
|
+
reports.forEach((r, i) => {
|
|
123
|
+
const prefix = `p${i}:`;
|
|
124
|
+
r.forEach(stat => {
|
|
125
|
+
const namespacedId = prefix + stat.id;
|
|
126
|
+
statsById.set(namespacedId, { ...stat, id: namespacedId } as RTCStats);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const media = { audio: createMediaTotals(), video: createMediaTotals() };
|
|
131
|
+
let selectedCandidatePair: RTCStats | null = null;
|
|
132
|
+
let fallbackCandidatePair: RTCStats | null = null;
|
|
133
|
+
let remoteInboundRttSumSeconds = 0;
|
|
134
|
+
let remoteInboundRttCount = 0;
|
|
135
|
+
|
|
136
|
+
statsById.forEach(stat => {
|
|
137
|
+
if (stat.type === 'candidate-pair') {
|
|
138
|
+
const isSelected = getStatBoolean(stat, 'selected') === true;
|
|
139
|
+
const isNominated = getStatBoolean(stat, 'nominated') === true;
|
|
140
|
+
const pairState = getStatString(stat, 'state');
|
|
141
|
+
if (isSelected) selectedCandidatePair = stat;
|
|
142
|
+
else if (!fallbackCandidatePair && isNominated && pairState === 'succeeded') fallbackCandidatePair = stat;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const kind = getMediaKind(stat);
|
|
147
|
+
if (!kind) return;
|
|
148
|
+
const bucket = media[kind];
|
|
149
|
+
|
|
150
|
+
if (stat.type === 'inbound-rtp') {
|
|
151
|
+
bucket.inboundPacketsReceived += getStatNumber(stat, 'packetsReceived') ?? 0;
|
|
152
|
+
bucket.inboundPacketsLost += Math.max(0, getStatNumber(stat, 'packetsLost') ?? 0);
|
|
153
|
+
bucket.inboundBytes += getStatNumber(stat, 'bytesReceived') ?? 0;
|
|
154
|
+
const jitter = getStatNumber(stat, 'jitter');
|
|
155
|
+
if (jitter !== null) { bucket.inboundJitterSumSeconds += jitter; bucket.inboundJitterCount += 1; }
|
|
156
|
+
bucket.inboundJitterBufferDelaySeconds += getStatNumber(stat, 'jitterBufferDelay') ?? 0;
|
|
157
|
+
bucket.inboundJitterBufferEmittedCount += getStatNumber(stat, 'jitterBufferEmittedCount') ?? 0;
|
|
158
|
+
bucket.inboundConcealedSamples += getStatNumber(stat, 'concealedSamples') ?? 0;
|
|
159
|
+
bucket.inboundTotalSamples += getStatNumber(stat, 'totalSamplesReceived') ?? 0;
|
|
160
|
+
const fps = getStatNumber(stat, 'framesPerSecond');
|
|
161
|
+
bucket.inboundFpsSum += fps ?? 0;
|
|
162
|
+
bucket.inboundFpsCount += fps !== null ? 1 : 0;
|
|
163
|
+
bucket.inboundFrameWidth = Math.max(bucket.inboundFrameWidth, Math.round(getStatNumber(stat, 'frameWidth') ?? 0));
|
|
164
|
+
bucket.inboundFrameHeight = Math.max(bucket.inboundFrameHeight, Math.round(getStatNumber(stat, 'frameHeight') ?? 0));
|
|
165
|
+
bucket.inboundFramesDecoded += getStatNumber(stat, 'framesDecoded') ?? 0;
|
|
166
|
+
bucket.inboundFreezeCount += getStatNumber(stat, 'freezeCount') ?? 0;
|
|
167
|
+
bucket.inboundFreezeDurationSeconds += getStatNumber(stat, 'totalFreezesDuration') ?? 0;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (stat.type === 'outbound-rtp') {
|
|
171
|
+
bucket.outboundPacketsSent += getStatNumber(stat, 'packetsSent') ?? 0;
|
|
172
|
+
bucket.outboundBytes += getStatNumber(stat, 'bytesSent') ?? 0;
|
|
173
|
+
bucket.outboundPacketsRetransmitted += getStatNumber(stat, 'retransmittedPacketsSent') ?? 0;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (stat.type === 'remote-inbound-rtp') {
|
|
177
|
+
bucket.remoteInboundPacketsLost += Math.max(0, getStatNumber(stat, 'packetsLost') ?? 0);
|
|
178
|
+
const remoteRtt = getStatNumber(stat, 'roundTripTime');
|
|
179
|
+
if (remoteRtt !== null) { remoteInboundRttSumSeconds += remoteRtt; remoteInboundRttCount += 1; }
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!selectedCandidatePair) selectedCandidatePair = fallbackCandidatePair;
|
|
184
|
+
const selectedPair = selectedCandidatePair as RTCStats | null;
|
|
185
|
+
const pairPrefix = selectedPair ? selectedPair.id.substring(0, selectedPair.id.indexOf(':') + 1) : '';
|
|
186
|
+
const localCandidate = selectedPair ? statsById.get(pairPrefix + (getStatString(selectedPair, 'localCandidateId') ?? '')) : null;
|
|
187
|
+
const remoteCandidate = selectedPair ? statsById.get(pairPrefix + (getStatString(selectedPair, 'remoteCandidateId') ?? '')) : null;
|
|
188
|
+
|
|
189
|
+
const localCandidateType = localCandidate ? getStatString(localCandidate, 'candidateType') : null;
|
|
190
|
+
const remoteCandidateType = remoteCandidate ? getStatString(remoteCandidate, 'candidateType') : null;
|
|
191
|
+
const localProtocol = localCandidate ? getStatString(localCandidate, 'protocol') : null;
|
|
192
|
+
const remoteProtocol = remoteCandidate ? getStatString(remoteCandidate, 'protocol') : null;
|
|
193
|
+
const isRelay = localCandidateType === 'relay' || remoteCandidateType === 'relay';
|
|
194
|
+
const transportPath = localCandidateType || remoteCandidateType
|
|
195
|
+
? `${isRelay ? 'TURN relay' : 'Direct'} (${localCandidateType ?? 'n/a'} -> ${remoteCandidateType ?? 'n/a'}, ${localProtocol ?? remoteProtocol ?? 'n/a'})`
|
|
196
|
+
: null;
|
|
197
|
+
|
|
198
|
+
const candidateRttSeconds = selectedPair ? getStatNumber(selectedPair, 'currentRoundTripTime') : null;
|
|
199
|
+
const remoteInboundRttSeconds = remoteInboundRttCount > 0 ? (remoteInboundRttSumSeconds / remoteInboundRttCount) : null;
|
|
200
|
+
const chosenRttSeconds = candidateRttSeconds ?? remoteInboundRttSeconds;
|
|
201
|
+
const rttMs = chosenRttSeconds !== null ? chosenRttSeconds * 1000 : null;
|
|
202
|
+
const availableOutgoingBitrate = selectedPair ? getStatNumber(selectedPair, 'availableOutgoingBitrate') : null;
|
|
203
|
+
const availableOutgoingKbps = availableOutgoingBitrate !== null ? availableOutgoingBitrate / 1000 : null;
|
|
204
|
+
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
const previousSample = this.statsSample;
|
|
207
|
+
const elapsedSeconds = previousSample ? (now - previousSample.timestampMs) / 1000 : 0;
|
|
208
|
+
|
|
209
|
+
const audioRxKbps = previousSample ? calculateBitrateKbps(previousSample.audioRxBytes, media.audio.inboundBytes, elapsedSeconds) : null;
|
|
210
|
+
const audioTxKbps = previousSample ? calculateBitrateKbps(previousSample.audioTxBytes, media.audio.outboundBytes, elapsedSeconds) : null;
|
|
211
|
+
const videoRxKbps = previousSample ? calculateBitrateKbps(previousSample.videoRxBytes, media.video.inboundBytes, elapsedSeconds) : null;
|
|
212
|
+
const videoTxKbps = previousSample ? calculateBitrateKbps(previousSample.videoTxBytes, media.video.outboundBytes, elapsedSeconds) : null;
|
|
213
|
+
|
|
214
|
+
let videoFps: number | null = null;
|
|
215
|
+
if (media.video.inboundFpsCount > 0) {
|
|
216
|
+
videoFps = media.video.inboundFpsSum / media.video.inboundFpsCount;
|
|
217
|
+
} else if (previousSample && elapsedSeconds > 0 && media.video.inboundFramesDecoded >= previousSample.videoFramesDecoded) {
|
|
218
|
+
videoFps = (media.video.inboundFramesDecoded - previousSample.videoFramesDecoded) / elapsedSeconds;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.freezeSamples.push({ timestampMs: now, freezeCount: media.video.inboundFreezeCount, freezeDurationSeconds: media.video.inboundFreezeDurationSeconds });
|
|
222
|
+
this.freezeSamples = this.freezeSamples.filter(sample => now - sample.timestampMs <= 60_000);
|
|
223
|
+
const freezeWindowBase = this.freezeSamples[0];
|
|
224
|
+
const videoFreezeCount60s = freezeWindowBase ? Math.max(0, media.video.inboundFreezeCount - freezeWindowBase.freezeCount) : null;
|
|
225
|
+
const videoFreezeDuration60s = freezeWindowBase ? Math.max(0, media.video.inboundFreezeDurationSeconds - freezeWindowBase.freezeDurationSeconds) : null;
|
|
226
|
+
|
|
227
|
+
const audioRxPacketLossPct = ratioPercent(media.audio.inboundPacketsLost, media.audio.inboundPacketsReceived + media.audio.inboundPacketsLost);
|
|
228
|
+
const audioTxPacketLossPct = ratioPercent(media.audio.remoteInboundPacketsLost, media.audio.outboundPacketsSent + media.audio.remoteInboundPacketsLost);
|
|
229
|
+
const videoRxPacketLossPct = ratioPercent(media.video.inboundPacketsLost, media.video.inboundPacketsReceived + media.video.inboundPacketsLost);
|
|
230
|
+
const videoTxPacketLossPct = ratioPercent(media.video.remoteInboundPacketsLost, media.video.outboundPacketsSent + media.video.remoteInboundPacketsLost);
|
|
231
|
+
|
|
232
|
+
const audioJitterMs = media.audio.inboundJitterCount > 0 ? (media.audio.inboundJitterSumSeconds / media.audio.inboundJitterCount) * 1000 : null;
|
|
233
|
+
const audioPlayoutDelayMs = media.audio.inboundJitterBufferEmittedCount > 0 ? (media.audio.inboundJitterBufferDelaySeconds / media.audio.inboundJitterBufferEmittedCount) * 1000 : null;
|
|
234
|
+
const audioConcealedPct = ratioPercent(media.audio.inboundConcealedSamples, media.audio.inboundTotalSamples);
|
|
235
|
+
const videoRetransmitPct = ratioPercent(media.video.outboundPacketsRetransmitted, media.video.outboundPacketsSent);
|
|
236
|
+
const videoResolution = media.video.inboundFrameWidth > 0 && media.video.inboundFrameHeight > 0
|
|
237
|
+
? `${media.video.inboundFrameWidth}x${media.video.inboundFrameHeight}` : null;
|
|
238
|
+
|
|
239
|
+
this._stats = {
|
|
240
|
+
transportPath, rttMs, availableOutgoingKbps,
|
|
241
|
+
audioRxPacketLossPct, audioTxPacketLossPct, audioJitterMs, audioPlayoutDelayMs, audioConcealedPct,
|
|
242
|
+
audioRxKbps, audioTxKbps,
|
|
243
|
+
videoRxPacketLossPct, videoTxPacketLossPct, videoRxKbps, videoTxKbps,
|
|
244
|
+
videoFps, videoResolution, videoFreezeCount60s, videoFreezeDuration60s, videoRetransmitPct,
|
|
245
|
+
updatedAtMs: now,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
this.statsSample = {
|
|
249
|
+
timestampMs: now,
|
|
250
|
+
audioRxBytes: media.audio.inboundBytes, audioTxBytes: media.audio.outboundBytes,
|
|
251
|
+
videoRxBytes: media.video.inboundBytes, videoTxBytes: media.video.outboundBytes,
|
|
252
|
+
videoFramesDecoded: media.video.inboundFramesDecoded,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
this.onChange?.();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
this.logger?.log('warning', 'Stats', `Failed to collect realtime stats: ${err}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { LOCAL_VIDEO_RESUME_GAP_MS } from '../constants.js';
|
|
2
|
+
|
|
3
|
+
interface ShouldForceLocalVideoRefreshArgs {
|
|
4
|
+
hiddenDurationMs?: number | null;
|
|
5
|
+
sleepGapMs?: number | null;
|
|
6
|
+
thresholdMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function shouldForceLocalVideoRefresh({
|
|
10
|
+
hiddenDurationMs,
|
|
11
|
+
sleepGapMs,
|
|
12
|
+
thresholdMs = LOCAL_VIDEO_RESUME_GAP_MS
|
|
13
|
+
}: ShouldForceLocalVideoRefreshArgs): boolean {
|
|
14
|
+
return (hiddenDurationMs ?? 0) >= thresholdMs || (sleepGapMs ?? 0) >= thresholdMs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ShouldRecoverLocalVideoArgs {
|
|
18
|
+
hasVideoTrack: boolean;
|
|
19
|
+
isScreenSharing: boolean;
|
|
20
|
+
videoTrackReadyState: MediaStreamTrackState | null;
|
|
21
|
+
videoTrackMuted: boolean;
|
|
22
|
+
forceRefresh: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function shouldRecoverLocalVideo({
|
|
26
|
+
hasVideoTrack,
|
|
27
|
+
isScreenSharing,
|
|
28
|
+
videoTrackReadyState,
|
|
29
|
+
videoTrackMuted,
|
|
30
|
+
forceRefresh
|
|
31
|
+
}: ShouldRecoverLocalVideoArgs): boolean {
|
|
32
|
+
if (!hasVideoTrack || isScreenSharing) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (forceRefresh) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return videoTrackReadyState !== 'live' || videoTrackMuted;
|
|
39
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent recovery state — surfaced to host apps so a relaunched tab can
|
|
3
|
+
* prompt the user to rejoin an in-flight call instead of silently dropping
|
|
4
|
+
* them on the home screen.
|
|
5
|
+
*
|
|
6
|
+
* Per the Phase 2 spec (`docs/resilience-failure-modes.md`, #5), the web
|
|
7
|
+
* scope is `sessionStorage`: per-tab, survives reload, lost on tab close —
|
|
8
|
+
* the right scope for "you reloaded the page mid-call".
|
|
9
|
+
*
|
|
10
|
+
* The record carries the same `reconnectToken` that `SignalingEngine`
|
|
11
|
+
* persists for in-tab reconnects. The two stores are kept in lockstep:
|
|
12
|
+
* a fresh `joined` writes both, a clean leave / `room_ended` /
|
|
13
|
+
* `INVALID_RECONNECT_TOKEN` clears both.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface RecoveryRecord {
|
|
17
|
+
roomId: string;
|
|
18
|
+
cid: string;
|
|
19
|
+
reconnectToken: string;
|
|
20
|
+
/** Server room state epoch at the moment the record was last refreshed. */
|
|
21
|
+
lastEpoch: number | null;
|
|
22
|
+
/** Unix-ms timestamp of the original join (NOT the latest reconnect). */
|
|
23
|
+
sessionStartTs: number;
|
|
24
|
+
/**
|
|
25
|
+
* Unix-ms after which the host app should NOT offer the rejoin prompt.
|
|
26
|
+
* Computed as `now + reconnectTokenTTLMs` at write time so the SDK does
|
|
27
|
+
* not need to know server clocks.
|
|
28
|
+
*/
|
|
29
|
+
expiresAtMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STORAGE_KEY = 'serenada.recovery';
|
|
33
|
+
|
|
34
|
+
function getStorage(): Storage | null {
|
|
35
|
+
try {
|
|
36
|
+
return typeof window !== 'undefined' ? window.sessionStorage : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadRecoveryRecord(): RecoveryRecord | null {
|
|
43
|
+
const store = getStorage();
|
|
44
|
+
if (!store) return null;
|
|
45
|
+
try {
|
|
46
|
+
const raw = store.getItem(STORAGE_KEY);
|
|
47
|
+
if (!raw) return null;
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
if (
|
|
50
|
+
typeof parsed !== 'object' ||
|
|
51
|
+
parsed === null ||
|
|
52
|
+
typeof parsed.roomId !== 'string' ||
|
|
53
|
+
typeof parsed.cid !== 'string' ||
|
|
54
|
+
typeof parsed.reconnectToken !== 'string' ||
|
|
55
|
+
typeof parsed.sessionStartTs !== 'number' ||
|
|
56
|
+
typeof parsed.expiresAtMs !== 'number'
|
|
57
|
+
) {
|
|
58
|
+
store.removeItem(STORAGE_KEY);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (Date.now() > parsed.expiresAtMs) {
|
|
62
|
+
store.removeItem(STORAGE_KEY);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
roomId: parsed.roomId,
|
|
67
|
+
cid: parsed.cid,
|
|
68
|
+
reconnectToken: parsed.reconnectToken,
|
|
69
|
+
lastEpoch: typeof parsed.lastEpoch === 'number' ? parsed.lastEpoch : null,
|
|
70
|
+
sessionStartTs: parsed.sessionStartTs,
|
|
71
|
+
expiresAtMs: parsed.expiresAtMs,
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
try {
|
|
75
|
+
store.removeItem(STORAGE_KEY);
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore cleanup failures and preserve best-effort behavior.
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function saveRecoveryRecord(record: RecoveryRecord): void {
|
|
84
|
+
const store = getStorage();
|
|
85
|
+
if (!store) return;
|
|
86
|
+
try {
|
|
87
|
+
store.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
88
|
+
} catch {
|
|
89
|
+
// Quota / private mode — best-effort persistence.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clearRecoveryRecord(): void {
|
|
94
|
+
const store = getStorage();
|
|
95
|
+
if (!store) return;
|
|
96
|
+
try {
|
|
97
|
+
store.removeItem(STORAGE_KEY);
|
|
98
|
+
} catch {
|
|
99
|
+
// Ignore.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const getHostname = (serverHost: string): string | null => {
|
|
2
|
+
try {
|
|
3
|
+
const normalized = serverHost.includes('://') ? serverHost : `http://${serverHost}`;
|
|
4
|
+
return new URL(normalized).hostname;
|
|
5
|
+
} catch {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const isLoopbackHost = (serverHost: string): boolean => {
|
|
11
|
+
const hostname = getHostname(serverHost)?.replace(/^\[|\]$/g, '');
|
|
12
|
+
if (!hostname) return false;
|
|
13
|
+
return hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.');
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getCurrentPageProtocol = (serverHost: string): 'http:' | 'https:' | null => {
|
|
17
|
+
if (typeof window === 'undefined') return null;
|
|
18
|
+
if (window.location.host !== serverHost) return null;
|
|
19
|
+
if (window.location.protocol === 'http:' || window.location.protocol === 'https:') {
|
|
20
|
+
return window.location.protocol;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const resolveServerBaseUrl = (serverHost: string): string => {
|
|
26
|
+
const trimmed = serverHost.trim();
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
throw new Error('serverHost is required');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(trimmed);
|
|
33
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
34
|
+
return parsed.origin;
|
|
35
|
+
}
|
|
36
|
+
if (parsed.protocol === 'ws:' || parsed.protocol === 'wss:') {
|
|
37
|
+
parsed.protocol = parsed.protocol === 'ws:' ? 'http:' : 'https:';
|
|
38
|
+
parsed.pathname = '';
|
|
39
|
+
parsed.search = '';
|
|
40
|
+
parsed.hash = '';
|
|
41
|
+
return parsed.origin;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Fall back to interpreting serverHost as a bare host[:port].
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const normalizedHost = trimmed.replace(/\/+$/, '');
|
|
48
|
+
const protocol = getCurrentPageProtocol(normalizedHost) ?? (isLoopbackHost(normalizedHost) ? 'http:' : 'https:');
|
|
49
|
+
return `${protocol}//${normalizedHost}`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const resolveServerUrls = (serverHost: string): { wsUrl: string; httpBaseUrl: string } => {
|
|
53
|
+
const httpBaseUrl = resolveServerBaseUrl(serverHost);
|
|
54
|
+
const baseUrl = new URL(httpBaseUrl);
|
|
55
|
+
const wsProtocol = baseUrl.protocol === 'http:' ? 'ws:' : 'wss:';
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
httpBaseUrl,
|
|
59
|
+
wsUrl: `${wsProtocol}//${baseUrl.host}/ws`,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const buildApiUrl = (serverHost: string, path: string): string => {
|
|
64
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
65
|
+
return `${resolveServerBaseUrl(serverHost)}${normalizedPath}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const buildRoomUrl = (serverHost: string, roomId: string): string =>
|
|
69
|
+
`${resolveServerBaseUrl(serverHost)}/call/${roomId}`;
|