@stream-io/video-client 1.15.6 → 1.16.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.
@@ -1,19 +1,17 @@
1
1
  import { BaseStats } from '../stats';
2
2
  import { SoundStateChangeHandler } from './sound-detector';
3
3
  import { flatten } from '../stats/utils';
4
-
5
- const AUDIO_LEVEL_THRESHOLD = 0.2;
4
+ import { getLogger } from '../logger';
6
5
 
7
6
  export class RNSpeechDetector {
8
7
  private pc1 = new RTCPeerConnection({});
9
8
  private pc2 = new RTCPeerConnection({});
10
- private intervalId: NodeJS.Timeout | undefined;
11
9
  private audioStream: MediaStream | undefined;
12
10
 
13
11
  /**
14
12
  * Starts the speech detection.
15
13
  */
16
- public async start() {
14
+ public async start(onSoundDetectedStateChanged: SoundStateChangeHandler) {
17
15
  try {
18
16
  this.cleanupAudioStream();
19
17
  const audioStream = await navigator.mediaDevices.getUserMedia({
@@ -31,6 +29,14 @@ export class RNSpeechDetector {
31
29
  e.candidate as RTCIceCandidateInit | undefined,
32
30
  );
33
31
  });
32
+ this.pc2.addEventListener('track', (e) => {
33
+ e.streams[0].getTracks().forEach((track) => {
34
+ // In RN, the remote track is automatically added to the audio output device
35
+ // so we need to mute it to avoid hearing the audio back
36
+ // @ts-ignore _setVolume is a private method in react-native-webrtc
37
+ track._setVolume(0);
38
+ });
39
+ });
34
40
 
35
41
  audioStream
36
42
  .getTracks()
@@ -41,64 +47,122 @@ export class RNSpeechDetector {
41
47
  const answer = await this.pc2.createAnswer();
42
48
  await this.pc1.setRemoteDescription(answer);
43
49
  await this.pc2.setLocalDescription(answer);
44
- const audioTracks = audioStream.getAudioTracks();
45
- // We need to mute the audio track for this temporary stream, or else you will hear yourself twice while in the call.
46
- audioTracks.forEach((track) => (track.enabled = false));
47
- } catch (error) {
48
- console.error(
49
- 'Error connecting and negotiating between PeerConnections:',
50
- error,
50
+ const unsub = this.onSpeakingDetectedStateChange(
51
+ onSoundDetectedStateChanged,
51
52
  );
53
+ return () => {
54
+ unsub();
55
+ this.stop();
56
+ };
57
+ } catch (error) {
58
+ const logger = getLogger(['RNSpeechDetector']);
59
+ logger('error', 'error handling permissions: ', error);
60
+ return () => {};
52
61
  }
53
62
  }
54
63
 
55
64
  /**
56
65
  * Stops the speech detection and releases all allocated resources.
57
66
  */
58
- public stop() {
67
+ private stop() {
59
68
  this.pc1.close();
60
69
  this.pc2.close();
61
70
  this.cleanupAudioStream();
62
- if (this.intervalId) {
63
- clearInterval(this.intervalId);
64
- }
65
71
  }
66
72
 
67
73
  /**
68
74
  * Public method that detects the audio levels and returns the status.
69
75
  */
70
- public onSpeakingDetectedStateChange(
76
+ private onSpeakingDetectedStateChange(
71
77
  onSoundDetectedStateChanged: SoundStateChangeHandler,
72
78
  ) {
73
- this.intervalId = setInterval(async () => {
74
- const stats = (await this.pc1.getStats()) as RTCStatsReport;
75
- const report = flatten(stats);
76
- // Audio levels are present inside stats of type `media-source` and of kind `audio`
77
- const audioMediaSourceStats = report.find(
78
- (stat) =>
79
- stat.type === 'media-source' &&
80
- (stat as RTCRtpStreamStats).kind === 'audio',
81
- ) as BaseStats;
82
- if (audioMediaSourceStats) {
83
- const { audioLevel } = audioMediaSourceStats;
84
- if (audioLevel) {
85
- if (audioLevel >= AUDIO_LEVEL_THRESHOLD) {
86
- onSoundDetectedStateChanged({
87
- isSoundDetected: true,
88
- audioLevel,
89
- });
90
- } else {
91
- onSoundDetectedStateChanged({
92
- isSoundDetected: false,
93
- audioLevel: 0,
94
- });
79
+ const initialBaselineNoiseLevel = 0.13;
80
+ let baselineNoiseLevel = initialBaselineNoiseLevel;
81
+ let speechDetected = false;
82
+ let intervalId: NodeJS.Timeout | undefined;
83
+ let speechTimer: NodeJS.Timeout | undefined;
84
+ let silenceTimer: NodeJS.Timeout | undefined;
85
+ let audioLevelHistory = []; // Store recent audio levels for smoother detection
86
+ const historyLength = 10;
87
+ const silenceThreshold = 1.1;
88
+ const resetThreshold = 0.9;
89
+ const speechTimeout = 500; // Speech is set to true after 500ms of audio detection
90
+ const silenceTimeout = 5000; // Reset baseline after 5 seconds of silence
91
+
92
+ const checkAudioLevel = async () => {
93
+ try {
94
+ const stats = (await this.pc1.getStats()) as RTCStatsReport;
95
+ const report = flatten(stats);
96
+ // Audio levels are present inside stats of type `media-source` and of kind `audio`
97
+ const audioMediaSourceStats = report.find(
98
+ (stat) =>
99
+ stat.type === 'media-source' &&
100
+ (stat as RTCRtpStreamStats).kind === 'audio',
101
+ ) as BaseStats;
102
+ if (audioMediaSourceStats) {
103
+ const { audioLevel } = audioMediaSourceStats;
104
+ if (audioLevel) {
105
+ // Update audio level history (with max historyLength sized array)
106
+ audioLevelHistory.push(audioLevel);
107
+ if (audioLevelHistory.length > historyLength) {
108
+ audioLevelHistory.shift();
109
+ }
110
+
111
+ // Calculate average audio level
112
+ const avgAudioLevel =
113
+ audioLevelHistory.reduce((a, b) => a + b, 0) /
114
+ audioLevelHistory.length;
115
+
116
+ // Update baseline (if necessary) based on silence detection
117
+ if (avgAudioLevel < baselineNoiseLevel * silenceThreshold) {
118
+ if (!silenceTimer) {
119
+ silenceTimer = setTimeout(() => {
120
+ baselineNoiseLevel = Math.min(
121
+ avgAudioLevel * resetThreshold,
122
+ initialBaselineNoiseLevel,
123
+ );
124
+ }, silenceTimeout);
125
+ }
126
+ } else {
127
+ clearTimeout(silenceTimer);
128
+ silenceTimer = undefined;
129
+ }
130
+
131
+ // Speech detection with hysteresis
132
+ if (avgAudioLevel > baselineNoiseLevel * 1.5) {
133
+ if (!speechDetected) {
134
+ speechDetected = true;
135
+ onSoundDetectedStateChanged({
136
+ isSoundDetected: true,
137
+ audioLevel,
138
+ });
139
+ }
140
+
141
+ clearTimeout(speechTimer);
142
+
143
+ speechTimer = setTimeout(() => {
144
+ speechDetected = false;
145
+ onSoundDetectedStateChanged({
146
+ isSoundDetected: false,
147
+ audioLevel: 0,
148
+ });
149
+ }, speechTimeout);
150
+ }
95
151
  }
96
152
  }
153
+ } catch (error) {
154
+ const logger = getLogger(['RNSpeechDetector']);
155
+ logger('error', 'error checking audio level from stats', error);
97
156
  }
98
- }, 1000);
157
+ };
158
+
159
+ // Call checkAudioLevel periodically (every 100ms)
160
+ intervalId = setInterval(checkAudioLevel, 100);
99
161
 
100
162
  return () => {
101
- clearInterval(this.intervalId);
163
+ clearInterval(intervalId);
164
+ clearTimeout(speechTimer);
165
+ clearTimeout(silenceTimer);
102
166
  };
103
167
  }
104
168
 
@@ -21,7 +21,6 @@ import { CallStatsReport } from '../stats';
21
21
  import {
22
22
  BlockedUserEvent,
23
23
  CallClosedCaption,
24
- CallHLSBroadcastingStartedEvent,
25
24
  CallIngressResponse,
26
25
  CallMemberAddedEvent,
27
26
  CallMemberRemovedEvent,
@@ -42,7 +41,7 @@ import {
42
41
  UnblockedUserEvent,
43
42
  UpdatedCallPermissionsEvent,
44
43
  UserResponse,
45
- WSEvent,
44
+ VideoEvent,
46
45
  } from '../gen/coordinator';
47
46
  import { Timestamp } from '../gen/google/protobuf/timestamp';
48
47
  import { ReconnectDetails } from '../gen/video/sfu/event/events';
@@ -60,7 +59,7 @@ import { hasScreenShare } from '../helpers/participantUtils';
60
59
  */
61
60
  const defaultEgress: EgressResponse = {
62
61
  broadcasting: false,
63
- hls: { playlist_url: '' },
62
+ hls: { playlist_url: '', status: '' },
64
63
  rtmps: [],
65
64
  };
66
65
 
@@ -314,8 +313,8 @@ export class CallState {
314
313
  private closedCaptionsTasks = new Map<string, NodeJS.Timeout>();
315
314
 
316
315
  private readonly eventHandlers: {
317
- [EventType in WSEvent['type']]:
318
- | ((event: Extract<WSEvent, { type: EventType }>) => void)
316
+ [EventType in VideoEvent['type']]:
317
+ | ((event: Extract<VideoEvent, { type: EventType }>) => void)
319
318
  | undefined;
320
319
  };
321
320
 
@@ -414,21 +413,16 @@ export class CallState {
414
413
 
415
414
  this.eventHandlers = {
416
415
  // these events are not updating the call state:
417
- 'call.deleted': undefined,
418
416
  'call.permission_request': undefined,
419
417
  'call.recording_ready': undefined,
418
+ 'call.rtmp_broadcast_failed': undefined,
419
+ 'call.rtmp_broadcast_started': undefined,
420
+ 'call.rtmp_broadcast_stopped': undefined,
420
421
  'call.transcription_ready': undefined,
421
422
  'call.user_muted': undefined,
422
423
  'connection.error': undefined,
423
424
  'connection.ok': undefined,
424
425
  'health.check': undefined,
425
- 'user.banned': undefined,
426
- 'user.deactivated': undefined,
427
- 'user.deleted': undefined,
428
- 'user.muted': undefined,
429
- 'user.presence.changed': undefined,
430
- 'user.reactivated': undefined,
431
- 'user.unbanned': undefined,
432
426
  'user.updated': undefined,
433
427
  custom: undefined,
434
428
 
@@ -446,12 +440,15 @@ export class CallState {
446
440
  this.setCurrentValue(this.captioningSubject, false);
447
441
  },
448
442
  'call.created': (e) => this.updateFromCallResponse(e.call),
443
+ 'call.deleted': (e) => this.updateFromCallResponse(e.call),
449
444
  'call.ended': (e) => {
450
445
  this.updateFromCallResponse(e.call);
451
446
  this.setCurrentValue(this.endedBySubject, e.user);
452
447
  },
453
448
  'call.hls_broadcasting_failed': this.updateFromHLSBroadcastingFailed,
454
- 'call.hls_broadcasting_started': this.updateFromHLSBroadcastStarted,
449
+ 'call.hls_broadcasting_started': (e) => {
450
+ this.updateFromCallResponse(e.call);
451
+ },
455
452
  'call.hls_broadcasting_stopped': this.updateFromHLSBroadcastStopped,
456
453
  'call.live_started': (e) => this.updateFromCallResponse(e.call),
457
454
  'call.member_added': this.updateFromMemberAdded,
@@ -1012,7 +1009,7 @@ export class CallState {
1012
1009
  *
1013
1010
  * @param event the video event that our backend sent us.
1014
1011
  */
1015
- updateFromEvent = (event: WSEvent) => {
1012
+ updateFromEvent = (event: VideoEvent) => {
1016
1013
  const update = this.eventHandlers[event.type];
1017
1014
  if (update) {
1018
1015
  update(event as any);
@@ -1205,6 +1202,10 @@ export class CallState {
1205
1202
  this.setCurrentValue(this.egressSubject, (egress = defaultEgress) => ({
1206
1203
  ...egress,
1207
1204
  broadcasting: false,
1205
+ hls: {
1206
+ ...egress.hls!,
1207
+ status: '',
1208
+ },
1208
1209
  }));
1209
1210
  };
1210
1211
 
@@ -1212,18 +1213,9 @@ export class CallState {
1212
1213
  this.setCurrentValue(this.egressSubject, (egress = defaultEgress) => ({
1213
1214
  ...egress,
1214
1215
  broadcasting: false,
1215
- }));
1216
- };
1217
-
1218
- private updateFromHLSBroadcastStarted = (
1219
- event: CallHLSBroadcastingStartedEvent,
1220
- ) => {
1221
- this.setCurrentValue(this.egressSubject, (egress = defaultEgress) => ({
1222
- ...egress,
1223
- broadcasting: true,
1224
1216
  hls: {
1225
- ...egress.hls,
1226
- playlist_url: event.hls_playlist_url,
1217
+ ...egress.hls!,
1218
+ status: '',
1227
1219
  },
1228
1220
  }));
1229
1221
  };
@@ -669,18 +669,30 @@ describe('CallState', () => {
669
669
  broadcasting: false,
670
670
  hls: {
671
671
  playlist_url: '',
672
+ status: 'starting',
672
673
  },
673
674
  },
674
675
  });
675
676
  // @ts-ignore
676
677
  state.updateFromEvent({
677
678
  type: 'call.hls_broadcasting_started',
678
- hls_playlist_url: 'https://example.com/playlist.m3u8',
679
+ // @ts-expect-error incomplete data
680
+ call: {
681
+ egress: {
682
+ broadcasting: true,
683
+ hls: {
684
+ playlist_url: 'https://example.com/playlist.m3u8',
685
+ status: 'running',
686
+ },
687
+ rtmps: [],
688
+ },
689
+ },
679
690
  });
680
691
  expect(state.egress?.broadcasting).toBe(true);
681
692
  expect(state.egress?.hls?.playlist_url).toBe(
682
693
  'https://example.com/playlist.m3u8',
683
694
  );
695
+ expect(state.egress?.hls?.status).toBe('running');
684
696
  });
685
697
 
686
698
  it('handles call.hls_broadcasting_stopped events', () => {
@@ -1,41 +0,0 @@
1
- import type { TargetResolution } from './coordinator';
2
- export type TargetResolutionRequest = TargetResolution;
3
- export type TargetResolutionResponse = Required<TargetResolution>;
4
- /**
5
- *
6
- * @export
7
- * @interface Bound
8
- */
9
- export interface Bound {
10
- /**
11
- *
12
- * @type {boolean}
13
- * @memberof Bound
14
- */
15
- inclusive: boolean;
16
- /**
17
- *
18
- * @type {number}
19
- * @memberof Bound
20
- */
21
- value: number;
22
- }
23
- /**
24
- *
25
- * @export
26
- * @interface DailyAggregateCallStatsResponse
27
- */
28
- export interface DailyAggregateCallStatsResponse<T = any> {
29
- /**
30
- *
31
- * @type {string}
32
- * @memberof DailyAggregateCallStatsResponse<T>
33
- */
34
- date: string;
35
- /**
36
- *
37
- * @type {T}
38
- * @memberof DailyAggregateCallStatsResponse<T>
39
- */
40
- report: T;
41
- }
package/src/gen/shims.ts DELETED
@@ -1,44 +0,0 @@
1
- import type { TargetResolution } from './coordinator';
2
-
3
- export type TargetResolutionRequest = TargetResolution;
4
- export type TargetResolutionResponse = Required<TargetResolution>;
5
-
6
- /**
7
- *
8
- * @export
9
- * @interface Bound
10
- */
11
- export interface Bound {
12
- /**
13
- *
14
- * @type {boolean}
15
- * @memberof Bound
16
- */
17
- inclusive: boolean;
18
- /**
19
- *
20
- * @type {number}
21
- * @memberof Bound
22
- */
23
- value: number;
24
- }
25
-
26
- /**
27
- *
28
- * @export
29
- * @interface DailyAggregateCallStatsResponse
30
- */
31
- export interface DailyAggregateCallStatsResponse<T = any> {
32
- /**
33
- *
34
- * @type {string}
35
- * @memberof DailyAggregateCallStatsResponse<T>
36
- */
37
- date: string;
38
- /**
39
- *
40
- * @type {T}
41
- * @memberof DailyAggregateCallStatsResponse<T>
42
- */
43
- report: T;
44
- }