@stream-io/video-client 1.61.2 → 1.62.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.
@@ -327,6 +327,241 @@ describe('MicrophoneManager', () => {
327
327
  await manager.disable();
328
328
  expect(manager['startSpeakingWhileMutedDetection']).toHaveBeenCalled();
329
329
  });
330
+
331
+ describe('mute hand-off', () => {
332
+ let micTrack: MediaStreamTrack;
333
+ let clonedTrack: MediaStreamTrack;
334
+
335
+ beforeEach(() => {
336
+ vi.stubGlobal(
337
+ 'MediaStream',
338
+ vi.fn(function (this: object, tracks: MediaStreamTrack[]) {
339
+ Object.assign(this, { getTracks: () => tracks });
340
+ }),
341
+ );
342
+ const stream = mockAudioStream();
343
+ [micTrack] = stream.getAudioTracks();
344
+ [clonedTrack] = mockAudioStream().getAudioTracks();
345
+ micTrack.clone = vi.fn(() => clonedTrack);
346
+ vi.mocked(getAudioStream).mockResolvedValueOnce(stream);
347
+ manager.dispose();
348
+ const devicePersistence = { enabled: false, storageKey: '' };
349
+ manager = new MicrophoneManager(call, devicePersistence);
350
+ });
351
+
352
+ afterEach(() => {
353
+ vi.unstubAllGlobals();
354
+ });
355
+
356
+ const enableAndSettle = async () => {
357
+ await manager.enable();
358
+ await sleep(25);
359
+ vi.mocked(micTrack.clone).mockClear();
360
+ vi.mocked(getAudioStream).mockClear();
361
+ vi.mocked(createSoundDetector).mockClear();
362
+ };
363
+
364
+ it('keeps the mic captured by handing a clone to the detector', async () => {
365
+ await enableAndSettle();
366
+ await manager.disable();
367
+ await sleep(25);
368
+
369
+ expect(micTrack.readyState).toBe('ended');
370
+ expect(clonedTrack.readyState).toBe('live');
371
+ expect(createSoundDetector).toHaveBeenCalledTimes(1);
372
+ const [detectorStream] = vi.mocked(createSoundDetector).mock.calls[0];
373
+ expect(detectorStream.getTracks()).toEqual([clonedTrack]);
374
+ expect(getAudioStream).not.toHaveBeenCalled();
375
+ });
376
+
377
+ it('releases the clone after unmute opened the new capture', async () => {
378
+ await enableAndSettle();
379
+ const cleanup = vi.fn(async () => {});
380
+ vi.mocked(createSoundDetector).mockImplementationOnce(() => cleanup);
381
+ await manager.disable();
382
+ await sleep(25);
383
+ await manager.enable();
384
+ await sleep(25);
385
+
386
+ expect(cleanup).toHaveBeenCalledTimes(1);
387
+ expect(getAudioStream).toHaveBeenCalledTimes(1);
388
+ const [captureOrder] =
389
+ vi.mocked(getAudioStream).mock.invocationCallOrder;
390
+ expect(captureOrder).toBeLessThan(cleanup.mock.invocationCallOrder[0]);
391
+ });
392
+
393
+ it('releases the clone and keeps the mic live when muting fails', async () => {
394
+ await enableAndSettle();
395
+ vi.mocked(call.stopPublish).mockRejectedValueOnce(
396
+ new Error('SFU unavailable'),
397
+ );
398
+
399
+ await expect(manager.disable()).rejects.toThrow('SFU unavailable');
400
+ await sleep(25);
401
+
402
+ expect(manager.state.status).toBe('enabled');
403
+ expect(micTrack.readyState).toBe('live');
404
+ expect(clonedTrack.readyState).toBe('ended');
405
+ expect(createSoundDetector).not.toHaveBeenCalled();
406
+ });
407
+
408
+ it('releases the clone and falls back when the detector cannot start', async () => {
409
+ await enableAndSettle();
410
+ vi.mocked(createSoundDetector).mockImplementationOnce(() => {
411
+ throw new Error('AudioContext unavailable');
412
+ });
413
+
414
+ await manager.disable();
415
+ await sleep(25);
416
+
417
+ expect(manager.state.status).toBe('disabled');
418
+ expect(clonedTrack.readyState).toBe('ended');
419
+ expect(getAudioStream).toHaveBeenCalledTimes(1);
420
+ });
421
+
422
+ it('unpublishes without waiting for the detector queue', async () => {
423
+ await enableAndSettle();
424
+ let unblock!: () => void;
425
+ const blocker = new Promise<void>((resolve) => (unblock = resolve));
426
+ withoutConcurrency(
427
+ manager['soundDetectorConcurrencyTag'],
428
+ () => blocker,
429
+ );
430
+
431
+ const muted = manager.disable();
432
+ await sleep(25);
433
+
434
+ expect(call.stopPublish).toHaveBeenCalledWith(TrackType.AUDIO);
435
+ expect(micTrack.readyState).toBe('ended');
436
+ expect(clonedTrack.readyState).toBe('live');
437
+ unblock();
438
+ await muted;
439
+ });
440
+
441
+ it('does not open a capture before muting an ended mic track', async () => {
442
+ await enableAndSettle();
443
+ micTrack.stop();
444
+
445
+ await manager.disable();
446
+ await sleep(25);
447
+
448
+ expect(micTrack.clone).not.toHaveBeenCalled();
449
+ const [unpublished] = vi.mocked(call.stopPublish).mock
450
+ .invocationCallOrder;
451
+ vi.mocked(getAudioStream).mock.invocationCallOrder.forEach((order) => {
452
+ expect(order).toBeGreaterThan(unpublished);
453
+ });
454
+ });
455
+
456
+ it('releases the handed-off track when a detector already runs', async () => {
457
+ await manager['startSpeakingWhileMutedDetection']('device-1');
458
+ await manager['startSpeakingWhileMutedDetection'](
459
+ 'device-1',
460
+ clonedTrack,
461
+ );
462
+
463
+ expect(clonedTrack.readyState).toBe('ended');
464
+ });
465
+
466
+ const blockUnpublish = () => {
467
+ let unblock!: () => void;
468
+ vi.mocked(call.stopPublish).mockImplementationOnce(
469
+ () => new Promise<void>((resolve) => (unblock = resolve)),
470
+ );
471
+ return () => unblock();
472
+ };
473
+
474
+ it('falls back to a new capture when the mic ends while unpublishing', async () => {
475
+ await enableAndSettle();
476
+ const unblock = blockUnpublish();
477
+
478
+ const muted = manager.disable();
479
+ await sleep(25);
480
+ clonedTrack.stop();
481
+ unblock();
482
+ await muted;
483
+ await sleep(25);
484
+
485
+ expect(createSoundDetector).toHaveBeenCalledTimes(1);
486
+ const [detectorStream] = vi.mocked(createSoundDetector).mock.calls[0];
487
+ expect(detectorStream.getTracks()).not.toContain(clonedTrack);
488
+ expect(getAudioStream).toHaveBeenCalledTimes(1);
489
+ });
490
+
491
+ it.each([
492
+ [
493
+ 'the call is left',
494
+ () => call.state.setCallingState(CallingState.LEFT),
495
+ ],
496
+ ['sending audio is revoked', () => call.state.setOwnCapabilities([])],
497
+ [
498
+ 'the notification is disabled',
499
+ () => manager.disableSpeakingWhileMutedNotification(),
500
+ ],
501
+ ])('releases the clone when %s while unpublishing', async (_, change) => {
502
+ await enableAndSettle();
503
+ const unblock = blockUnpublish();
504
+
505
+ const muted = manager.disable();
506
+ await sleep(25);
507
+ await change();
508
+ unblock();
509
+ await muted;
510
+ await sleep(25);
511
+
512
+ expect(clonedTrack.readyState).toBe('ended');
513
+ expect(createSoundDetector).not.toHaveBeenCalled();
514
+ });
515
+
516
+ it('releases the clone when another mic is selected while unpublishing', async () => {
517
+ await enableAndSettle();
518
+ const unblock = blockUnpublish();
519
+
520
+ const muted = manager.disable();
521
+ await sleep(25);
522
+ const selected = manager.select('other-mic');
523
+ unblock();
524
+ await Promise.all([muted, selected]);
525
+ await sleep(25);
526
+
527
+ expect(clonedTrack.readyState).toBe('ended');
528
+ expect(getAudioStream).toHaveBeenCalledWith(
529
+ expect.objectContaining({ deviceId: { exact: 'other-mic' } }),
530
+ expect.any(Tracer),
531
+ );
532
+ });
533
+
534
+ it('keeps the detector when a stale unmute is handled after muting again', async () => {
535
+ await enableAndSettle();
536
+ const cleanup = vi.fn(async () => {});
537
+ vi.mocked(createSoundDetector).mockImplementationOnce(() => cleanup);
538
+ let unblock!: () => void;
539
+ const blocker = new Promise<void>((resolve) => (unblock = resolve));
540
+ withoutConcurrency(
541
+ manager['soundDetectorConcurrencyTag'],
542
+ () => blocker,
543
+ );
544
+
545
+ await manager.disable();
546
+ await manager.enable();
547
+ await manager.disable();
548
+ unblock();
549
+ await sleep(25);
550
+
551
+ expect(manager.state.status).toBe('disabled');
552
+ expect(cleanup).not.toHaveBeenCalled();
553
+ expect(clonedTrack.readyState).toBe('live');
554
+ });
555
+
556
+ it('does not hand off the mic when leaving the call', async () => {
557
+ await enableAndSettle();
558
+ call.state.setCallingState(CallingState.LEFT);
559
+ await manager.disable(true);
560
+
561
+ expect(micTrack.clone).not.toHaveBeenCalled();
562
+ expect(micTrack.readyState).toBe('ended');
563
+ });
564
+ });
330
565
  });
