@stream-io/video-client 1.42.3 → 1.43.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 +13 -0
- package/dist/index.browser.es.js +89 -20
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +90 -21
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +89 -20
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +41 -1
- package/dist/src/devices/MicrophoneManager.d.ts +2 -0
- package/dist/src/helpers/no-audio-detector.d.ts +1 -7
- package/package.json +1 -1
- package/src/Call.ts +76 -0
- package/src/devices/MicrophoneManager.ts +21 -5
- package/src/devices/__tests__/MicrophoneManager.test.ts +52 -0
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +26 -1
- package/src/devices/__tests__/web-audio.mocks.ts +6 -2
- package/src/helpers/__tests__/no-audio-detector.test.ts +54 -28
- package/src/helpers/no-audio-detector.ts +25 -20
package/dist/src/Call.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Publisher, Subscriber, TrackPublishOptions } from './rtc';
|
|
2
2
|
import { CallState } from './store';
|
|
3
3
|
import { ScopedLogger } from './logger';
|
|
4
|
-
import type { AcceptCallResponse, BlockUserResponse, CallRingEvent, CallSettingsResponse, CollectUserFeedbackRequest, CollectUserFeedbackResponse, DeleteCallRequest, DeleteCallResponse, EndCallResponse, GetCallReportResponse, GetCallResponse, GetCallSessionParticipantStatsDetailsResponse, GetOrCreateCallRequest, GetOrCreateCallResponse, GoLiveRequest, GoLiveResponse, JoinCallResponse, KickUserRequest, KickUserResponse, ListRecordingsResponse, ListTranscriptionsResponse, MuteUsersResponse, PinRequest, PinResponse, QueryCallMembersRequest, QueryCallMembersResponse, QueryCallSessionParticipantStatsResponse, QueryCallSessionParticipantStatsTimelineResponse, QueryCallStatsMapResponse, RejectCallResponse, RequestPermissionRequest, RequestPermissionResponse, RingCallRequest, RingCallResponse, SendCallEventResponse, SendReactionRequest, SendReactionResponse, StartClosedCaptionsRequest, StartClosedCaptionsResponse, StartFrameRecordingRequest, StartFrameRecordingResponse, StartHLSBroadcastingResponse, StartRTMPBroadcastsRequest, StartRTMPBroadcastsResponse, StartTranscriptionRequest, StartTranscriptionResponse, StopAllRTMPBroadcastsResponse, StopClosedCaptionsRequest, StopClosedCaptionsResponse, StopFrameRecordingResponse, StopHLSBroadcastingResponse, StopLiveRequest, StopLiveResponse, StopRecordingResponse, StopRTMPBroadcastsResponse, StopTranscriptionResponse, UnblockUserResponse, UnpinRequest, UnpinResponse, UpdateCallMembersRequest, UpdateCallMembersResponse, UpdateCallRequest, UpdateCallResponse, UpdateUserPermissionsRequest, UpdateUserPermissionsResponse } from './gen/coordinator';
|
|
4
|
+
import type { AcceptCallResponse, BlockUserResponse, CallRingEvent, CallSettingsResponse, CollectUserFeedbackRequest, CollectUserFeedbackResponse, DeleteCallRequest, DeleteCallResponse, DeleteRecordingResponse, DeleteTranscriptionResponse, EndCallResponse, GetCallReportResponse, GetCallResponse, GetCallSessionParticipantStatsDetailsResponse, GetOrCreateCallRequest, GetOrCreateCallResponse, GoLiveRequest, GoLiveResponse, JoinCallResponse, KickUserRequest, KickUserResponse, ListRecordingsResponse, ListTranscriptionsResponse, MuteUsersResponse, PinRequest, PinResponse, QueryCallMembersRequest, QueryCallMembersResponse, QueryCallParticipantsRequest, QueryCallParticipantsResponse, QueryCallSessionParticipantStatsResponse, QueryCallSessionParticipantStatsTimelineResponse, QueryCallStatsMapResponse, RejectCallResponse, RequestPermissionRequest, RequestPermissionResponse, RingCallRequest, RingCallResponse, SendCallEventResponse, SendReactionRequest, SendReactionResponse, StartClosedCaptionsRequest, StartClosedCaptionsResponse, StartFrameRecordingRequest, StartFrameRecordingResponse, StartHLSBroadcastingResponse, StartRTMPBroadcastsRequest, StartRTMPBroadcastsResponse, StartTranscriptionRequest, StartTranscriptionResponse, StopAllRTMPBroadcastsResponse, StopClosedCaptionsRequest, StopClosedCaptionsResponse, StopFrameRecordingResponse, StopHLSBroadcastingResponse, StopLiveRequest, StopLiveResponse, StopRecordingResponse, StopRTMPBroadcastsResponse, StopTranscriptionResponse, UnblockUserResponse, UnpinRequest, UnpinResponse, UpdateCallMembersRequest, UpdateCallMembersResponse, UpdateCallRequest, UpdateCallResponse, UpdateUserPermissionsRequest, UpdateUserPermissionsResponse } from './gen/coordinator';
|
|
5
5
|
import { AudioTrackType, CallConstructor, CallLeaveOptions, CallRecordingType, ClientPublishOptions, ClosedCaptionsSettings, JoinCallData, StartCallRecordingFnType, TrackMuteType, VideoTrackType } from './types';
|
|
6
6
|
import { ClientCapability, TrackType, VideoDimension } from './gen/video/sfu/models/models';
|
|
7
7
|
import { Tracer } from './stats';
|
|
@@ -631,6 +631,15 @@ export declare class Call {
|
|
|
631
631
|
* @returns
|
|
632
632
|
*/
|
|
633
633
|
queryMembers: (request?: Omit<QueryCallMembersRequest, "type" | "id">) => Promise<QueryCallMembersResponse>;
|
|
634
|
+
/**
|
|
635
|
+
* Query call participants with optional filters.
|
|
636
|
+
*
|
|
637
|
+
* @param data the request data.
|
|
638
|
+
* @param params optional query parameters.
|
|
639
|
+
*/
|
|
640
|
+
queryParticipants: (data?: QueryCallParticipantsRequest, params?: {
|
|
641
|
+
limit?: number;
|
|
642
|
+
}) => Promise<QueryCallParticipantsResponse>;
|
|
634
643
|
/**
|
|
635
644
|
* Will update the call members.
|
|
636
645
|
*
|
|
@@ -653,14 +662,45 @@ export declare class Call {
|
|
|
653
662
|
* Otherwise, all recordings for the current call will be returned.
|
|
654
663
|
*
|
|
655
664
|
* @param callSessionId the call session id to retrieve recordings for.
|
|
665
|
+
* @deprecated use {@link listRecordings} instead.
|
|
656
666
|
*/
|
|
657
667
|
queryRecordings: (callSessionId?: string) => Promise<ListRecordingsResponse>;
|
|
668
|
+
/**
|
|
669
|
+
* Retrieves the list of recordings for the current call or call session.
|
|
670
|
+
*
|
|
671
|
+
* If `callSessionId` is provided, it will return the recordings for that call session.
|
|
672
|
+
* Otherwise, all recordings for the current call will be returned.
|
|
673
|
+
*
|
|
674
|
+
* @param callSessionId the call session id to retrieve recordings for.
|
|
675
|
+
*/
|
|
676
|
+
listRecordings: (callSessionId?: string) => Promise<ListRecordingsResponse>;
|
|
677
|
+
/**
|
|
678
|
+
* Deletes a recording for the given call session.
|
|
679
|
+
*
|
|
680
|
+
* @param callSessionId the call session id that the recording belongs to.
|
|
681
|
+
* @param filename the recording filename.
|
|
682
|
+
*/
|
|
683
|
+
deleteRecording: (callSessionId: string, filename: string) => Promise<DeleteRecordingResponse>;
|
|
684
|
+
/**
|
|
685
|
+
* Deletes a transcription for the given call session.
|
|
686
|
+
*
|
|
687
|
+
* @param callSessionId the call session id that the transcription belongs to.
|
|
688
|
+
* @param filename the transcription filename.
|
|
689
|
+
*/
|
|
690
|
+
deleteTranscription: (callSessionId: string, filename: string) => Promise<DeleteTranscriptionResponse>;
|
|
658
691
|
/**
|
|
659
692
|
* Retrieves the list of transcriptions for the current call.
|
|
660
693
|
*
|
|
661
694
|
* @returns the list of transcriptions.
|
|
695
|
+
* @deprecated use {@link listTranscriptions} instead.
|
|
662
696
|
*/
|
|
663
697
|
queryTranscriptions: () => Promise<ListTranscriptionsResponse>;
|
|
698
|
+
/**
|
|
699
|
+
* Retrieves the list of transcriptions for the current call.
|
|
700
|
+
*
|
|
701
|
+
* @returns the list of transcriptions.
|
|
702
|
+
*/
|
|
703
|
+
listTranscriptions: () => Promise<ListTranscriptionsResponse>;
|
|
664
704
|
/**
|
|
665
705
|
* Retrieve call statistics for a particular call session (historical).
|
|
666
706
|
* Here `callSessionID` is mandatory.
|
|
@@ -10,6 +10,7 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
|
|
|
10
10
|
private speakingWhileMutedNotificationEnabled;
|
|
11
11
|
private soundDetectorConcurrencyTag;
|
|
12
12
|
private soundDetectorCleanup?;
|
|
13
|
+
private soundDetectorDeviceId?;
|
|
13
14
|
private noAudioDetectorCleanup?;
|
|
14
15
|
private rnSpeechDetector;
|
|
15
16
|
private noiseCancellation;
|
|
@@ -69,5 +70,6 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
|
|
|
69
70
|
protected doSetAudioBitrateProfile(profile: AudioBitrateProfile): void;
|
|
70
71
|
private startSpeakingWhileMutedDetection;
|
|
71
72
|
private stopSpeakingWhileMutedDetection;
|
|
73
|
+
private teardownSpeakingWhileMutedDetection;
|
|
72
74
|
private hasPermission;
|
|
73
75
|
}
|
|
@@ -4,12 +4,6 @@ export type NoAudioDetectorOptions = {
|
|
|
4
4
|
* Defaults to 350ms.
|
|
5
5
|
*/
|
|
6
6
|
detectionFrequencyInMs?: number;
|
|
7
|
-
/**
|
|
8
|
-
* Defines the audio level threshold. Values below this are considered no audio.
|
|
9
|
-
* Defaults to 0. This value should be in the range of 0-255.
|
|
10
|
-
* Only applies to browser implementation.
|
|
11
|
-
*/
|
|
12
|
-
audioLevelThreshold?: number;
|
|
13
7
|
/**
|
|
14
8
|
* Duration of continuous no-audio (in ms) before emitting the first event.
|
|
15
9
|
*/
|
|
@@ -23,7 +17,7 @@ export type NoAudioDetectorOptions = {
|
|
|
23
17
|
/**
|
|
24
18
|
* See https://developer.mozilla.org/en-US/docs/web/api/analysernode/fftsize
|
|
25
19
|
*
|
|
26
|
-
* Defaults to
|
|
20
|
+
* Defaults to 512.
|
|
27
21
|
* Only applies to browser implementation.
|
|
28
22
|
*/
|
|
29
23
|
fftSize?: number;
|
package/package.json
CHANGED
package/src/Call.ts
CHANGED
|
@@ -39,6 +39,8 @@ import type {
|
|
|
39
39
|
Credentials,
|
|
40
40
|
DeleteCallRequest,
|
|
41
41
|
DeleteCallResponse,
|
|
42
|
+
DeleteRecordingResponse,
|
|
43
|
+
DeleteTranscriptionResponse,
|
|
42
44
|
EndCallResponse,
|
|
43
45
|
GetCallReportResponse,
|
|
44
46
|
GetCallResponse,
|
|
@@ -59,6 +61,8 @@ import type {
|
|
|
59
61
|
PinResponse,
|
|
60
62
|
QueryCallMembersRequest,
|
|
61
63
|
QueryCallMembersResponse,
|
|
64
|
+
QueryCallParticipantsRequest,
|
|
65
|
+
QueryCallParticipantsResponse,
|
|
62
66
|
QueryCallSessionParticipantStatsResponse,
|
|
63
67
|
QueryCallSessionParticipantStatsTimelineResponse,
|
|
64
68
|
QueryCallStatsMapResponse,
|
|
@@ -1919,6 +1923,7 @@ export class Call {
|
|
|
1919
1923
|
'Updating publish options after joining the call does not have an effect',
|
|
1920
1924
|
);
|
|
1921
1925
|
}
|
|
1926
|
+
this.tracer.trace('updatePublishOptions', options);
|
|
1922
1927
|
this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
|
|
1923
1928
|
};
|
|
1924
1929
|
|
|
@@ -2494,6 +2499,22 @@ export class Call {
|
|
|
2494
2499
|
});
|
|
2495
2500
|
};
|
|
2496
2501
|
|
|
2502
|
+
/**
|
|
2503
|
+
* Query call participants with optional filters.
|
|
2504
|
+
*
|
|
2505
|
+
* @param data the request data.
|
|
2506
|
+
* @param params optional query parameters.
|
|
2507
|
+
*/
|
|
2508
|
+
queryParticipants = async (
|
|
2509
|
+
data: QueryCallParticipantsRequest = {},
|
|
2510
|
+
params: { limit?: number } = {},
|
|
2511
|
+
): Promise<QueryCallParticipantsResponse> => {
|
|
2512
|
+
return this.streamClient.post<
|
|
2513
|
+
QueryCallParticipantsResponse,
|
|
2514
|
+
QueryCallParticipantsRequest
|
|
2515
|
+
>(`${this.streamClientBasePath}/participants`, data, params);
|
|
2516
|
+
};
|
|
2517
|
+
|
|
2497
2518
|
/**
|
|
2498
2519
|
* Will update the call members.
|
|
2499
2520
|
*
|
|
@@ -2555,9 +2576,24 @@ export class Call {
|
|
|
2555
2576
|
* Otherwise, all recordings for the current call will be returned.
|
|
2556
2577
|
*
|
|
2557
2578
|
* @param callSessionId the call session id to retrieve recordings for.
|
|
2579
|
+
* @deprecated use {@link listRecordings} instead.
|
|
2558
2580
|
*/
|
|
2559
2581
|
queryRecordings = async (
|
|
2560
2582
|
callSessionId?: string,
|
|
2583
|
+
): Promise<ListRecordingsResponse> => {
|
|
2584
|
+
return this.listRecordings(callSessionId);
|
|
2585
|
+
};
|
|
2586
|
+
|
|
2587
|
+
/**
|
|
2588
|
+
* Retrieves the list of recordings for the current call or call session.
|
|
2589
|
+
*
|
|
2590
|
+
* If `callSessionId` is provided, it will return the recordings for that call session.
|
|
2591
|
+
* Otherwise, all recordings for the current call will be returned.
|
|
2592
|
+
*
|
|
2593
|
+
* @param callSessionId the call session id to retrieve recordings for.
|
|
2594
|
+
*/
|
|
2595
|
+
listRecordings = async (
|
|
2596
|
+
callSessionId?: string,
|
|
2561
2597
|
): Promise<ListRecordingsResponse> => {
|
|
2562
2598
|
let endpoint = this.streamClientBasePath;
|
|
2563
2599
|
if (callSessionId) {
|
|
@@ -2568,12 +2604,52 @@ export class Call {
|
|
|
2568
2604
|
);
|
|
2569
2605
|
};
|
|
2570
2606
|
|
|
2607
|
+
/**
|
|
2608
|
+
* Deletes a recording for the given call session.
|
|
2609
|
+
*
|
|
2610
|
+
* @param callSessionId the call session id that the recording belongs to.
|
|
2611
|
+
* @param filename the recording filename.
|
|
2612
|
+
*/
|
|
2613
|
+
deleteRecording = async (
|
|
2614
|
+
callSessionId: string,
|
|
2615
|
+
filename: string,
|
|
2616
|
+
): Promise<DeleteRecordingResponse> => {
|
|
2617
|
+
return this.streamClient.delete<DeleteRecordingResponse>(
|
|
2618
|
+
`${this.streamClientBasePath}/${encodeURIComponent(callSessionId)}/recordings/${encodeURIComponent(filename)}`,
|
|
2619
|
+
);
|
|
2620
|
+
};
|
|
2621
|
+
|
|
2622
|
+
/**
|
|
2623
|
+
* Deletes a transcription for the given call session.
|
|
2624
|
+
*
|
|
2625
|
+
* @param callSessionId the call session id that the transcription belongs to.
|
|
2626
|
+
* @param filename the transcription filename.
|
|
2627
|
+
*/
|
|
2628
|
+
deleteTranscription = async (
|
|
2629
|
+
callSessionId: string,
|
|
2630
|
+
filename: string,
|
|
2631
|
+
): Promise<DeleteTranscriptionResponse> => {
|
|
2632
|
+
return this.streamClient.delete<DeleteTranscriptionResponse>(
|
|
2633
|
+
`${this.streamClientBasePath}/${encodeURIComponent(callSessionId)}/transcriptions/${encodeURIComponent(filename)}`,
|
|
2634
|
+
);
|
|
2635
|
+
};
|
|
2636
|
+
|
|
2571
2637
|
/**
|
|
2572
2638
|
* Retrieves the list of transcriptions for the current call.
|
|
2573
2639
|
*
|
|
2574
2640
|
* @returns the list of transcriptions.
|
|
2641
|
+
* @deprecated use {@link listTranscriptions} instead.
|
|
2575
2642
|
*/
|
|
2576
2643
|
queryTranscriptions = async (): Promise<ListTranscriptionsResponse> => {
|
|
2644
|
+
return this.listTranscriptions();
|
|
2645
|
+
};
|
|
2646
|
+
|
|
2647
|
+
/**
|
|
2648
|
+
* Retrieves the list of transcriptions for the current call.
|
|
2649
|
+
*
|
|
2650
|
+
* @returns the list of transcriptions.
|
|
2651
|
+
*/
|
|
2652
|
+
listTranscriptions = async (): Promise<ListTranscriptionsResponse> => {
|
|
2577
2653
|
return this.streamClient.get<ListTranscriptionsResponse>(
|
|
2578
2654
|
`${this.streamClientBasePath}/transcriptions`,
|
|
2579
2655
|
);
|
|
@@ -34,6 +34,7 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
34
34
|
private speakingWhileMutedNotificationEnabled = true;
|
|
35
35
|
private soundDetectorConcurrencyTag = Symbol('soundDetectorConcurrencyTag');
|
|
36
36
|
private soundDetectorCleanup?: () => Promise<void>;
|
|
37
|
+
private soundDetectorDeviceId?: string;
|
|
37
38
|
private noAudioDetectorCleanup?: () => Promise<void>;
|
|
38
39
|
private rnSpeechDetector: RNSpeechDetector | undefined;
|
|
39
40
|
private noiseCancellation: INoiseCancellation | undefined;
|
|
@@ -385,6 +386,11 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
385
386
|
|
|
386
387
|
private async startSpeakingWhileMutedDetection(deviceId?: string) {
|
|
387
388
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
389
|
+
if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
|
|
390
|
+
return;
|
|
391
|
+
|
|
392
|
+
await this.teardownSpeakingWhileMutedDetection();
|
|
393
|
+
|
|
388
394
|
if (isReactNative()) {
|
|
389
395
|
this.rnSpeechDetector = new RNSpeechDetector();
|
|
390
396
|
const unsubscribe = await this.rnSpeechDetector.start((event) => {
|
|
@@ -404,16 +410,26 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
404
410
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
405
411
|
});
|
|
406
412
|
}
|
|
413
|
+
|
|
414
|
+
this.soundDetectorDeviceId = deviceId;
|
|
407
415
|
});
|
|
408
416
|
}
|
|
409
417
|
|
|
410
418
|
private async stopSpeakingWhileMutedDetection() {
|
|
411
419
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
420
|
+
return this.teardownSpeakingWhileMutedDetection();
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private async teardownSpeakingWhileMutedDetection(): Promise<void> {
|
|
425
|
+
const soundDetectorCleanup = this.soundDetectorCleanup;
|
|
426
|
+
this.soundDetectorCleanup = undefined;
|
|
427
|
+
this.soundDetectorDeviceId = undefined;
|
|
428
|
+
this.state.setSpeakingWhileMuted(false);
|
|
429
|
+
if (!soundDetectorCleanup) return;
|
|
430
|
+
|
|
431
|
+
await soundDetectorCleanup().catch((err) => {
|
|
432
|
+
this.logger.warn('Failed to stop speaking while muted detector', err);
|
|
417
433
|
});
|
|
418
434
|
}
|
|
419
435
|
|
|
@@ -222,6 +222,38 @@ describe('MicrophoneManager', () => {
|
|
|
222
222
|
}
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
it('should not create duplicate sound detectors for the same device', async () => {
|
|
226
|
+
const detectorMock = vi.mocked(createSoundDetector);
|
|
227
|
+
const cleanup = vi.fn(async () => {});
|
|
228
|
+
detectorMock.mockImplementationOnce(() => cleanup);
|
|
229
|
+
|
|
230
|
+
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
231
|
+
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
232
|
+
|
|
233
|
+
expect(detectorMock).toHaveBeenCalledTimes(1);
|
|
234
|
+
|
|
235
|
+
await manager['stopSpeakingWhileMutedDetection']();
|
|
236
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should cleanup previous detector before starting a new device detector', async () => {
|
|
240
|
+
const detectorMock = vi.mocked(createSoundDetector);
|
|
241
|
+
const cleanupFirst = vi.fn(async () => {});
|
|
242
|
+
const cleanupSecond = vi.fn(async () => {});
|
|
243
|
+
detectorMock
|
|
244
|
+
.mockImplementationOnce(() => cleanupFirst)
|
|
245
|
+
.mockImplementationOnce(() => cleanupSecond);
|
|
246
|
+
|
|
247
|
+
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
248
|
+
await manager['startSpeakingWhileMutedDetection']('device-2');
|
|
249
|
+
|
|
250
|
+
expect(detectorMock).toHaveBeenCalledTimes(2);
|
|
251
|
+
expect(cleanupFirst).toHaveBeenCalledTimes(1);
|
|
252
|
+
|
|
253
|
+
await manager['stopSpeakingWhileMutedDetection']();
|
|
254
|
+
expect(cleanupSecond).toHaveBeenCalledTimes(1);
|
|
255
|
+
});
|
|
256
|
+
|
|
225
257
|
// --- this ---
|
|
226
258
|
it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
|
|
227
259
|
await manager.enable();
|
|
@@ -530,6 +562,26 @@ describe('MicrophoneManager', () => {
|
|
|
530
562
|
});
|
|
531
563
|
});
|
|
532
564
|
|
|
565
|
+
describe('no-audio detector configuration', () => {
|
|
566
|
+
it('applies silence threshold and emit interval in runtime monitoring', async () => {
|
|
567
|
+
const noAudioDetector = vi.mocked(createNoAudioDetector);
|
|
568
|
+
|
|
569
|
+
manager.setSilenceThreshold(3000);
|
|
570
|
+
manager['call'].state.setCallingState(CallingState.JOINED);
|
|
571
|
+
await manager.enable();
|
|
572
|
+
|
|
573
|
+
await vi.waitFor(() => {
|
|
574
|
+
expect(noAudioDetector).toHaveBeenCalled();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const options = noAudioDetector.mock.calls.at(-1)?.[1];
|
|
578
|
+
expect(options).toMatchObject({
|
|
579
|
+
noAudioThresholdMs: 3000,
|
|
580
|
+
emitIntervalMs: 3000,
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
533
585
|
afterEach(() => {
|
|
534
586
|
vi.restoreAllMocks();
|
|
535
587
|
vi.clearAllMocks();
|
|
@@ -16,6 +16,7 @@ import { SoundStateChangeHandler } from '../../helpers/sound-detector';
|
|
|
16
16
|
import { settled, withoutConcurrency } from '../../helpers/concurrency';
|
|
17
17
|
|
|
18
18
|
let handler: SoundStateChangeHandler = () => {};
|
|
19
|
+
let unsubscribeHandlers: ReturnType<typeof vi.fn>[] = [];
|
|
19
20
|
|
|
20
21
|
vi.mock('../../helpers/platforms.ts', () => {
|
|
21
22
|
return {
|
|
@@ -51,7 +52,9 @@ vi.mock('../../helpers/RNSpeechDetector.ts', () => {
|
|
|
51
52
|
RNSpeechDetector: vi.fn().mockImplementation(() => ({
|
|
52
53
|
start: vi.fn((callback) => {
|
|
53
54
|
handler = callback;
|
|
54
|
-
|
|
55
|
+
const unsubscribe = vi.fn();
|
|
56
|
+
unsubscribeHandlers.push(unsubscribe);
|
|
57
|
+
return unsubscribe;
|
|
55
58
|
}),
|
|
56
59
|
stop: vi.fn(),
|
|
57
60
|
onSpeakingDetectedStateChange: vi.fn(),
|
|
@@ -63,6 +66,7 @@ describe('MicrophoneManager React Native', () => {
|
|
|
63
66
|
let manager: MicrophoneManager;
|
|
64
67
|
let checkPermissionMock: ReturnType<typeof vi.fn>;
|
|
65
68
|
beforeEach(() => {
|
|
69
|
+
unsubscribeHandlers = [];
|
|
66
70
|
checkPermissionMock = vi.fn(async () => true);
|
|
67
71
|
|
|
68
72
|
globalThis.streamRNVideoSDK = {
|
|
@@ -153,6 +157,27 @@ describe('MicrophoneManager React Native', () => {
|
|
|
153
157
|
expect(manager.state.speakingWhileMuted).toBe(false);
|
|
154
158
|
});
|
|
155
159
|
|
|
160
|
+
it('should not create duplicate speech detectors for the same device', async () => {
|
|
161
|
+
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
162
|
+
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
163
|
+
|
|
164
|
+
expect(unsubscribeHandlers).toHaveLength(1);
|
|
165
|
+
|
|
166
|
+
await manager['stopSpeakingWhileMutedDetection']();
|
|
167
|
+
expect(unsubscribeHandlers[0]).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should cleanup previous speech detector before starting a new one', async () => {
|
|
171
|
+
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
172
|
+
await manager['startSpeakingWhileMutedDetection']('device-2');
|
|
173
|
+
|
|
174
|
+
expect(unsubscribeHandlers).toHaveLength(2);
|
|
175
|
+
expect(unsubscribeHandlers[0]).toHaveBeenCalledTimes(1);
|
|
176
|
+
|
|
177
|
+
await manager['stopSpeakingWhileMutedDetection']();
|
|
178
|
+
expect(unsubscribeHandlers[1]).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
156
181
|
it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
|
|
157
182
|
await manager.enable();
|
|
158
183
|
await manager.disable();
|
|
@@ -18,8 +18,12 @@ export const createMockAnalyserNode = (
|
|
|
18
18
|
get frequencyBinCount() {
|
|
19
19
|
return fftSize / 2;
|
|
20
20
|
},
|
|
21
|
-
// Default implementation fills array with
|
|
22
|
-
// Tests can override with mockImplementation to simulate different audio levels
|
|
21
|
+
// Default implementation fills array with midpoint (silence waveform)
|
|
22
|
+
// Tests can override with mockImplementation to simulate different audio levels.
|
|
23
|
+
getByteTimeDomainData: vi.fn((array: Uint8Array) => {
|
|
24
|
+
array.fill(128);
|
|
25
|
+
}),
|
|
26
|
+
// Keep frequency-domain API for other helpers that use it.
|
|
23
27
|
getByteFrequencyData: vi.fn((array: Uint8Array) => {
|
|
24
28
|
array.fill(0);
|
|
25
29
|
}),
|
|
@@ -12,7 +12,7 @@ describe('no-audio-detector (browser)', () => {
|
|
|
12
12
|
let mockAudioContext: ReturnType<typeof setupAudioContextMock>;
|
|
13
13
|
let audioStream: MediaStream;
|
|
14
14
|
type MockAnalyserNode = ReturnType<typeof createMockAnalyserNode> & {
|
|
15
|
-
|
|
15
|
+
getByteTimeDomainData: ReturnType<typeof vi.fn>;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
const getAnalyserNode = () => {
|
|
@@ -30,7 +30,6 @@ describe('no-audio-detector (browser)', () => {
|
|
|
30
30
|
noAudioThresholdMs: 5000,
|
|
31
31
|
emitIntervalMs: 5000,
|
|
32
32
|
detectionFrequencyInMs: 500,
|
|
33
|
-
audioLevelThreshold: 1, // Use threshold of 1 so level 0 is detected as "no audio"
|
|
34
33
|
...overrides,
|
|
35
34
|
});
|
|
36
35
|
|
|
@@ -38,9 +37,19 @@ describe('no-audio-detector (browser)', () => {
|
|
|
38
37
|
};
|
|
39
38
|
|
|
40
39
|
const setAudioLevel = (analyserNode: MockAnalyserNode, level: number) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
const amplitude = Math.min(Math.max(level, 0), 127);
|
|
41
|
+
vi.mocked(analyserNode.getByteTimeDomainData).mockImplementation(
|
|
42
|
+
(array) => {
|
|
43
|
+
if (amplitude === 0) {
|
|
44
|
+
array.fill(128);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < array.length; i++) {
|
|
49
|
+
array[i] = i % 2 === 0 ? 128 + amplitude : 128 - amplitude;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
);
|
|
44
53
|
};
|
|
45
54
|
|
|
46
55
|
beforeEach(() => {
|
|
@@ -92,6 +101,16 @@ describe('no-audio-detector (browser)', () => {
|
|
|
92
101
|
expect(onCaptureStatusChange).toHaveBeenCalledWith(false);
|
|
93
102
|
});
|
|
94
103
|
|
|
104
|
+
it('should treat tiny 127/128 jitter as no audio', () => {
|
|
105
|
+
const { onCaptureStatusChange, analyserNode } = createDetector();
|
|
106
|
+
setAudioLevel(analyserNode, 1);
|
|
107
|
+
|
|
108
|
+
vi.advanceTimersByTime(5500);
|
|
109
|
+
|
|
110
|
+
expect(onCaptureStatusChange).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(onCaptureStatusChange).toHaveBeenLastCalledWith(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
95
114
|
it('should respect custom emit interval', () => {
|
|
96
115
|
const { onCaptureStatusChange, analyserNode } = createDetector({
|
|
97
116
|
noAudioThresholdMs: 3000,
|
|
@@ -114,6 +133,27 @@ describe('no-audio-detector (browser)', () => {
|
|
|
114
133
|
});
|
|
115
134
|
|
|
116
135
|
describe('audio detection', () => {
|
|
136
|
+
it('should stop and emit audio detected when sound appears before threshold', () => {
|
|
137
|
+
const { onCaptureStatusChange, analyserNode } = createDetector();
|
|
138
|
+
setAudioLevel(analyserNode, 0);
|
|
139
|
+
|
|
140
|
+
// Start in no-audio detecting mode but stay below threshold.
|
|
141
|
+
vi.advanceTimersByTime(3000);
|
|
142
|
+
expect(onCaptureStatusChange).not.toHaveBeenCalled();
|
|
143
|
+
|
|
144
|
+
// Audio appears before no-audio threshold is reached.
|
|
145
|
+
setAudioLevel(analyserNode, 10);
|
|
146
|
+
vi.advanceTimersByTime(500);
|
|
147
|
+
|
|
148
|
+
expect(onCaptureStatusChange).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(onCaptureStatusChange).toHaveBeenLastCalledWith(true);
|
|
150
|
+
|
|
151
|
+
// Detector should be stopped.
|
|
152
|
+
onCaptureStatusChange.mockClear();
|
|
153
|
+
vi.advanceTimersByTime(10000);
|
|
154
|
+
expect(onCaptureStatusChange).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
117
157
|
it('should stop checking after audio is detected', () => {
|
|
118
158
|
const { onCaptureStatusChange, analyserNode } = createDetector();
|
|
119
159
|
setAudioLevel(analyserNode, 0);
|
|
@@ -134,22 +174,6 @@ describe('no-audio-detector (browser)', () => {
|
|
|
134
174
|
vi.advanceTimersByTime(10000);
|
|
135
175
|
expect(onCaptureStatusChange).not.toHaveBeenCalled();
|
|
136
176
|
});
|
|
137
|
-
|
|
138
|
-
it('should respect custom audio level threshold', () => {
|
|
139
|
-
const { onCaptureStatusChange, analyserNode } = createDetector({
|
|
140
|
-
audioLevelThreshold: 20, // Custom threshold
|
|
141
|
-
});
|
|
142
|
-
setAudioLevel(analyserNode, 15);
|
|
143
|
-
|
|
144
|
-
// Should detect as no audio since 15 < 20
|
|
145
|
-
vi.advanceTimersByTime(5500);
|
|
146
|
-
expect(onCaptureStatusChange).toHaveBeenCalledWith(false);
|
|
147
|
-
|
|
148
|
-
setAudioLevel(analyserNode, 25);
|
|
149
|
-
vi.advanceTimersByTime(500);
|
|
150
|
-
|
|
151
|
-
expect(onCaptureStatusChange).toHaveBeenCalledWith(true);
|
|
152
|
-
});
|
|
153
177
|
});
|
|
154
178
|
|
|
155
179
|
describe('track state handling', () => {
|
|
@@ -176,7 +200,7 @@ describe('no-audio-detector (browser)', () => {
|
|
|
176
200
|
expect(onCaptureStatusChange).toHaveBeenCalledTimes(1);
|
|
177
201
|
});
|
|
178
202
|
|
|
179
|
-
it('should
|
|
203
|
+
it('should emit no-audio when track ends', () => {
|
|
180
204
|
const [track] = audioStream.getAudioTracks() as Array<
|
|
181
205
|
Omit<MediaStreamTrack, 'readyState'> & { readyState: string }
|
|
182
206
|
>;
|
|
@@ -191,9 +215,10 @@ describe('no-audio-detector (browser)', () => {
|
|
|
191
215
|
// Advance detection cycle
|
|
192
216
|
vi.advanceTimersByTime(500);
|
|
193
217
|
|
|
194
|
-
//
|
|
218
|
+
// Ended track should be treated as no-audio and eventually emit.
|
|
195
219
|
vi.advanceTimersByTime(5000);
|
|
196
|
-
expect(onCaptureStatusChange).
|
|
220
|
+
expect(onCaptureStatusChange).toHaveBeenCalledTimes(1);
|
|
221
|
+
expect(onCaptureStatusChange).toHaveBeenCalledWith(false);
|
|
197
222
|
});
|
|
198
223
|
});
|
|
199
224
|
|
|
@@ -247,7 +272,7 @@ describe('no-audio-detector (browser)', () => {
|
|
|
247
272
|
});
|
|
248
273
|
|
|
249
274
|
describe('edge cases', () => {
|
|
250
|
-
it('should
|
|
275
|
+
it('should emit no-audio when stream has no audio tracks', async () => {
|
|
251
276
|
const onCaptureStatusChange = vi.fn();
|
|
252
277
|
const emptyStream = {
|
|
253
278
|
getAudioTracks: () => [],
|
|
@@ -260,12 +285,13 @@ describe('no-audio-detector (browser)', () => {
|
|
|
260
285
|
detectionFrequencyInMs: 500,
|
|
261
286
|
});
|
|
262
287
|
|
|
263
|
-
//
|
|
288
|
+
// Missing track should be treated as no-audio and eventually emit.
|
|
264
289
|
vi.advanceTimersByTime(10000);
|
|
265
|
-
expect(onCaptureStatusChange).
|
|
290
|
+
expect(onCaptureStatusChange).toHaveBeenCalledTimes(1);
|
|
291
|
+
expect(onCaptureStatusChange).toHaveBeenCalledWith(false);
|
|
266
292
|
|
|
267
293
|
// Cleanup should work
|
|
268
|
-
expect(
|
|
294
|
+
await expect(stop()).resolves.toBeUndefined();
|
|
269
295
|
});
|
|
270
296
|
});
|
|
271
297
|
});
|
|
@@ -6,12 +6,6 @@ export type NoAudioDetectorOptions = {
|
|
|
6
6
|
* Defaults to 350ms.
|
|
7
7
|
*/
|
|
8
8
|
detectionFrequencyInMs?: number;
|
|
9
|
-
/**
|
|
10
|
-
* Defines the audio level threshold. Values below this are considered no audio.
|
|
11
|
-
* Defaults to 0. This value should be in the range of 0-255.
|
|
12
|
-
* Only applies to browser implementation.
|
|
13
|
-
*/
|
|
14
|
-
audioLevelThreshold?: number;
|
|
15
9
|
/**
|
|
16
10
|
* Duration of continuous no-audio (in ms) before emitting the first event.
|
|
17
11
|
*/
|
|
@@ -25,7 +19,7 @@ export type NoAudioDetectorOptions = {
|
|
|
25
19
|
/**
|
|
26
20
|
* See https://developer.mozilla.org/en-US/docs/web/api/analysernode/fftsize
|
|
27
21
|
*
|
|
28
|
-
* Defaults to
|
|
22
|
+
* Defaults to 512.
|
|
29
23
|
* Only applies to browser implementation.
|
|
30
24
|
*/
|
|
31
25
|
fftSize?: number;
|
|
@@ -52,12 +46,23 @@ type StateTransition =
|
|
|
52
46
|
| { shouldEmit: true; nextState: DetectorState; capturesAudio: boolean };
|
|
53
47
|
|
|
54
48
|
/**
|
|
55
|
-
* Analyzes
|
|
49
|
+
* Analyzes time-domain waveform data to determine if audio is being captured.
|
|
50
|
+
* Uses the waveform RMS around the 128 midpoint for robust silence detection.
|
|
56
51
|
*/
|
|
57
|
-
const hasAudio = (analyser: AnalyserNode
|
|
58
|
-
const data = new Uint8Array(analyser.
|
|
59
|
-
analyser.
|
|
60
|
-
|
|
52
|
+
const hasAudio = (analyser: AnalyserNode): boolean => {
|
|
53
|
+
const data = new Uint8Array(analyser.fftSize);
|
|
54
|
+
analyser.getByteTimeDomainData(data);
|
|
55
|
+
|
|
56
|
+
let squareSum = 0;
|
|
57
|
+
for (const sample of data) {
|
|
58
|
+
const centered = sample - 128;
|
|
59
|
+
// Ignore tiny quantization/jitter around midpoint (e.g. 127/128 samples).
|
|
60
|
+
const signal = Math.abs(centered) <= 1 ? 0 : centered;
|
|
61
|
+
squareSum += signal * signal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rms = Math.sqrt(squareSum / data.length);
|
|
65
|
+
return rms > 0;
|
|
61
66
|
};
|
|
62
67
|
|
|
63
68
|
/** Helper for "no event" transitions */
|
|
@@ -81,9 +86,9 @@ const transitionState = (
|
|
|
81
86
|
options: NoAudioDetectorOptions,
|
|
82
87
|
): StateTransition => {
|
|
83
88
|
if (audioDetected) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
// Any observed audio means the microphone is capturing.
|
|
90
|
+
// Emit recovery/success and let the caller stop the detector.
|
|
91
|
+
return emit(true, { kind: 'IDLE' });
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
const { noAudioThresholdMs, emitIntervalMs } = options;
|
|
@@ -137,21 +142,21 @@ export const createNoAudioDetector = (
|
|
|
137
142
|
) => {
|
|
138
143
|
const {
|
|
139
144
|
detectionFrequencyInMs = 350,
|
|
140
|
-
|
|
141
|
-
fftSize = 256,
|
|
145
|
+
fftSize = 512,
|
|
142
146
|
onCaptureStatusChange,
|
|
143
147
|
} = options;
|
|
144
148
|
|
|
145
149
|
let state: DetectorState = { kind: 'IDLE' };
|
|
146
150
|
const { audioContext, analyser } = createAudioAnalyzer(audioStream, fftSize);
|
|
147
151
|
const detectionIntervalId = setInterval(() => {
|
|
148
|
-
const [
|
|
149
|
-
if (
|
|
152
|
+
const [track] = audioStream.getAudioTracks();
|
|
153
|
+
if (track && !track.enabled) {
|
|
150
154
|
state = { kind: 'IDLE' };
|
|
151
155
|
return;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
|
|
158
|
+
// Missing or ended track is treated as no-audio to surface abrupt capture loss.
|
|
159
|
+
const audioDetected = track?.readyState === 'live' && hasAudio(analyser);
|
|
155
160
|
const transition = transitionState(state, audioDetected, options);
|
|
156
161
|
|
|
157
162
|
state = transition.nextState;
|