@stream-io/video-client 1.51.0 → 1.52.1-beta.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.browser.es.js +382 -18
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +382 -17
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +382 -18
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +13 -1
  9. package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
  10. package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
  11. package/dist/src/gen/video/sfu/event/events.d.ts +26 -1
  12. package/dist/src/gen/video/sfu/models/models.d.ts +208 -2
  13. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +31 -2
  14. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +67 -1
  15. package/dist/src/helpers/participantUtils.d.ts +10 -0
  16. package/dist/src/rtc/BasePeerConnection.d.ts +1 -1
  17. package/dist/src/rtc/Publisher.d.ts +4 -1
  18. package/dist/src/rtc/Subscriber.d.ts +7 -0
  19. package/dist/src/rtc/types.d.ts +1 -0
  20. package/dist/src/stats/rtc/StatsTracer.d.ts +2 -1
  21. package/dist/src/stats/utils.d.ts +1 -0
  22. package/package.json +14 -14
  23. package/src/Call.ts +51 -2
  24. package/src/devices/__tests__/CameraManager.test.ts +3 -1
  25. package/src/devices/__tests__/DeviceManager.test.ts +3 -1
  26. package/src/devices/__tests__/MicrophoneManager.test.ts +3 -1
  27. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +3 -1
  28. package/src/devices/__tests__/ScreenShareManager.test.ts +3 -1
  29. package/src/devices/__tests__/web-audio.mocks.ts +3 -1
  30. package/src/gen/google/protobuf/struct.ts +7 -12
  31. package/src/gen/google/protobuf/timestamp.ts +6 -7
  32. package/src/gen/video/sfu/event/events.ts +33 -25
  33. package/src/gen/video/sfu/models/models.ts +349 -1
  34. package/src/gen/video/sfu/signal_rpc/signal.client.ts +51 -29
  35. package/src/gen/video/sfu/signal_rpc/signal.ts +122 -15
  36. package/src/helpers/__tests__/DynascaleManager.test.ts +8 -7
  37. package/src/helpers/__tests__/browsers.test.ts +4 -4
  38. package/src/helpers/__tests__/participantUtils.test.ts +47 -0
  39. package/src/helpers/client-details.ts +4 -1
  40. package/src/helpers/participantUtils.ts +15 -0
  41. package/src/rtc/BasePeerConnection.ts +7 -1
  42. package/src/rtc/Publisher.ts +4 -0
  43. package/src/rtc/Subscriber.ts +29 -1
  44. package/src/rtc/__tests__/Subscriber.test.ts +5 -1
  45. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +16 -15
  46. package/src/rtc/types.ts +1 -0
  47. package/src/stats/rtc/StatsTracer.ts +25 -4
  48. package/src/stats/rtc/__tests__/StatsTracer.test.ts +155 -0
@@ -23,6 +23,14 @@ import { VisibilityState } from '../../types';
23
23
  import { noopComparator } from '../../sorting';
24
24
  import { TrackType } from '../../gen/video/sfu/models/models';
25
25
 
