@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.
@@ -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 256.
20
+ * Defaults to 512.
27
21
  * Only applies to browser implementation.
28
22
  */
29
23
  fftSize?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.42.3",
3
+ "version": "1.43.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
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
- if (!this.soundDetectorCleanup) return;
413
- const soundDetectorCleanup = this.soundDetectorCleanup;
414
- this.soundDetectorCleanup = undefined;
415
- this.state.setSpeakingWhileMuted(false);
416
- await soundDetectorCleanup();
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
- return vi.fn();
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 zeros
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
- getByteFrequencyData: ReturnType<typeof vi.fn>;
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
- vi.mocked(analyserNode.getByteFrequencyData).mockImplementation((array) => {
42
- array.fill(level);
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 reset state when track ends', () => {
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
- // Should not emit (track ended)
218
+ // Ended track should be treated as no-audio and eventually emit.
195
219
  vi.advanceTimersByTime(5000);
196
- expect(onCaptureStatusChange).not.toHaveBeenCalled();
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 handle empty audio tracks array', () => {
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
- // Should not crash or emit events
288
+ // Missing track should be treated as no-audio and eventually emit.
264
289
  vi.advanceTimersByTime(10000);
265
- expect(onCaptureStatusChange).not.toHaveBeenCalled();
290
+ expect(onCaptureStatusChange).toHaveBeenCalledTimes(1);
291
+ expect(onCaptureStatusChange).toHaveBeenCalledWith(false);
266
292
 
267
293
  // Cleanup should work
268
- expect(() => stop()).not.toThrow();
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 256.
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 frequency data to determine if audio is being captured.
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, threshold: number): boolean => {
58
- const data = new Uint8Array(analyser.frequencyBinCount);
59
- analyser.getByteFrequencyData(data);
60
- return data.some((value) => value > threshold);
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
- return state.kind === 'IDLE' || state.kind === 'EMITTING'
85
- ? emit(true, state)
86
- : noEmit(state);
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
- audioLevelThreshold = 0,
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 [audioTrack] = audioStream.getAudioTracks();
149
- if (!audioTrack?.enabled || audioTrack.readyState === 'ended') {
152
+ const [track] = audioStream.getAudioTracks();
153
+ if (track && !track.enabled) {
150
154
  state = { kind: 'IDLE' };
151
155
  return;
152
156
  }
153
157
 
154
- const audioDetected = hasAudio(analyser, audioLevelThreshold);
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;