331
566
 
332
567
  describe('Noise Cancellation', () => {
@@ -234,6 +234,33 @@ describe('SpeakerManager.test', () => {
234
234
  expect(persistedManager.state.selectedDevice).toBe('speaker-1');
235
235
  });
236
236
 
237
+ it('does not restore a speaker when output selection is not supported', async () => {
238
+ const persistedManager = createPersistedManager();
239
+ Object.defineProperty(
240
+ persistedManager.state,
241
+ 'isDeviceSelectionSupported',
242
+ { value: false },
243
+ );
244
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
245
+ of([
246
+ {
247
+ deviceId: 'speaker-1',
248
+ kind: 'audiooutput',
249
+ label: 'Speaker 1',
250
+ groupId: 'speaker-group',
251
+ } as MediaDeviceInfo,
252
+ ]),
253
+ );
254
+ persist('speaker-1', 'Speaker 1');
255
+
256
+ const selectSpy = vi.spyOn(persistedManager, 'select');
257
+ // @ts-expect-error - partial data
258
+ await persistedManager.apply({});
259
+
260
+ expect(selectSpy).not.toHaveBeenCalled();
261
+ expect(persistedManager.state.selectedDevice).toBe('');
262
+ });
263
+
237
264
  it('does not select a missing persisted speaker device', async () => {
238
265
  const persistedManager = createPersistedManager();
239
266
  vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
@@ -23,6 +23,7 @@ vi.mock('../BrowserPermission', () => {
23
23
  vi.mock('../../helpers/browsers', () => ({
24
24
  isSafari: vi.fn(),
25
25
  isFirefox: vi.fn(),
26
+ isWebKit: vi.fn(),
26
27
  }));
27
28
 
28
29
  type MediaDevicesMock = MediaDevices &
@@ -66,11 +67,11 @@ describe('devices', () => {
66
67
  vi.resetModules();
67
68
  });
68
69
 
69
- it('checkIfAudioOutputChangeSupported uses audio element when not Safari', async () => {
70
+ it('checkIfAudioOutputChangeSupported uses audio element when not WebKit', async () => {
70
71
  setupMediaDevices();
71
72
  const { checkIfAudioOutputChangeSupported } = await loadDevicesModule();
72
73
  const browsers = await import('../../helpers/browsers');
73
- vi.mocked(browsers.isSafari).mockReturnValue(false);
74
+ vi.mocked(browsers.isWebKit).mockReturnValue(false);
74
75
  const createElementSpy = vi
75
76
  .spyOn(document, 'createElement')
76
77
  .mockReturnValue({ setSinkId: vi.fn() } as unknown as HTMLMediaElement);
@@ -79,21 +80,16 @@ describe('devices', () => {
79
80
  expect(createElementSpy).toHaveBeenCalledWith('audio');
80
81
  });
81
82
 
82
- it('checkIfAudioOutputChangeSupported uses AudioContext in Safari', async () => {
83
+ it('checkIfAudioOutputChangeSupported is false on WebKit', async () => {
83
84
  setupMediaDevices();
84
- class AudioContextStub {}
85
- (
86
- AudioContextStub.prototype as { setSinkId?: () => Promise<void> }
87
- ).setSinkId = vi.fn();
88
- Object.defineProperty(globalThis, 'AudioContext', {
89
- configurable: true,
90
- value: AudioContextStub as typeof AudioContext,
91
- });
92
-
93
85
  const { checkIfAudioOutputChangeSupported } = await loadDevicesModule();
94
86
  const browsers = await import('../../helpers/browsers');
95
- vi.mocked(browsers.isSafari).mockReturnValue(true);
96
- expect(checkIfAudioOutputChangeSupported()).toBe(true);
87
+ vi.mocked(browsers.isWebKit).mockReturnValue(true);
88
+ vi.spyOn(document, 'createElement').mockReturnValue({
89
+ setSinkId: vi.fn(),
90
+ } as unknown as HTMLMediaElement);
91
+
92
+ expect(checkIfAudioOutputChangeSupported()).toBe(false);
97
93
  });
98
94
 
99
95
  it('getAudioDevices prompts and filters devices', async () => {
@@ -10,6 +10,7 @@ const createTrack = (
10
10
  getSettings: () => settings,
11
11
  enabled: true,
12
12
  readyState: 'live',
13
+ clone: () => createTrack(settings, extras),
13
14
  stop: () => {
14
15
  // @ts-expect-error read-only property
15
16
  track.readyState = 'ended';
@@ -157,6 +157,7 @@ export const mockAudioStream = () => {
157
157
  label: mockAudioDevices[0].label,
158
158
  enabled: true,
159
159
  readyState: 'live',
160
+ clone: () => mockAudioStream().getAudioTracks()[0],
160
161
  stop: () => {
161
162
  track.readyState = 'ended';
162
163
  },
@@ -11,7 +11,7 @@ import {
11
11
  } from 'rxjs';
12
12
  import { BrowserPermission } from './BrowserPermission';
13
13
  import { lazy } from '../helpers/lazy';
14
- import { isFirefox } from '../helpers/browsers';
14
+ import { isFirefox, isWebKit } from '../helpers/browsers';
15
15
  import { dumpStream, Tracer } from '../stats';
16
16
  import { getCurrentValue } from '../store/rxUtils';
17
17
  import { videoLoggerSystem } from '../logger';
@@ -58,9 +58,15 @@ const getDevices = (
58
58
  /**
59
59
  * Tells if the browser supports audio output change on 'audio' elements,
60
60
  * see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
61
+ *
62
+ * WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
63
+ * setSinkId requires a user gesture, a per-device output can run at a
64
+ * different sample rate than the remote audio mixer (pitched/slowed audio),
65
+ * and audio goes silent when the selected device disconnects.
61
66
  */
62
67
  export const checkIfAudioOutputChangeSupported = () => {
63
68
  if (typeof document === 'undefined') return false;
69
+ if (isWebKit()) return false;
64
70
  const element = document.createElement('audio');
65
71
  return 'setSinkId' in element;
66
72
  };
@@ -323,7 +323,7 @@ export class DynascaleManager {
323
323
  deviceId: string,
324
324
  audioContext: AudioContext | undefined,
325
325
  ) => {
326
- if (!deviceId) return;
326
+ if (!deviceId || !this.speaker.state.isDeviceSelectionSupported) return;
327
327
  if ('setSinkId' in audioElement) {
328
328
  audioElement.setSinkId(deviceId).catch((e) => {
329
329
  this.logger.warn(`Can't to set AudioElement sinkId`, e);
@@ -430,7 +430,7 @@ export class DynascaleManager {
430
430
  });
431
431
  });
432
432
 
433
- const sinkIdSubscription = !('setSinkId' in audioElement)
433
+ const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
434
434
  ? null
435
435
  : this.speaker.state.selectedDevice$.subscribe((deviceId) => {
436
436
  const audioContext = this.getOrCreateAudioContext();
@@ -29,6 +29,7 @@ vi.mock(import('../browsers'), async (importOriginal) => {
29
29
  return {
30
30
  ...module,
31
31
  isSafari: () => globalThis._isSafari ?? false,
32
+ isWebKit: () => false,
32
33
  };
33
34
  });
34
35
 
@@ -191,6 +192,9 @@ describe('DynascaleManager', () => {
191
192
  it('audio: Safari should use AudioContext for audio playback', () => {
192
193
  globalThis._isSafari = true;
193
194
  dynascaleManager.setUseWebAudio(true); // enabled by default on Safari
195
+ Object.defineProperty(call.speaker.state, 'isDeviceSelectionSupported', {
196
+ value: false,
197
+ });
194
198
 
195
199
  vi.useFakeTimers();
196
200
  const audioElement = document.createElement('audio');
@@ -257,11 +261,9 @@ describe('DynascaleManager', () => {
257
261
  expect(gainNode.connect).toHaveBeenCalledWith(audioContext.destination);
258
262
 
259
263
  call.speaker.select('different-device-id');
260
- expect(audioElement.setSinkId).toHaveBeenCalledWith(
261
- 'different-device-id',
262
- );
264
+ expect(audioElement.setSinkId).not.toHaveBeenCalled();
263
265
  // @ts-expect-error sinkId isn't available in the TS definition
264
- expect(audioContext.sinkId).toBe('different-device-id');
266
+ expect(audioContext.sinkId).not.toBe('different-device-id');
265
267
 
266
268
  call.speaker.setVolume(0.5);
267
269
  expect(audioElement.volume).toBe(0.5);
@@ -19,6 +19,7 @@ import { toJSON } from './helpers/iceCandiates';
19
19
  import {
20
20
  BasePeerConnectionOpts,
21
21
  OnIceConnected,
22
+ OnNoLocalCandidates,
22
23
  OnPeerConnectionStateChange,
23
24
  OnReconnectionNeeded,
24
25
  OnRemoteTrackUnmute,
@@ -43,10 +44,12 @@ export abstract class BasePeerConnection {
43
44
 
44
45
  protected onReconnectionNeeded?: OnReconnectionNeeded;
45
46
  private onIceConnected?: OnIceConnected;
47
+ private onNoLocalCandidates?: OnNoLocalCandidates;
46
48
  private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
47
49
  protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
48
50
  private readonly iceRestartDelay: number;
49
51
  private iceHasEverConnected = false;
52
+ private localCandidatesGathered = 0;
50
53
  private iceRestartTimeout?: NodeJS.Timeout;
51
54
  private preConnectStuckTimeout?: NodeJS.Timeout;
52
55
  protected isIceRestarting = false;
@@ -73,6 +76,7 @@ export abstract class BasePeerConnection {
73
76
  dispatcher,
74
77
  onReconnectionNeeded,
75
78
  onIceConnected,
79
+ onNoLocalCandidates,
76
80
  onPeerConnectionStateChange,
77
81
  onRemoteTrackUnmute,
78
82
  tag,
@@ -93,6 +97,7 @@ export abstract class BasePeerConnection {
93
97
  this.tag = tag;
94
98
  this.onReconnectionNeeded = onReconnectionNeeded;
95
99
  this.onIceConnected = onIceConnected;
100
+ this.onNoLocalCandidates = onNoLocalCandidates;
96
101
  this.onPeerConnectionStateChange = onPeerConnectionStateChange;
97
102
  this.onRemoteTrackUnmute = onRemoteTrackUnmute;
98
103
  this.logger = videoLoggerSystem.getLogger(
@@ -148,6 +153,7 @@ export abstract class BasePeerConnection {
148
153
  this.preConnectStuckTimeout = undefined;
149
154
  this.onReconnectionNeeded = undefined;
150
155
  this.onIceConnected = undefined;
156
+ this.onNoLocalCandidates = undefined;
151
157
  this.onPeerConnectionStateChange = undefined;
152
158
  this.onRemoteTrackUnmute = undefined;
153
159
  this.isDisposed = true;
@@ -285,6 +291,20 @@ export abstract class BasePeerConnection {
285
291
  return this.trackIdToTrackType.get(trackId);
286
292
  };
287
293
 
294
+ /**
295
+ * Returns `true` once ICE has reached `connected`/`completed` at least
296
+ * once during the lifetime of this peer connection.
297
+ */
298
+ hasIceEverConnected = () => this.iceHasEverConnected;
299
+
300
+ /**
301
+ * Returns `true` when the latest ICE gathering round completed without
302
+ * producing a single local candidate.
303
+ */
304
+ hasNoLocalCandidates = () =>
305
+ this.pc.iceGatheringState === 'complete' &&
306
+ this.localCandidatesGathered === 0;
307
+
288
308
  /**
289
309
  * Checks if the `RTCPeerConnection` is healthy.
290
310
  * It checks the ICE connection state and the peer connection state.
@@ -311,6 +331,7 @@ export abstract class BasePeerConnection {
311
331
  this.logger.debug('null ice candidate');
312
332
  return;
313
333
  }
334
+ if (candidate.candidate) this.localCandidatesGathered++;
314
335
 
315
336
  const iceCandidate = toJSON(candidate);
316
337
  this.sfuClient
@@ -389,6 +410,20 @@ export abstract class BasePeerConnection {
389
410
  private handleConnectionStateUpdate = (
390
411
  state: RTCIceConnectionState | RTCPeerConnectionState,
391
412
  ) => {
413
+ // Fire `onIceConnected` exactly once per peer-connection lifetime, the
414
+ // first time ICE reaches `connected`/`completed` end-to-end. Recorded
415
+ // ahead of the guards below so a connection established while offline,
416
+ // reconnecting or restarting ICE is not missed. Used by `Call` to reset
417
+ // the unsupported-network failure counter only after WebRTC has actually
418
+ // recovered, not merely on SFU join.
419
+ if (
420
+ (state === 'connected' || state === 'completed') &&
421
+ !this.iceHasEverConnected
422
+ ) {
423
+ this.iceHasEverConnected = true;
424
+ this.onIceConnected?.(this.peerType);
425
+ }
426
+
392
427
  const { callingState } = this.state;
393
428
  if (callingState === CallingState.OFFLINE) return;
394
429
  if (callingState === CallingState.RECONNECTING) return;
@@ -466,14 +501,6 @@ export abstract class BasePeerConnection {
466
501
 
467
502
  case 'connected':
468
503
  case 'completed':
469
- // Fire `onIceConnected` exactly once per peer-connection lifetime —
470
- // the first time ICE reaches `connected`/`completed` end-to-end.
471
- // Used by `Call` to reset the unsupported-network failure counter
472
- // only after WebRTC has actually recovered, not merely on SFU join.
473
- if (!this.iceHasEverConnected) {
474
- this.iceHasEverConnected = true;
475
- this.onIceConnected?.(this.peerType);
476
- }
477
504
  // clear any scheduled restartICE since the connection is healthy
478
505
  if (this.iceRestartTimeout) {
479
506
  this.logger.info('connected connection, canceling restartICE');
@@ -502,7 +529,18 @@ export abstract class BasePeerConnection {
502
529
  * Handles the ICE gathering state change event.
503
530
  */
504
531
  private onIceGatherChange = () => {
505
- this.logger.debug(`ICE Gathering State`, this.pc.iceGatheringState);
532
+ const state = this.pc.iceGatheringState;
533
+ this.logger.debug(`ICE Gathering State`, state);
534
+ if (state === 'gathering') {
535
+ this.localCandidatesGathered = 0;
536
+ } else if (
537
+ state === 'complete' &&
538
+ this.localCandidatesGathered === 0 &&
539
+ !this.iceHasEverConnected
540
+ ) {
541
+ this.logger.info('ICE gathering completed without local candidates');
542
+ this.onNoLocalCandidates?.(this.peerType);
543
+ }
506
544
  };
507
545
 
508
546
  /**