Skip to content

Commit a031a9e

Browse files
committed
fix(client): prevent stale speaking-while-muted detector
1 parent 695e69c commit a031a9e

File tree

3 files changed

+79
-6
lines changed

3 files changed

+79
-6
lines changed

packages/client/src/devices/MicrophoneManager.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
3434
private speakingWhileMutedNotificationEnabled = true;
3535
private soundDetectorConcurrencyTag = Symbol('soundDetectorConcurrencyTag');
3636
private soundDetectorCleanup?: () => Promise<void>;
37+
private soundDetectorDeviceId?: string;
3738
private noAudioDetectorCleanup?: () => Promise<void>;
3839
private rnSpeechDetector: RNSpeechDetector | undefined;
3940
private noiseCancellation: INoiseCancellation | undefined;
@@ -385,6 +386,11 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
385386

386387
private async startSpeakingWhileMutedDetection(deviceId?: string) {
387388
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
389+
if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
390+
return;
391+
392+
await this.teardownSpeakingWhileMutedDetection();
393+
388394
if (isReactNative()) {
389395
this.rnSpeechDetector = new RNSpeechDetector();
390396
const unsubscribe = await this.rnSpeechDetector.start((event) => {
@@ -404,16 +410,26 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
404410
this.state.setSpeakingWhileMuted(event.isSoundDetected);
405411
});
406412
}
413+
414+
this.soundDetectorDeviceId = deviceId;
407415
});
408416
}
409417

410418
private async stopSpeakingWhileMutedDetection() {
411419
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);
417433
});
418434
}
419435

packages/client/src/devices/__tests__/MicrophoneManager.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,38 @@ describe('MicrophoneManager', () => {
222222
}
223223
});
224224

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+
225257
// --- this ---
226258
it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
227259
await manager.enable();

packages/client/src/devices/__tests__/MicrophoneManagerRN.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { SoundStateChangeHandler } from '../../helpers/sound-detector';
1616
import { settled, withoutConcurrency } from '../../helpers/concurrency';
1717

1818
let handler: SoundStateChangeHandler = () => {};
19+
let unsubscribeHandlers: ReturnType<typeof vi.fn>[] = [];
1920

2021
vi.mock('../../helpers/platforms.ts', () => {
2122
return {
@@ -51,7 +52,9 @@ vi.mock('../../helpers/RNSpeechDetector.ts', () => {
5152
RNSpeechDetector: vi.fn().mockImplementation(() => ({
5253
start: vi.fn((callback) => {
5354
handler = callback;
54-
return vi.fn();
55+
const unsubscribe = vi.fn();
56+
unsubscribeHandlers.push(unsubscribe);
57+
return unsubscribe;
5558
}),
5659
stop: vi.fn(),
5760
onSpeakingDetectedStateChange: vi.fn(),
@@ -63,6 +66,7 @@ describe('MicrophoneManager React Native', () => {
6366
let manager: MicrophoneManager;
6467
let checkPermissionMock: ReturnType<typeof vi.fn>;
6568
beforeEach(() => {
69+
unsubscribeHandlers = [];
6670
checkPermissionMock = vi.fn(async () => true);
6771

6872
globalThis.streamRNVideoSDK = {
@@ -153,6 +157,27 @@ describe('MicrophoneManager React Native', () => {
153157
expect(manager.state.speakingWhileMuted).toBe(false);
154158
});
155159

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+
156181
it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
157182
await manager.enable();
158183
await manager.disable();

0 commit comments

Comments
 (0)