@stream-io/video-client 1.47.0 → 1.48.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.
- package/CHANGELOG.md +6 -0
- package/dist/index.browser.es.js +9 -192
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +8 -192
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.es.js +9 -192
- package/dist/index.es.js.map +1 -1
- package/dist/src/devices/MicrophoneManager.d.ts +0 -1
- package/dist/src/types.d.ts +11 -0
- package/index.ts +0 -1
- package/package.json +1 -1
- package/src/devices/MicrophoneManager.ts +9 -5
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +28 -29
- package/src/types.ts +9 -0
- package/dist/src/helpers/RNSpeechDetector.d.ts +0 -23
- package/src/helpers/RNSpeechDetector.ts +0 -224
- package/src/helpers/__tests__/RNSpeechDetector.test.ts +0 -52
|
@@ -13,7 +13,6 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
|
|
|
13
13
|
private soundDetectorCleanup?;
|
|
14
14
|
private soundDetectorDeviceId?;
|
|
15
15
|
private noAudioDetectorCleanup?;
|
|
16
|
-
private rnSpeechDetector;
|
|
17
16
|
private noiseCancellation;
|
|
18
17
|
private noiseCancellationChangeUnsubscribe;
|
|
19
18
|
private noiseCancellationRegistration?;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -371,6 +371,17 @@ export type StreamRNVideoSDKGlobals = {
|
|
|
371
371
|
*/
|
|
372
372
|
check(permission: 'microphone' | 'camera'): Promise<boolean>;
|
|
373
373
|
};
|
|
374
|
+
nativeEvents: {
|
|
375
|
+
speechActivity: {
|
|
376
|
+
/**
|
|
377
|
+
* Subscribes to native speech activity events.
|
|
378
|
+
* Returns an unsubscribe function.
|
|
379
|
+
*/
|
|
380
|
+
subscribe(cb: (state: {
|
|
381
|
+
isSoundDetected: boolean;
|
|
382
|
+
}) => void): () => void;
|
|
383
|
+
};
|
|
384
|
+
};
|
|
374
385
|
};
|
|
375
386
|
declare global {
|
|
376
387
|
var streamRNVideoSDK: StreamRNVideoSDKGlobals | undefined;
|
package/index.ts
CHANGED
|
@@ -23,7 +23,6 @@ export * from './src/helpers/DynascaleManager';
|
|
|
23
23
|
export * from './src/helpers/ViewportTracker';
|
|
24
24
|
export * from './src/helpers/sound-detector';
|
|
25
25
|
export * from './src/helpers/participantUtils';
|
|
26
|
-
export * from './src/helpers/RNSpeechDetector';
|
|
27
26
|
export * as Browsers from './src/helpers/browsers';
|
|
28
27
|
|
|
29
28
|
export * from './src/logger';
|
package/package.json
CHANGED
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
createSafeAsyncSubscription,
|
|
25
25
|
createSubscription,
|
|
26
26
|
} from '../store/rxUtils';
|
|
27
|
-
import { RNSpeechDetector } from '../helpers/RNSpeechDetector';
|
|
28
27
|
import { withoutConcurrency } from '../helpers/concurrency';
|
|
29
28
|
import { disposeOfMediaStream } from './utils';
|
|
30
29
|
import { promiseWithResolvers } from '../helpers/promise';
|
|
@@ -36,7 +35,6 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
36
35
|
private soundDetectorCleanup?: () => Promise<void>;
|
|
37
36
|
private soundDetectorDeviceId?: string;
|
|
38
37
|
private noAudioDetectorCleanup?: () => Promise<void>;
|
|
39
|
-
private rnSpeechDetector: RNSpeechDetector | undefined;
|
|
40
38
|
private noiseCancellation: INoiseCancellation | undefined;
|
|
41
39
|
private noiseCancellationChangeUnsubscribe: (() => void) | undefined;
|
|
42
40
|
private noiseCancellationRegistration?: Promise<void>;
|
|
@@ -422,13 +420,19 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
422
420
|
await this.teardownSpeakingWhileMutedDetection();
|
|
423
421
|
|
|
424
422
|
if (isReactNative()) {
|
|
425
|
-
|
|
426
|
-
|
|
423
|
+
const speechActivity =
|
|
424
|
+
globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
|
|
425
|
+
if (!speechActivity) {
|
|
426
|
+
this.logger.warn(
|
|
427
|
+
'Native speech activity not available, make sure the "@stream-io/react-native-webrtc" peer dependency version is satisfied',
|
|
428
|
+
);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const unsubscribe = speechActivity.subscribe((event) => {
|
|
427
432
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
428
433
|
});
|
|
429
434
|
this.soundDetectorCleanup = async () => {
|
|
430
435
|
unsubscribe();
|
|
431
|
-
this.rnSpeechDetector = undefined;
|
|
432
436
|
};
|
|
433
437
|
} else {
|
|
434
438
|
// Need to start a new stream that's not connected to publisher
|
|
@@ -12,11 +12,12 @@ import {
|
|
|
12
12
|
import { of } from 'rxjs';
|
|
13
13
|
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
14
14
|
import { OwnCapability } from '../../gen/coordinator';
|
|
15
|
-
import { SoundStateChangeHandler } from '../../helpers/sound-detector';
|
|
16
15
|
import { settled, withoutConcurrency } from '../../helpers/concurrency';
|
|
17
16
|
|
|
18
|
-
let
|
|
19
|
-
|
|
17
|
+
let speechActivityCallback:
|
|
18
|
+
| ((state: { isSoundDetected: boolean }) => void)
|
|
19
|
+
| null = null;
|
|
20
|
+
let unsubscribeMocks: ReturnType<typeof vi.fn>[] = [];
|
|
20
21
|
|
|
21
22
|
vi.mock('../../helpers/platforms.ts', () => {
|
|
22
23
|
return {
|
|
@@ -46,28 +47,21 @@ vi.mock('../../Call.ts', () => {
|
|
|
46
47
|
};
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
vi.mock('../../helpers/RNSpeechDetector.ts', () => {
|
|
50
|
-
console.log('MOCKING RNSpeechDetector');
|
|
51
|
-
return {
|
|
52
|
-
RNSpeechDetector: vi.fn().mockImplementation(() => ({
|
|
53
|
-
start: vi.fn((callback) => {
|
|
54
|
-
handler = callback;
|
|
55
|
-
const unsubscribe = vi.fn();
|
|
56
|
-
unsubscribeHandlers.push(unsubscribe);
|
|
57
|
-
return unsubscribe;
|
|
58
|
-
}),
|
|
59
|
-
stop: vi.fn(),
|
|
60
|
-
onSpeakingDetectedStateChange: vi.fn(),
|
|
61
|
-
})),
|
|
62
|
-
};
|
|
63
|
-
});
|
|
64
|
-
|
|
65
50
|
describe('MicrophoneManager React Native', () => {
|
|
66
51
|
let manager: MicrophoneManager;
|
|
67
52
|
let checkPermissionMock: ReturnType<typeof vi.fn>;
|
|
53
|
+
let subscribeMock: ReturnType<typeof vi.fn>;
|
|
54
|
+
|
|
68
55
|
beforeEach(() => {
|
|
69
|
-
|
|
56
|
+
speechActivityCallback = null;
|
|
57
|
+
unsubscribeMocks = [];
|
|
70
58
|
checkPermissionMock = vi.fn(async () => true);
|
|
59
|
+
subscribeMock = vi.fn((cb) => {
|
|
60
|
+
speechActivityCallback = cb;
|
|
61
|
+
const unsub = vi.fn();
|
|
62
|
+
unsubscribeMocks.push(unsub);
|
|
63
|
+
return unsub;
|
|
64
|
+
});
|
|
71
65
|
|
|
72
66
|
globalThis.streamRNVideoSDK = {
|
|
73
67
|
callManager: {
|
|
@@ -78,6 +72,11 @@ describe('MicrophoneManager React Native', () => {
|
|
|
78
72
|
permissions: {
|
|
79
73
|
check: checkPermissionMock,
|
|
80
74
|
},
|
|
75
|
+
nativeEvents: {
|
|
76
|
+
speechActivity: {
|
|
77
|
+
subscribe: subscribeMock,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
81
80
|
};
|
|
82
81
|
|
|
83
82
|
const devicePersistence = { enabled: false, storageKey: '' };
|
|
@@ -100,7 +99,7 @@ describe('MicrophoneManager React Native', () => {
|
|
|
100
99
|
|
|
101
100
|
await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 });
|
|
102
101
|
expect(fn).toHaveBeenCalled();
|
|
103
|
-
expect(
|
|
102
|
+
expect(subscribeMock).toHaveBeenCalled();
|
|
104
103
|
});
|
|
105
104
|
|
|
106
105
|
it('should check native microphone permission before starting detection', async () => {
|
|
@@ -146,15 +145,15 @@ describe('MicrophoneManager React Native', () => {
|
|
|
146
145
|
|
|
147
146
|
it('should update speaking while muted state', async () => {
|
|
148
147
|
await manager['startSpeakingWhileMutedDetection']();
|
|
149
|
-
expect(
|
|
148
|
+
expect(subscribeMock).toHaveBeenCalled();
|
|
150
149
|
|
|
151
150
|
expect(manager.state.speakingWhileMuted).toBe(false);
|
|
152
151
|
|
|
153
|
-
|
|
152
|
+
speechActivityCallback!({ isSoundDetected: true });
|
|
154
153
|
|
|
155
154
|
expect(manager.state.speakingWhileMuted).toBe(true);
|
|
156
155
|
|
|
157
|
-
|
|
156
|
+
speechActivityCallback!({ isSoundDetected: false });
|
|
158
157
|
|
|
159
158
|
expect(manager.state.speakingWhileMuted).toBe(false);
|
|
160
159
|
});
|
|
@@ -163,21 +162,21 @@ describe('MicrophoneManager React Native', () => {
|
|
|
163
162
|
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
164
163
|
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
165
164
|
|
|
166
|
-
expect(
|
|
165
|
+
expect(unsubscribeMocks).toHaveLength(1);
|
|
167
166
|
|
|
168
167
|
await manager['stopSpeakingWhileMutedDetection']();
|
|
169
|
-
expect(
|
|
168
|
+
expect(unsubscribeMocks[0]).toHaveBeenCalledTimes(1);
|
|
170
169
|
});
|
|
171
170
|
|
|
172
171
|
it('should cleanup previous speech detector before starting a new one', async () => {
|
|
173
172
|
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
174
173
|
await manager['startSpeakingWhileMutedDetection']('device-2');
|
|
175
174
|
|
|
176
|
-
expect(
|
|
177
|
-
expect(
|
|
175
|
+
expect(unsubscribeMocks).toHaveLength(2);
|
|
176
|
+
expect(unsubscribeMocks[0]).toHaveBeenCalledTimes(1);
|
|
178
177
|
|
|
179
178
|
await manager['stopSpeakingWhileMutedDetection']();
|
|
180
|
-
expect(
|
|
179
|
+
expect(unsubscribeMocks[1]).toHaveBeenCalledTimes(1);
|
|
181
180
|
});
|
|
182
181
|
|
|
183
182
|
it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
|
package/src/types.ts
CHANGED
|
@@ -462,6 +462,15 @@ export type StreamRNVideoSDKGlobals = {
|
|
|
462
462
|
*/
|
|
463
463
|
check(permission: 'microphone' | 'camera'): Promise<boolean>;
|
|
464
464
|
};
|
|
465
|
+
nativeEvents: {
|
|
466
|
+
speechActivity: {
|
|
467
|
+
/**
|
|
468
|
+
* Subscribes to native speech activity events.
|
|
469
|
+
* Returns an unsubscribe function.
|
|
470
|
+
*/
|
|
471
|
+
subscribe(cb: (state: { isSoundDetected: boolean }) => void): () => void;
|
|
472
|
+
};
|
|
473
|
+
};
|
|
465
474
|
};
|
|
466
475
|
|
|
467
476
|
declare global {
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { SoundStateChangeHandler } from './sound-detector';
|
|
2
|
-
export declare class RNSpeechDetector {
|
|
3
|
-
private readonly pc1;
|
|
4
|
-
private readonly pc2;
|
|
5
|
-
private audioStream;
|
|
6
|
-
private externalAudioStream;
|
|
7
|
-
private isStopped;
|
|
8
|
-
constructor(externalAudioStream?: MediaStream);
|
|
9
|
-
/**
|
|
10
|
-
* Starts the speech detection.
|
|
11
|
-
*/
|
|
12
|
-
start(onSoundDetectedStateChanged: SoundStateChangeHandler): Promise<() => void>;
|
|
13
|
-
/**
|
|
14
|
-
* Stops the speech detection and releases all allocated resources.
|
|
15
|
-
*/
|
|
16
|
-
private stop;
|
|
17
|
-
/**
|
|
18
|
-
* Public method that detects the audio levels and returns the status.
|
|
19
|
-
*/
|
|
20
|
-
private onSpeakingDetectedStateChange;
|
|
21
|
-
private cleanupAudioStream;
|
|
22
|
-
private forwardIceCandidate;
|
|
23
|
-
}
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { BaseStats, flatten } from '../stats';
|
|
2
|
-
import { SoundStateChangeHandler } from './sound-detector';
|
|
3
|
-
import { videoLoggerSystem } from '../logger';
|
|
4
|
-
|
|
5
|
-
export class RNSpeechDetector {
|
|
6
|
-
private readonly pc1 = new RTCPeerConnection({});
|
|
7
|
-
private readonly pc2 = new RTCPeerConnection({});
|
|
8
|
-
private audioStream: MediaStream | undefined;
|
|
9
|
-
private externalAudioStream: MediaStream | undefined;
|
|
10
|
-
private isStopped = false;
|
|
11
|
-
|
|
12
|
-
constructor(externalAudioStream?: MediaStream) {
|
|
13
|
-
this.externalAudioStream = externalAudioStream;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Starts the speech detection.
|
|
18
|
-
*/
|
|
19
|
-
public async start(onSoundDetectedStateChanged: SoundStateChangeHandler) {
|
|
20
|
-
let detachListeners: (() => void) | undefined;
|
|
21
|
-
let unsubscribe: (() => void) | undefined;
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
this.isStopped = false;
|
|
25
|
-
const audioStream =
|
|
26
|
-
this.externalAudioStream != null
|
|
27
|
-
? this.externalAudioStream
|
|
28
|
-
: await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
29
|
-
this.audioStream = audioStream;
|
|
30
|
-
|
|
31
|
-
const onPc1IceCandidate = (e: RTCPeerConnectionIceEvent) => {
|
|
32
|
-
this.forwardIceCandidate(this.pc2, e.candidate);
|
|
33
|
-
};
|
|
34
|
-
const onPc2IceCandidate = (e: RTCPeerConnectionIceEvent) => {
|
|
35
|
-
this.forwardIceCandidate(this.pc1, e.candidate);
|
|
36
|
-
};
|
|
37
|
-
const onTrackPc2 = (e: RTCTrackEvent) => {
|
|
38
|
-
e.streams[0].getTracks().forEach((track) => {
|
|
39
|
-
// In RN, the remote track is automatically added to the audio output device
|
|
40
|
-
// so we need to mute it to avoid hearing the audio back
|
|
41
|
-
// @ts-expect-error _setVolume is a private method in react-native-webrtc
|
|
42
|
-
track._setVolume(0);
|
|
43
|
-
});
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
this.pc1.addEventListener('icecandidate', onPc1IceCandidate);
|
|
47
|
-
this.pc2.addEventListener('icecandidate', onPc2IceCandidate);
|
|
48
|
-
this.pc2.addEventListener('track', onTrackPc2);
|
|
49
|
-
detachListeners = () => {
|
|
50
|
-
this.pc1.removeEventListener('icecandidate', onPc1IceCandidate);
|
|
51
|
-
this.pc2.removeEventListener('icecandidate', onPc2IceCandidate);
|
|
52
|
-
this.pc2.removeEventListener('track', onTrackPc2);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
audioStream
|
|
56
|
-
.getTracks()
|
|
57
|
-
.forEach((track) => this.pc1.addTrack(track, audioStream));
|
|
58
|
-
const offer = await this.pc1.createOffer({});
|
|
59
|
-
await this.pc2.setRemoteDescription(offer);
|
|
60
|
-
await this.pc1.setLocalDescription(offer);
|
|
61
|
-
const answer = await this.pc2.createAnswer();
|
|
62
|
-
await this.pc1.setRemoteDescription(answer);
|
|
63
|
-
await this.pc2.setLocalDescription(answer);
|
|
64
|
-
unsubscribe = this.onSpeakingDetectedStateChange(
|
|
65
|
-
onSoundDetectedStateChanged,
|
|
66
|
-
);
|
|
67
|
-
return () => {
|
|
68
|
-
detachListeners?.();
|
|
69
|
-
unsubscribe?.();
|
|
70
|
-
this.stop();
|
|
71
|
-
};
|
|
72
|
-
} catch (error) {
|
|
73
|
-
detachListeners?.();
|
|
74
|
-
unsubscribe?.();
|
|
75
|
-
this.stop();
|
|
76
|
-
|
|
77
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
78
|
-
logger.error('error handling permissions: ', error);
|
|
79
|
-
return () => {};
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Stops the speech detection and releases all allocated resources.
|
|
85
|
-
*/
|
|
86
|
-
private stop() {
|
|
87
|
-
if (this.isStopped) return;
|
|
88
|
-
this.isStopped = true;
|
|
89
|
-
|
|
90
|
-
this.pc1.close();
|
|
91
|
-
this.pc2.close();
|
|
92
|
-
|
|
93
|
-
if (this.externalAudioStream != null) {
|
|
94
|
-
this.externalAudioStream = undefined;
|
|
95
|
-
} else {
|
|
96
|
-
this.cleanupAudioStream();
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Public method that detects the audio levels and returns the status.
|
|
102
|
-
*/
|
|
103
|
-
private onSpeakingDetectedStateChange(
|
|
104
|
-
onSoundDetectedStateChanged: SoundStateChangeHandler,
|
|
105
|
-
) {
|
|
106
|
-
const initialBaselineNoiseLevel = 0.13;
|
|
107
|
-
let baselineNoiseLevel = initialBaselineNoiseLevel;
|
|
108
|
-
let speechDetected = false;
|
|
109
|
-
let speechTimer: NodeJS.Timeout | undefined;
|
|
110
|
-
let silenceTimer: NodeJS.Timeout | undefined;
|
|
111
|
-
const audioLevelHistory: number[] = []; // Store recent audio levels for smoother detection
|
|
112
|
-
const historyLength = 10;
|
|
113
|
-
const silenceThreshold = 1.1;
|
|
114
|
-
const resetThreshold = 0.9;
|
|
115
|
-
const speechTimeout = 500; // Speech is set to true after 500ms of audio detection
|
|
116
|
-
const silenceTimeout = 5000; // Reset baseline after 5 seconds of silence
|
|
117
|
-
|
|
118
|
-
const checkAudioLevel = async () => {
|
|
119
|
-
try {
|
|
120
|
-
const stats = await this.pc1.getStats();
|
|
121
|
-
const report = flatten(stats);
|
|
122
|
-
// Audio levels are present inside stats of type `media-source` and of kind `audio`
|
|
123
|
-
const audioMediaSourceStats = report.find(
|
|
124
|
-
(stat) =>
|
|
125
|
-
stat.type === 'media-source' &&
|
|
126
|
-
(stat as RTCRtpStreamStats).kind === 'audio',
|
|
127
|
-
) as BaseStats;
|
|
128
|
-
if (audioMediaSourceStats) {
|
|
129
|
-
const { audioLevel } = audioMediaSourceStats;
|
|
130
|
-
if (audioLevel) {
|
|
131
|
-
// Update audio level history (with max historyLength sized array)
|
|
132
|
-
audioLevelHistory.push(audioLevel);
|
|
133
|
-
if (audioLevelHistory.length > historyLength) {
|
|
134
|
-
audioLevelHistory.shift();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Calculate average audio level
|
|
138
|
-
const avgAudioLevel =
|
|
139
|
-
audioLevelHistory.reduce((a, b) => a + b, 0) /
|
|
140
|
-
audioLevelHistory.length;
|
|
141
|
-
|
|
142
|
-
// Update baseline (if necessary) based on silence detection
|
|
143
|
-
if (avgAudioLevel < baselineNoiseLevel * silenceThreshold) {
|
|
144
|
-
if (!silenceTimer) {
|
|
145
|
-
silenceTimer = setTimeout(() => {
|
|
146
|
-
baselineNoiseLevel = Math.min(
|
|
147
|
-
avgAudioLevel * resetThreshold,
|
|
148
|
-
initialBaselineNoiseLevel,
|
|
149
|
-
);
|
|
150
|
-
}, silenceTimeout);
|
|
151
|
-
}
|
|
152
|
-
} else {
|
|
153
|
-
clearTimeout(silenceTimer);
|
|
154
|
-
silenceTimer = undefined;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Speech detection with hysteresis
|
|
158
|
-
if (avgAudioLevel > baselineNoiseLevel * 1.5) {
|
|
159
|
-
if (!speechDetected) {
|
|
160
|
-
speechDetected = true;
|
|
161
|
-
onSoundDetectedStateChanged({
|
|
162
|
-
isSoundDetected: true,
|
|
163
|
-
audioLevel,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
clearTimeout(speechTimer);
|
|
168
|
-
|
|
169
|
-
speechTimer = setTimeout(() => {
|
|
170
|
-
speechDetected = false;
|
|
171
|
-
onSoundDetectedStateChanged({
|
|
172
|
-
isSoundDetected: false,
|
|
173
|
-
audioLevel: 0,
|
|
174
|
-
});
|
|
175
|
-
}, speechTimeout);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
} catch (error) {
|
|
180
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
181
|
-
logger.error('error checking audio level from stats', error);
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
const intervalId = setInterval(checkAudioLevel, 250);
|
|
186
|
-
return () => {
|
|
187
|
-
clearInterval(intervalId);
|
|
188
|
-
clearTimeout(speechTimer);
|
|
189
|
-
clearTimeout(silenceTimer);
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
private cleanupAudioStream() {
|
|
194
|
-
if (!this.audioStream) {
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
this.audioStream.getTracks().forEach((track) => track.stop());
|
|
198
|
-
if (
|
|
199
|
-
// @ts-expect-error release() is present in react-native-webrtc
|
|
200
|
-
typeof this.audioStream.release === 'function'
|
|
201
|
-
) {
|
|
202
|
-
// @ts-expect-error called to dispose the stream in RN
|
|
203
|
-
this.audioStream.release();
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
private forwardIceCandidate(
|
|
208
|
-
destination: RTCPeerConnection,
|
|
209
|
-
candidate: RTCIceCandidate | null,
|
|
210
|
-
) {
|
|
211
|
-
if (
|
|
212
|
-
this.isStopped ||
|
|
213
|
-
!candidate ||
|
|
214
|
-
destination.signalingState === 'closed'
|
|
215
|
-
) {
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
destination.addIceCandidate(candidate).catch(() => {
|
|
219
|
-
// silently ignore the error
|
|
220
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
221
|
-
logger.info('cannot add ice candidate - ignoring');
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import { RNSpeechDetector } from '../RNSpeechDetector';
|
|
4
|
-
|
|
5
|
-
describe('RNSpeechDetector', () => {
|
|
6
|
-
// Shared test setup stubs RTCPeerConnection with a vi.fn constructor.
|
|
7
|
-
// We keep a typed handle to that constructor to inspect created instances.
|
|
8
|
-
let rtcPeerConnectionMockCtor: ReturnType<typeof vi.fn>;
|
|
9
|
-
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
rtcPeerConnectionMockCtor =
|
|
12
|
-
globalThis.RTCPeerConnection as unknown as ReturnType<typeof vi.fn>;
|
|
13
|
-
rtcPeerConnectionMockCtor.mockClear();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
vi.restoreAllMocks();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('ignores late ICE candidates after cleanup', async () => {
|
|
21
|
-
const stream = {
|
|
22
|
-
getTracks: () => [],
|
|
23
|
-
} as unknown as MediaStream;
|
|
24
|
-
const detector = new RNSpeechDetector(stream);
|
|
25
|
-
|
|
26
|
-
const cleanup = await detector.start(() => {});
|
|
27
|
-
cleanup();
|
|
28
|
-
|
|
29
|
-
// start() creates two peer connections (pc1 and pc2). We pull them from
|
|
30
|
-
// constructor call results to inspect listener wiring and ICE forwarding.
|
|
31
|
-
const [pc1, pc2] = rtcPeerConnectionMockCtor.mock.results.map(
|
|
32
|
-
(result) => result.value,
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
// Find the registered ICE callback and invoke it manually after cleanup to
|
|
36
|
-
// simulate a late ICE event arriving during teardown.
|
|
37
|
-
const onIceCandidate = pc1.addEventListener.mock.calls.find(
|
|
38
|
-
([eventName]: [string]) => eventName === 'icecandidate',
|
|
39
|
-
)?.[1] as ((e: RTCPeerConnectionIceEvent) => void) | undefined;
|
|
40
|
-
|
|
41
|
-
expect(onIceCandidate).toBeDefined();
|
|
42
|
-
onIceCandidate?.({
|
|
43
|
-
candidate: { candidate: 'candidate:1 1 UDP 0 127.0.0.1 11111 typ host' },
|
|
44
|
-
} as unknown as RTCPeerConnectionIceEvent);
|
|
45
|
-
|
|
46
|
-
expect(pc1.removeEventListener).toHaveBeenCalledWith(
|
|
47
|
-
'icecandidate',
|
|
48
|
-
onIceCandidate,
|
|
49
|
-
);
|
|
50
|
-
expect(pc2.addIceCandidate).not.toHaveBeenCalled();
|
|
51
|
-
});
|
|
52
|
-
});
|