26
+ vi.mock(import('../browsers'), async (importOriginal) => {
27
+ const module = await importOriginal();
28
+ return {
29
+ ...module,
30
+ isSafari: () => globalThis._isSafari ?? false,
31
+ };
32
+ });
33
+
26
34
  describe('DynascaleManager', () => {
27
35
  let dynascaleManager: DynascaleManager;
28
36
  let call: Call;
@@ -50,13 +58,6 @@ describe('DynascaleManager', () => {
50
58
  beforeEach(() => {
51
59
  // Mock global isSafari to false for testing
52
60
  globalThis._isSafari = false;
53
- vi.mock(import('../browsers'), async (importOriginal) => {
54
- const module = await importOriginal();
55
- return {
56
- ...module,
57
- isSafari: () => globalThis._isSafari ?? false,
58
- };
59
- });
60
61
 
61
62
  dynascaleManager.setUseWebAudio(false);
62
63
 
@@ -9,6 +9,10 @@ import {
9
9
  import { getClientDetails } from '../client-details';
10
10
  import { ClientDetails } from '../../gen/video/sfu/models/models';
11
11
 
12
+ vi.mock('../client-details', () => ({
13
+ getClientDetails: vi.fn(),
14
+ }));
15
+
12
16
  describe('browsers', () => {
13
17
  beforeEach(() => {
14
18
  Object.defineProperty(globalThis, 'navigator', {
@@ -156,10 +160,6 @@ describe('browsers', () => {
156
160
  });
157
161
 
158
162
  describe('isSupportedBrowser', () => {
159
- vi.mock('../client-details', () => ({
160
- getClientDetails: vi.fn(),
161
- }));
162
-
163
163
  it('should return false if browser is undefined', async () => {
164
164
  vi.mocked(getClientDetails).mockResolvedValue({
165
165
  browser: undefined,
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  hasAudio,
4
+ hasInterruptedTrack,
4
5
  hasPausedTrack,
5
6
  hasScreenShare,
6
7
  hasScreenShareAudio,
@@ -120,6 +121,52 @@ describe('participantUtils', () => {
120
121
  });
121
122
  });
122
123
 
124
+ describe('hasInterruptedTrack', () => {
125
+ it('returns true when the track is both interrupted and published', () => {
126
+ const participant = createMockParticipant({
127
+ publishedTracks: [TrackType.AUDIO],
128
+ interruptedTracks: [TrackType.AUDIO],
129
+ });
130
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(true);
131
+ });
132
+
133
+ it('returns false when the track is interrupted but no longer published', () => {
134
+ const participant = createMockParticipant({
135
+ publishedTracks: [],
136
+ interruptedTracks: [TrackType.AUDIO],
137
+ });
138
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
139
+ });
140
+
141
+ it('returns false when the track is published but not interrupted', () => {
142
+ const participant = createMockParticipant({
143
+ publishedTracks: [TrackType.AUDIO],
144
+ interruptedTracks: [],
145
+ });
146
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
147
+ });
148
+
149
+ it('returns false when interruptedTracks is undefined', () => {
150
+ const participant = createMockParticipant({
151
+ publishedTracks: [TrackType.AUDIO],
152
+ interruptedTracks: undefined,
153
+ });
154
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
155
+ });
156
+
157
+ it('checks each track type independently', () => {
158
+ const participant = createMockParticipant({
159
+ publishedTracks: [TrackType.AUDIO, TrackType.VIDEO],
160
+ interruptedTracks: [TrackType.VIDEO],
161
+ });
162
+ expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
163
+ expect(hasInterruptedTrack(participant, TrackType.VIDEO)).toBe(true);
164
+ expect(hasInterruptedTrack(participant, TrackType.SCREEN_SHARE)).toBe(
165
+ false,
166
+ );
167
+ });
168
+ });
169
+
123
170
  describe('hasPausedTrack', () => {
124
171
  it('should return true when participant has paused VIDEO track', () => {
125
172
  const participant = createMockParticipant({
@@ -137,6 +137,7 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
137
137
  sdk: sdkInfo,
138
138
  os: osInfo,
139
139
  device: deviceInfo,
140
+ webrtcVersion: webRtcInfo?.version || '',
140
141
  };
141
142
  }
142
143
 
@@ -173,11 +174,12 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
173
174
  const uaBrowser = userAgentData?.fullVersionList?.find(
174
175
  (v) => !v.brand.includes('Chromium') && !v.brand.match(/[()\-./:;=?_]/g),
175
176
  );
177
+ const browserVersion = uaBrowser?.version || browser.version || '';
176
178
  return {
177
179
  sdk: sdkInfo,
178
180
  browser: {
179
181
  name: uaBrowser?.brand || browser.name || navigator.userAgent,
180
- version: uaBrowser?.version || browser.version || '',
182
+ version: browserVersion,
181
183
  },
182
184
  os: {
183
185
  name: userAgentData?.platform || os.name || '',
@@ -190,5 +192,6 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
190
192
  .join(' '),
191
193
  version: '',
192
194
  },
195
+ webrtcVersion: webRtcInfo?.version || '',
193
196
  };
194
197
  };
@@ -41,6 +41,21 @@ export const hasScreenShareAudio = (p: StreamVideoParticipant): boolean =>
41
41
  export const isPinned = (p: StreamVideoParticipant): boolean =>
42
42
  !!p.pin && (p.pin.isLocalPin || p.pin.pinnedAt > 0);
43
43
 
44
+ /**
45
+ * Check if a participant has a track that is currently interrupted: the
46
+ * participant intends to publish it (it is in `publishedTracks`) but no
47
+ * media is flowing right now (it is in `interruptedTracks`).
48
+ *
49
+ * @param p the participant to check.
50
+ * @param trackType the track type to check.
51
+ */
52
+ export const hasInterruptedTrack = (
53
+ p: StreamVideoParticipant,
54
+ trackType: TrackType,
55
+ ): boolean =>
56
+ !!p.interruptedTracks?.includes(trackType) &&
57
+ p.publishedTracks.includes(trackType);
58
+
44
59
  /**
45
60
  * Check if a participant has a paused track of the specified type.
46
61
  *
@@ -69,6 +69,7 @@ export abstract class BasePeerConnection {
69
69
  enableTracing,
70
70
  clientPublishOptions,
71
71
  iceRestartDelay = 2500,
72
+ statsTimestampDriftThresholdMs = 0,
72
73
  }: BasePeerConnectionOpts,
73
74
  ) {
74
75
  this.peerType = peerType;
@@ -85,7 +86,12 @@ export abstract class BasePeerConnection {
85
86
  { tags: [tag] },
86
87
  );
87
88
  this.pc = this.createPeerConnection(connectionConfig);
88
- this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
89
+ this.stats = new StatsTracer(
90
+ this.pc,
91
+ peerType,
92
+ this.trackIdToTrackType,
93
+ statsTimestampDriftThresholdMs,
94
+ );
89
95
  if (enableTracing) {
90
96
  this.tracer = new Tracer(
91
97
  `${tag}-${peerType === PeerType.SUBSCRIBER ? 'sub' : 'pub'}`,
@@ -39,6 +39,7 @@ export class Publisher extends BasePeerConnection {
39
39
  private readonly transceiverCache = new TransceiverCache();
40
40
  private readonly clonedTracks = new Set<MediaStreamTrack>();
41
41
  private publishOptions: PublishOption[];
42
+ private readonly selfSubEnabled: boolean;
42
43
 
43
44
  /**
44
45
  * Constructs a new `Publisher` instance.
@@ -46,9 +47,11 @@ export class Publisher extends BasePeerConnection {
46
47
  constructor(
47
48
  baseOptions: BasePeerConnectionOpts,
48
49
  publishOptions: PublishOption[],
50
+ opts: { selfSubEnabled?: boolean } = {},
49
51
  ) {
50
52
  super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
51
53
  this.publishOptions = publishOptions;
54
+ this.selfSubEnabled = opts.selfSubEnabled ?? false;
52
55
 
53
56
  this.on('iceRestart', (iceRestart) => {
54
57
  if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED) return;
@@ -576,6 +579,7 @@ export class Publisher extends BasePeerConnection {
576
579
  muted: !isTrackLive,
577
580
  codec: publishOption.codec,
578
581
  publishOptionId: publishOption.id,
582
+ selfSubAudioVideo: this.selfSubEnabled,
579
583
  };
580
584
  };
581
585
 
@@ -14,6 +14,14 @@ import { enableStereo, removeCodecsExcept } from './helpers/sdp';
14
14
  * @internal
15
15
  */
16
16
  export class Subscriber extends BasePeerConnection {
17
+ /**
18
+ * Remote streams received from the SFU. For a self-sub case
19
+ * we need to be able to distinguish between the local capture stream.
20
+ * The map will never contain local streams so we can safely use it to
21
+ * check if the stream is remote and dispose it when needed.
22
+ */
23
+ private trackedStreams: WeakSet<MediaStream> = new WeakSet();
24
+
17
25
  /**
18
26
  * Constructs a new `Subscriber` instance.
19
27
  */
@@ -75,6 +83,7 @@ export class Subscriber extends BasePeerConnection {
75
83
  const participantToUpdate = this.state.participants.find(
76
84
  (p) => p.trackLookupPrefix === trackId,
77
85
  );
86
+ const isSelfSub = !!participantToUpdate?.isLocalParticipant;
78
87
  this.logger.debug(
79
88
  `[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
80
89
  track.id,
@@ -107,6 +116,10 @@ export class Subscriber extends BasePeerConnection {
107
116
 
108
117
  this.trackIdToTrackType.set(track.id, trackType);
109
118
 
119
+ if (isSelfSub) {
120
+ this.trackedStreams.add(primaryStream);
121
+ }
122
+
110
123
  if (!participantToUpdate) {
111
124
  this.logger.warn(
112
125
  `[onTrack]: Received track for unknown participant: ${trackId}`,
@@ -127,6 +140,13 @@ export class Subscriber extends BasePeerConnection {
127
140
  return;
128
141
  }
129
142
 
143
+ // Self-sub loopback audio routes to the speaker by default, which
144
+ // would echo the local user's voice. Default-mute here; consumers
145
+ // (the loopback recording hook) re-enable explicitly when needed.
146
+ if (isSelfSub && e.track.kind === 'audio') {
147
+ e.track.enabled = false;
148
+ }
149
+
130
150
  // get the previous stream to dispose it later
131
151
  // usually this happens during migration, when the stream is replaced
132
152
  // with a new one but the old one is still in the state
@@ -137,8 +157,15 @@ export class Subscriber extends BasePeerConnection {
137
157
  [streamKindProp]: primaryStream,
138
158
  });
139
159
 
140
- // now, dispose the previous stream if it exists
141
160
  if (previousStream) {
161
+ if (isSelfSub && !this.trackedStreams.has(previousStream)) {
162
+ // this is the local capture stream, we don't want to dispose it
163
+ this.logger.debug(
164
+ `[onTrack]: Skipping cleanup of previous ${e.track.kind} stream for userId: ${participantToUpdate.userId} because it is not tracked`,
165
+ );
166
+ return;
167
+ }
168
+
142
169
  this.logger.info(
143
170
  `[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`,
144
171
  );
@@ -196,6 +223,7 @@ export class Subscriber extends BasePeerConnection {
196
223
  await this.sfuClient.sendAnswer({
197
224
  peerType: PeerType.SUBSCRIBER,
198
225
  sdp: answer.sdp || '',
226
+ negotiationId: subscriberOffer.negotiationId,
199
227
  });
200
228
 
201
229
  this.isIceRestarting = false;
@@ -425,7 +425,10 @@ describe('Subscriber', () => {
425
425
  .mockResolvedValue({ sdp: 'answer-sdp' });
426
426
  vi.spyOn(subscriber['pc'], 'setRemoteDescription').mockResolvedValue();
427
427
 
428
- const offer = SubscriberOffer.create({ sdp: 'offer-sdp' });
428
+ const offer = SubscriberOffer.create({
429
+ sdp: 'offer-sdp',
430
+ negotiationId: 42,
431
+ });
429
432
  // @ts-expect-error - private method
430
433
  await subscriber.negotiate(offer);
431
434
  expect(subscriber['pc'].setRemoteDescription).toHaveBeenCalledWith({
@@ -437,6 +440,7 @@ describe('Subscriber', () => {
437
440
  expect(sfuClient.sendAnswer).toHaveBeenCalledWith({
438
441
  peerType: PeerType.SUBSCRIBER,
439
442
  sdp: 'answer-sdp',
443
+ negotiationId: 42,
440
444
  });
441
445
  });
442
446
  });
@@ -1,6 +1,6 @@
1
1
  import { vi } from 'vitest';
2
2
 
3
- const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
3
+ const RTCPeerConnectionMock = vi.fn(function (): Partial<RTCPeerConnection> {
4
4
  return {
5
5
  addEventListener: vi.fn(),
6
6
  addIceCandidate: vi.fn(),
@@ -23,7 +23,7 @@ const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
23
23
  });
24
24
  vi.stubGlobal('RTCPeerConnection', RTCPeerConnectionMock);
25
25
 
26
- const MediaStreamMock = vi.fn((): Partial<MediaStream> => {
26
+ const MediaStreamMock = vi.fn(function (): Partial<MediaStream> {
27
27
  return {
28
28
  getTracks: vi.fn().mockReturnValue([]),
29
29
  addTrack: vi.fn(),
@@ -34,7 +34,7 @@ const MediaStreamMock = vi.fn((): Partial<MediaStream> => {
34
34
  });
35
35
  vi.stubGlobal('MediaStream', MediaStreamMock);
36
36
 
37
- const MediaStreamTrackMock = vi.fn((): Partial<MediaStreamTrack> => {
37
+ const MediaStreamTrackMock = vi.fn(function (): Partial<MediaStreamTrack> {
38
38
  return {
39
39
  addEventListener: vi.fn(),
40
40
  removeEventListener: vi.fn(),
@@ -49,7 +49,7 @@ const MediaStreamTrackMock = vi.fn((): Partial<MediaStreamTrack> => {
49
49
  });
50
50
  vi.stubGlobal('MediaStreamTrack', MediaStreamTrackMock);
51
51
 
52
- const RTCRtpTransceiverMock = vi.fn((): Partial<RTCRtpTransceiver> => {
52
+ const RTCRtpTransceiverMock = vi.fn(function (): Partial<RTCRtpTransceiver> {
53
53
  return {
54
54
  // @ts-expect-error - incomplete mock
55
55
  sender: {
@@ -64,24 +64,25 @@ const RTCRtpTransceiverMock = vi.fn((): Partial<RTCRtpTransceiver> => {
64
64
  });
65
65
  vi.stubGlobal('RTCRtpTransceiver', RTCRtpTransceiverMock);
66
66
 
67
- const RTCTrackEvent = vi.fn(
68
- (type: string, eventInitDict: RTCTrackEventInit): Partial<RTCTrackEvent> => {
69
- return {
70
- type,
71
- ...eventInitDict,
72
- };
73
- },
74
- );
67
+ const RTCTrackEvent = vi.fn(function (
68
+ type: string,
69
+ eventInitDict: RTCTrackEventInit,
70
+ ): Partial<RTCTrackEvent> {
71
+ return {
72
+ type,
73
+ ...eventInitDict,
74
+ };
75
+ });
75
76
  vi.stubGlobal('RTCTrackEvent', RTCTrackEvent);
76
77
 
77
- const RTCRtpReceiverMock = vi.fn((): Partial<typeof RTCRtpReceiver> => {
78
+ const RTCRtpReceiverMock = vi.fn(function (): Partial<typeof RTCRtpReceiver> {
78
79
  return {
79
80
  getCapabilities: vi.fn(),
80
81
  };
81
82
  });
82
83
  vi.stubGlobal('RTCRtpReceiver', RTCRtpReceiverMock);
83
84
 
84
- const RTCRtpSenderMock = vi.fn((): Partial<typeof RTCRtpSender> => {
85
+ const RTCRtpSenderMock = vi.fn(function (): Partial<typeof RTCRtpSender> {
85
86
  return {
86
87
  getCapabilities: vi.fn(),
87
88
  // @ts-expect-error - incomplete mock
@@ -90,7 +91,7 @@ const RTCRtpSenderMock = vi.fn((): Partial<typeof RTCRtpSender> => {
90
91
  });
91
92
  vi.stubGlobal('RTCRtpSender', RTCRtpSenderMock);
92
93
 
93
- const AudioContextMock = vi.fn((): Partial<AudioContext> => {
94
+ const AudioContextMock = vi.fn(function (): Partial<AudioContext> {
94
95
  return {
95
96
  state: 'suspended',
96
97
  sinkId: '',
package/src/rtc/types.ts CHANGED
@@ -64,6 +64,7 @@ export type BasePeerConnectionOpts = {
64
64
  enableTracing: boolean;
65
65
  iceRestartDelay?: number;
66
66
  clientPublishOptions?: ClientPublishOptions;
67
+ statsTimestampDriftThresholdMs?: number;
67
68
  };
68
69
 
69
70
  export type TrackPublishOptions = {
@@ -19,6 +19,7 @@ export class StatsTracer {
19
19
  private readonly pc: RTCPeerConnection;
20
20
  private readonly peerType: PeerType;
21
21
  private readonly trackIdToTrackType: Map<string, TrackType>;
22
+ private readonly driftThresholdMs: number;
22
23
 
23
24
  private costOverrides?: Map<TrackType, number>;
24
25
 
@@ -33,10 +34,12 @@ export class StatsTracer {
33
34
  pc: RTCPeerConnection,
34
35
  peerType: PeerType,
35
36
  trackIdToTrackType: Map<string, TrackType>,
37
+ statsTimestampDriftThresholdMs: number = 0,
36
38
  ) {
37
39
  this.pc = pc;
38
40
  this.peerType = peerType;
39
41
  this.trackIdToTrackType = trackIdToTrackType;
42
+ this.driftThresholdMs = statsTimestampDriftThresholdMs;
40
43
  }
41
44
 
42
45
  /**
@@ -49,7 +52,11 @@ export class StatsTracer {
49
52
  */
50
53
  get = async (): Promise<ComputedStats> => {
51
54
  const stats = await this.pc.getStats();
52
- const currentStats = toObject(stats);
55
+ const currentStats = toObjectWithCorrectedTimestamp(
56
+ stats,
57
+ Date.now(),
58
+ this.driftThresholdMs,
59
+ );
53
60
 
54
61
  const performanceStats = this.withOverrides(
55
62
  this.peerType === PeerType.SUBSCRIBER
@@ -213,14 +220,28 @@ export class StatsTracer {
213
220
  }
214
221
 
215
222
  /**
216
- * Convert the stat report to an object.
223
+ * Convert the stat report to an object, correcting clock drift along the way.
224
+ * Entries whose `timestamp` differs from `wallNow` by more than `thresholdMs`
225
+ * are replaced with a clone whose `timestamp` is set to `wallNow`. The platform
226
+ * clock backing `DOMHighResTimeStamp` can desynchronise from `Date.now()` after
227
+ * system sleep or clock-jump events (notably on Electron/Chromium), which
228
+ * corrupts the delta-compressed stats payload. A non-positive `thresholdMs`
229
+ * disables correction.
217
230
  *
218
231
  * @param report the stat report to convert.
232
+ * @param wallNow current wall-clock time used as the drift reference.
233
+ * @param thresholdMs maximum tolerated drift in milliseconds.
219
234
  */
220
- const toObject = (report: RTCStatsReport): Record<string, RTCStats> => {
235
+ const toObjectWithCorrectedTimestamp = (
236
+ report: RTCStatsReport,
237
+ wallNow: number,
238
+ thresholdMs: number,
239
+ ): Record<string, RTCStats> => {
221
240
  const obj: Record<string, RTCStats> = {};
241
+ const correct = thresholdMs > 0;
222
242
  report.forEach((v, k) => {
223
- obj[k] = v;
243
+ const drift = Math.abs(v.timestamp - wallNow);
244
+ obj[k] = correct && drift > thresholdMs ? { ...v, timestamp: wallNow } : v;
224
245
  });
225
246
  return obj;
226
247
  };
@@ -0,0 +1,155 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { StatsTracer } from '../StatsTracer';
3
+ import { PeerType } from '../../../gen/video/sfu/models/models';
4
+
5
+ const WALL_NOW = 1_700_000_000_000;
6
+ const THRESHOLD_MS = 5000;
7
+
8
+ const makeReport = (
9
+ entries: Array<{ id: string; timestamp: number }>,
10
+ ): RTCStatsReport => {
11
+ const map = new Map<string, RTCStats>();
12
+ for (const e of entries) {
13
+ map.set(e.id, { id: e.id, timestamp: e.timestamp, type: 'codec' });
14
+ }
15
+ return map as unknown as RTCStatsReport;
16
+ };
17
+
18
+ const makePc = (report: RTCStatsReport) => {
19
+ return {
20
+ getStats: vi.fn().mockResolvedValue(report),
21
+ } as unknown as RTCPeerConnection;
22
+ };
23
+
24
+ describe('StatsTracer timestamp drift correction', () => {
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ vi.setSystemTime(WALL_NOW);
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.useRealTimers();
32
+ });
33
+
34
+ it('keeps original timestamps when no entry drifts past the threshold', async () => {
35
+ const report = makeReport([
36
+ { id: 'a', timestamp: WALL_NOW - 1000 },
37
+ { id: 'b', timestamp: WALL_NOW + 2000 },
38
+ ]);
39
+ const tracer = new StatsTracer(
40
+ makePc(report),
41
+ PeerType.SUBSCRIBER,
42
+ new Map(),
43
+ THRESHOLD_MS,
44
+ );
45
+
46
+ const { delta } = await tracer.get();
47
+
48
+ expect(delta.timestamp).toBe(WALL_NOW + 2000);
49
+ });
50
+
51
+ it('replaces drifted timestamps with wall time', async () => {
52
+ const report = makeReport([
53
+ { id: 'a', timestamp: WALL_NOW },
54
+ { id: 'b', timestamp: WALL_NOW + 10_000 },
55
+ ]);
56
+ const tracer = new StatsTracer(
57
+ makePc(report),
58
+ PeerType.SUBSCRIBER,
59
+ new Map(),
60
+ THRESHOLD_MS,
61
+ );
62
+
63
+ const { delta } = await tracer.get();
64
+
65
+ expect(delta.timestamp).toBe(WALL_NOW);
66
+ });
67
+
68
+ it('clamps stale timestamps to wall time (past drift)', async () => {
69
+ const report = makeReport([
70
+ { id: 'a', timestamp: WALL_NOW + 2000 },
71
+ { id: 'b', timestamp: WALL_NOW - 10_000 },
72
+ ]);
73
+ const tracer = new StatsTracer(
74
+ makePc(report),
75
+ PeerType.SUBSCRIBER,
76
+ new Map(),
77
+ THRESHOLD_MS,
78
+ );
79
+
80
+ const { delta } = await tracer.get();
81
+
82
+ expect(delta['b'].timestamp).toBe(WALL_NOW);
83
+ expect(delta.timestamp).toBe(WALL_NOW + 2000);
84
+ });
85
+
86
+ it('keeps timestamps with drift exactly at the threshold (exclusive boundary)', async () => {
87
+ const report = makeReport([
88
+ { id: 'a', timestamp: WALL_NOW },
89
+ { id: 'b', timestamp: WALL_NOW + THRESHOLD_MS },
90
+ ]);
91
+ const tracer = new StatsTracer(
92
+ makePc(report),
93
+ PeerType.SUBSCRIBER,
94
+ new Map(),
95
+ THRESHOLD_MS,
96
+ );
97
+
98
+ const { delta } = await tracer.get();
99
+
100
+ expect(delta.timestamp).toBe(WALL_NOW + THRESHOLD_MS);
101
+ });
102
+
103
+ it('clamps remote-sourced stat types like any other stat (no special handling)', async () => {
104
+ const map = new Map<string, RTCStats>([
105
+ ['anchor', { id: 'anchor', timestamp: WALL_NOW + 2000, type: 'codec' }],
106
+ [
107
+ 'remote-in',
108
+ {
109
+ id: 'remote-in',
110
+ timestamp: WALL_NOW - 30_000,
111
+ type: 'remote-inbound-rtp',
112
+ },
113
+ ],
114
+ [
115
+ 'remote-out',
116
+ {
117
+ id: 'remote-out',
118
+ timestamp: WALL_NOW + 30_000,
119
+ type: 'remote-outbound-rtp',
120
+ },
121
+ ],
122
+ ]);
123
+ const tracer = new StatsTracer(
124
+ makePc(map as unknown as RTCStatsReport),
125
+ PeerType.SUBSCRIBER,
126
+ new Map(),
127
+ THRESHOLD_MS,
128
+ );
129
+
130
+ const { delta } = await tracer.get();
131
+
132
+ // both stale and future drift get clamped to wall time, while the
133
+ // within-threshold anchor stays the delta's top-level timestamp.
134
+ expect(delta['remote-in'].timestamp).toBe(WALL_NOW);
135
+ expect(delta['remote-out'].timestamp).toBe(WALL_NOW);
136
+ expect(delta.timestamp).toBe(WALL_NOW + 2000);
137
+ });
138
+
139
+ it('does not correct when the threshold is disabled (0)', async () => {
140
+ const report = makeReport([
141
+ { id: 'a', timestamp: WALL_NOW },
142
+ { id: 'b', timestamp: WALL_NOW + 99_999 },
143
+ ]);
144
+ const tracer = new StatsTracer(
145
+ makePc(report),
146
+ PeerType.SUBSCRIBER,
147
+ new Map(),
148
+ 0,
149
+ );
150
+
151
+ const { delta } = await tracer.get();
152
+
153
+ expect(delta.timestamp).toBe(WALL_NOW + 99_999);
154
+ });
155
+ });