import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { fromPartial } from '@total-typescript/shoehorn'; import { NoiseCancellationStub } from './NoiseCancellationStub'; import { Call } from '../../Call'; import { StreamClient } from '../../coordinator/connection/client'; import { sleep } from '../../coordinator/connection/utils'; import { NoiseCancellationSettingsModeEnum, OwnCapability, } from '../../gen/coordinator'; import { AudioBitrateProfile, TrackType, } from '../../gen/video/sfu/models/models'; import { CallingState, StreamVideoWriteableStateStore } from '../../store'; import { createLocalStorageMock, emitDeviceIds, mockAudioDevices, mockAudioStream, mockBrowserPermission, mockCall, mockDeviceIds$, } from './mocks'; import { createAudioStreamForDevice } from './mediaStreamTestHelpers'; import { setupAudioContextMock } from './web-audio.mocks'; import { getAudioStream } from '../devices'; import { MicrophoneManager } from '../MicrophoneManager'; import { of } from 'rxjs'; import { createSoundDetector, SoundStateChangeHandler, } from '../../helpers/sound-detector'; import { createNoAudioDetector, NoAudioDetectorOptions, } from '../../helpers/no-audio-detector'; import { PermissionsContext } from '../../permissions'; import { Tracer } from '../../stats'; import { settled, withoutConcurrency } from '../../helpers/concurrency'; import { defaultDeviceId, readPreferences, toPreferenceList, } from '../devicePersistence'; vi.mock('../devices.ts', () => { console.log('MOCKING devices API'); return { disposeOfMediaStream: vi.fn(), getAudioDevices: vi.fn(() => { return of(mockAudioDevices); }), getAudioStream: vi.fn(() => Promise.resolve(mockAudioStream())), getAudioBrowserPermission: () => mockBrowserPermission, getVideoBrowserPermission: () => mockBrowserPermission, deviceIds$: mockDeviceIds$(), resolveDeviceId: (deviceId) => deviceId, }; }); vi.mock('../../helpers/sound-detector.ts', () => { console.log('MOCKING sound detector'); return { createSoundDetector: vi.fn(() => () => {}), }; }); vi.mock('../../helpers/no-audio-detector.ts', () => { console.log('MOCKING no-audio detector'); return { createNoAudioDetector: vi.fn(() => async () => {}), }; }); vi.mock('../../Call.ts', () => { console.log('MOCKING Call'); return { Call: vi.fn(() => mockCall()), }; }); describe('MicrophoneManager', () => { let manager: MicrophoneManager; let call: Call; beforeEach(() => { setupAudioContextMock(); vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( of('granted'), ); call = new Call({ id: '', type: '', streamClient: new StreamClient('abc123'), clientStore: new StreamVideoWriteableStateStore(), }); const devicePersistence = { enabled: false, storageKey: '' }; manager = new MicrophoneManager(call, devicePersistence, 'disable-tracks'); }); it('list devices', () => { const spy = vi.fn(); manager.listDevices().subscribe(spy); expect(spy).toHaveBeenCalledWith(mockAudioDevices); }); it('get stream', async () => { await manager.enable(); expect(getAudioStream).toHaveBeenCalledWith( { deviceId: undefined }, expect.any(Tracer), ); }); it('should get device id from stream', async () => { expect(manager.state.selectedDevice).toBeUndefined(); await manager.enable(); expect(manager.state.selectedDevice).toBeDefined(); }); it('publish stream', async () => { manager['call'].state.setCallingState(CallingState.JOINED); await manager.enable(); expect(manager['call'].publish).toHaveBeenCalledWith( manager.state.mediaStream, TrackType.AUDIO, { audioBitrateProfile: AudioBitrateProfile.VOICE_STANDARD_UNSPECIFIED }, ); }); it('stop publish stream', async () => { manager['call'].state.setCallingState(CallingState.JOINED); await manager.enable(); await manager.disable(); expect(manager['call'].stopPublish).toHaveBeenCalledWith(TrackType.AUDIO); }); it('disable-enable mic should set track.enabled', async () => { await manager.enable(); expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(true); await manager.disable(); expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(false); }); it('disable mic with forceStop should remove the stream', async () => { await manager.enable(); expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(true); await manager.disable(); expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(false); await manager.disable(true); expect(manager.state.mediaStream).toBeUndefined(); }); describe('Speaking While Muted', () => { it(`should start sound detection if mic is disabled`, async () => { await manager.enable(); // @ts-expect-error private api const fn = vi.spyOn(manager, 'startSpeakingWhileMutedDetection'); await manager.disable(); await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 }); expect(fn).toHaveBeenCalled(); }); it('should not start sound detection if browser mic permission is denied', async () => { vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( of('denied'), ); const devicePersistence = { enabled: false, storageKey: '' }; const innerManager = new MicrophoneManager( call, devicePersistence, 'disable-tracks', ); // @ts-expect-error private api const fn = vi.spyOn(innerManager, 'startSpeakingWhileMutedDetection'); await innerManager.enable(); await sleep(25); expect(fn).not.toHaveBeenCalled(); }); it(`should stop sound detection if mic is enabled`, async () => { manager.state.setSpeakingWhileMuted(true); manager['soundDetectorCleanup'] = async () => {}; await manager.enable(); // @ts-expect-error private field const syncTag = manager.soundDetectorConcurrencyTag; await withoutConcurrency(syncTag, () => Promise.resolve()); await settled(syncTag); await sleep(25); expect(manager.state.speakingWhileMuted).toBe(false); }); it('should update speaking while muted state', async () => { const mock = createSoundDetector as Mock; let handler: SoundStateChangeHandler; const prevMockImplementation = mock.getMockImplementation(); mock.mockImplementation((_: MediaStream, h: SoundStateChangeHandler) => { handler = h; }); try { await manager['startSpeakingWhileMutedDetection'](); expect(manager.state.speakingWhileMuted).toBe(false); handler!({ isSoundDetected: true, audioLevel: 2 }); expect(manager.state.speakingWhileMuted).toBe(true); handler!({ isSoundDetected: false, audioLevel: 0 }); expect(manager.state.speakingWhileMuted).toBe(false); } finally { mock.mockImplementation(prevMockImplementation!); } }); it('should not create duplicate sound detectors for the same device', async () => { const detectorMock = vi.mocked(createSoundDetector); const cleanup = vi.fn(async () => {}); detectorMock.mockImplementationOnce(() => cleanup); await manager['startSpeakingWhileMutedDetection']('device-1'); await manager['startSpeakingWhileMutedDetection']('device-1'); expect(detectorMock).toHaveBeenCalledTimes(1); await manager['stopSpeakingWhileMutedDetection'](); expect(cleanup).toHaveBeenCalledTimes(1); }); it('should cleanup previous detector before starting a new device detector', async () => { const detectorMock = vi.mocked(createSoundDetector); const cleanupFirst = vi.fn(async () => {}); const cleanupSecond = vi.fn(async () => {}); detectorMock .mockImplementationOnce(() => cleanupFirst) .mockImplementationOnce(() => cleanupSecond); await manager['startSpeakingWhileMutedDetection']('device-1'); await manager['startSpeakingWhileMutedDetection']('device-2'); expect(detectorMock).toHaveBeenCalledTimes(2); expect(cleanupFirst).toHaveBeenCalledTimes(1); await manager['stopSpeakingWhileMutedDetection'](); expect(cleanupSecond).toHaveBeenCalledTimes(1); }); // --- this --- it('should stop speaking while muted notifications if user loses permission to send audio', async () => { await manager.enable(); await manager.disable(); // @ts-expect-error private api const fn = vi.spyOn(manager, 'stopSpeakingWhileMutedDetection'); manager['call'].state.setOwnCapabilities([]); await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 }); expect(fn).toHaveBeenCalled(); }); // --- this --- it('should start speaking while muted notifications if user gains permission to send audio', async () => { await manager.enable(); await manager.disable(); manager['call'].state.setOwnCapabilities([]); // @ts-expect-error private api const fn = vi.spyOn(manager, 'startSpeakingWhileMutedDetection'); manager['call'].state.setOwnCapabilities([OwnCapability.SEND_AUDIO]); await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 }); expect(fn).toHaveBeenCalled(); }); it(`disables speaking while muted notifications`, async () => { // @ts-expect-error - private api vi.spyOn(manager, 'startSpeakingWhileMutedDetection'); // @ts-expect-error - private api vi.spyOn(manager, 'stopSpeakingWhileMutedDetection'); await manager.disableSpeakingWhileMutedNotification(); await manager.disable(); expect(manager['stopSpeakingWhileMutedDetection']).toHaveBeenCalled(); expect( manager['startSpeakingWhileMutedDetection'], ).not.toHaveBeenCalled(); }); it(`enables speaking while muted notifications`, async () => { // @ts-expect-error - private api vi.spyOn(manager, 'startSpeakingWhileMutedDetection'); await manager.enableSpeakingWhileMutedNotification(); await manager.disable(); expect(manager['startSpeakingWhileMutedDetection']).toHaveBeenCalled(); }); }); describe('Noise Cancellation', () => { it('should register filter if all preconditions are met', async () => { call.state.setCallingState(CallingState.IDLE); const registerFilter = vi.spyOn(manager, 'registerFilter'); const noiseCancellation = new NoiseCancellationStub(); const noiseCancellationEnable = vi.spyOn(noiseCancellation, 'enable'); await manager.enableNoiseCancellation(noiseCancellation); expect(registerFilter).toBeCalled(); expect(noiseCancellationEnable).not.toBeCalled(); }); it('should unregister filter when disabling noise cancellation', async () => { const noiseCancellation = new NoiseCancellationStub(); await manager.enableNoiseCancellation(noiseCancellation); await manager.disableNoiseCancellation(); expect(call.notifyNoiseCancellationStopped).toBeCalled(); }); it('should throw when own capabilities are missing', async () => { call.state.setOwnCapabilities([]); await expect(() => manager.enableNoiseCancellation(new NoiseCancellationStub()), ).rejects.toThrow(); }); it('should throw when noise cancellation is disabled in call settings', async () => { call.state.setOwnCapabilities([OwnCapability.ENABLE_NOISE_CANCELLATION]); call.state.updateFromCallResponse( fromPartial({ egress: {}, settings: { audio: { noise_cancellation: { mode: NoiseCancellationSettingsModeEnum.DISABLED, }, }, }, }), ); await expect(() => manager.enableNoiseCancellation(new NoiseCancellationStub()), ).rejects.toThrow(); }); it('should automatically enable noise noise suppression after joining a call', async () => { call.state.setCallingState(CallingState.IDLE); // reset state call.state.updateFromCallResponse( fromPartial({ egress: {}, settings: { audio: { noise_cancellation: { mode: NoiseCancellationSettingsModeEnum.AUTO_ON, }, }, }, }), ); const noiseCancellation = new NoiseCancellationStub(); const noiseCancellationEnable = vi.spyOn(noiseCancellation, 'enable'); await manager.enableNoiseCancellation(noiseCancellation); expect(noiseCancellationEnable).not.toBeCalled(); call.state.setCallingState(CallingState.JOINED); // it is quite hard to test the "detached" callingState$ subscription // with the current tools and architecture. // that is why we go with the good old sleep await sleep(25); expect(noiseCancellationEnable).toBeCalled(); expect(call.notifyNoiseCancellationStarting).toBeCalled(); }); it('should automatically disable noise suppression after leaving the call', async () => { const noiseCancellation = new NoiseCancellationStub(); const noiseSuppressionDisable = vi.spyOn(noiseCancellation, 'disable'); await manager.enableNoiseCancellation(noiseCancellation); expect(noiseSuppressionDisable).not.toBeCalled(); call.state.setCallingState(CallingState.LEFT); // it is quite hard to test the "detached" callingState$ subscription // with the current tools and architecture. // that is why we go with the good old sleep await sleep(25); expect(noiseSuppressionDisable).toBeCalled(); expect(call.notifyNoiseCancellationStopped).toBeCalled(); }); }); describe('Audio Settings', () => { beforeEach(() => { // @ts-expect-error - read only property call.permissionsContext = new PermissionsContext(); call.permissionsContext.canPublish = vi.fn().mockReturnValue(true); }); it('should apply defaults when mic_default_on is true and enabled is pristine', async () => { const enable = vi.spyOn(manager, 'enable'); // @ts-expect-error - partial data await manager.apply({ mic_default_on: true }, true); expect(enable).toHaveBeenCalled(); }); it('should turn the mic on when set on dashboard', async () => { const enable = vi.spyOn(manager, 'enable'); // @ts-expect-error - partial data await manager.apply({ mic_default_on: true }, true); expect(enable).toHaveBeenCalled(); }); it('should not turn the mic on when set on dashboard', async () => { const enable = vi.spyOn(manager, 'enable'); // @ts-expect-error - partial data await manager.apply({ mic_default_on: false }, true); expect(enable).not.toHaveBeenCalled(); }); it('should not turn on the mic when permission is missing', async () => { call.permissionsContext.canPublish = vi.fn().mockReturnValue(false); const enable = vi.spyOn(manager, 'enable'); // @ts-expect-error - partial data await manager.apply({ mic_default_on: true }, true); expect(enable).not.toHaveBeenCalled(); }); it('should publish the audio stream when mic is turned on before settings are applied', async () => { await manager.enable(); // @ts-expect-error - private api vi.spyOn(manager, 'publishStream'); // @ts-expect-error - partial data await manager.apply({ mic_default_on: true }, true); expect(manager['publishStream']).toHaveBeenCalled(); }); it('should skip defaults when preferences are applied', async () => { const devicePersistence = { enabled: true, storageKey: '' }; const persistedManager = new MicrophoneManager( call, devicePersistence, 'disable-tracks', ); const applySpy = vi .spyOn(persistedManager as never, 'applyPersistedPreferences') .mockResolvedValue(true); const enableSpy = vi.spyOn(persistedManager, 'enable'); // @ts-expect-error - partial data await persistedManager.apply({ mic_default_on: true }, true); expect(applySpy).toHaveBeenCalledWith(true); expect(enableSpy).not.toHaveBeenCalled(); }); it('should skip persisted preferences when permission is not granted', async () => { vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( of('prompt'), ); const devicePersistence = { enabled: true, storageKey: '' }; const persistedManager = new MicrophoneManager( call, devicePersistence, 'disable-tracks', ); const applySpy = vi.spyOn( persistedManager as never, 'applyPersistedPreferences', ); const enableSpy = vi.spyOn(persistedManager, 'enable'); // @ts-expect-error - partial data await persistedManager.apply({ mic_default_on: true }, true); expect(applySpy).not.toHaveBeenCalled(); expect(enableSpy).toHaveBeenCalled(); }); it('should not apply defaults when mic is not pristine', async () => { manager.state.setStatus('enabled'); const applySpy = vi.spyOn(manager as never, 'applyPersistedPreferences'); const enableSpy = vi.spyOn(manager, 'enable'); // @ts-expect-error - partial data await manager.apply({ mic_default_on: true }, true); expect(applySpy).not.toHaveBeenCalled(); expect(enableSpy).not.toHaveBeenCalled(); }); }); describe('Hi-Fi Audio', () => { it('enables hi-fi audio', async () => { call.state.updateFromCallResponse( fromPartial({ egress: {}, settings: { audio: { hifi_audio_enabled: true } }, }), ); await manager.enable(); // @ts-expect-error - private api const apply = vi.spyOn(manager, 'applySettingsToStream'); const publish = vi.spyOn(call, 'publish'); const profile = AudioBitrateProfile.MUSIC_HIGH_QUALITY; await manager.setAudioBitrateProfile(profile); expect(apply).toHaveBeenCalledOnce(); expect(publish).toHaveBeenCalledWith( manager.state.mediaStream, TrackType.AUDIO, { audioBitrateProfile: profile }, ); }); it('throws an error when enabling hi-fi audio if not allowed', async () => { call.state.updateFromCallResponse( fromPartial({ egress: {}, settings: { audio: { hifi_audio_enabled: false } }, }), ); await manager.enable(); await expect(() => manager.setAudioBitrateProfile(AudioBitrateProfile.VOICE_HIGH_QUALITY), ).rejects.toThrowError(); }); }); describe('performTest', () => { it('should return true when microphone captures audio', async () => { const mock = vi.mocked(createNoAudioDetector); mock.mockImplementationOnce((_stream, options) => { // Simulate audio detected immediately setImmediate(() => options.onCaptureStatusChange(true)); return async () => {}; }); const capturesAudio = await manager.performTest('test-device-id'); expect(capturesAudio).toBe(true); }); it('should return false when microphone does not capture audio', async () => { const mock = vi.mocked(createNoAudioDetector); mock.mockImplementationOnce((_stream, options) => { // Simulate no audio detected after test duration setImmediate(() => options.onCaptureStatusChange(false)); return async () => {}; }); const capturesAudio = await manager.performTest('test-device-id'); expect(capturesAudio).toBe(false); }); it('should use custom testDurationMs when provided', async () => { const mock = vi.mocked(createNoAudioDetector); let capturedOptions: NoAudioDetectorOptions; mock.mockImplementationOnce((_stream, options) => { capturedOptions = options; setTimeout(() => options.onCaptureStatusChange(true), 50); return async () => {}; }); const customDuration = 5000; await manager.performTest('test-device-id', { testDurationMs: customDuration, }); expect(capturedOptions.noAudioThresholdMs).toBe(customDuration); expect(capturedOptions.emitIntervalMs).toBe(customDuration); }); it('should call getStream with exact deviceId', async () => { const mock = vi.mocked(createNoAudioDetector); mock.mockImplementationOnce((_stream, options) => { setTimeout(() => options.onCaptureStatusChange(true), 50); return async () => {}; }); const deviceId = 'specific-device-id'; await manager.performTest(deviceId); expect(getAudioStream).toHaveBeenCalledWith( { deviceId: { exact: deviceId } }, expect.any(Tracer), ); }); it('should cleanup detector and dispose stream after test completes', async () => { const mock = vi.mocked(createNoAudioDetector); const cleanupFn = vi.fn(async () => {}); let onCaptureStatusChange: ((capturesAudio: boolean) => void) | undefined; mock.mockImplementationOnce((_stream, options) => { onCaptureStatusChange = options.onCaptureStatusChange; setTimeout(() => onCaptureStatusChange?.(true), 50); return cleanupFn; }); await manager.performTest('test-device-id'); // Wait for cleanup to be called await vi.waitFor(() => { expect(cleanupFn).toHaveBeenCalled(); }); }); }); describe('no-audio detector configuration', () => { it('applies silence threshold and emit interval in runtime monitoring', async () => { const noAudioDetector = vi.mocked(createNoAudioDetector); manager.setSilenceThreshold(3000); manager['call'].state.setCallingState(CallingState.JOINED); await manager.enable(); await vi.waitFor(() => { expect(noAudioDetector).toHaveBeenCalled(); }); const options = noAudioDetector.mock.calls.at(-1)?.[1]; expect(options).toMatchObject({ noAudioThresholdMs: 3000, emitIntervalMs: 3000, }); }); }); describe('Device Persistence Stress', () => { it('persists the final microphone and muted state after rapid toggles, switches, and unplug', async () => { const storageKey = '@test/device-preferences-microphone-stress'; const localStorageMock = createLocalStorageMock(); const originalWindow = globalThis.window; Object.defineProperty(globalThis, 'window', { configurable: true, value: { localStorage: localStorageMock }, }); const getAudioStreamMock = vi.mocked(getAudioStream); getAudioStreamMock.mockImplementation((constraints) => { const requestedDeviceId = (constraints?.deviceId as { exact?: string }) ?.exact; const selectedDevice = mockAudioDevices.find((d) => d.deviceId === requestedDeviceId) ?? mockAudioDevices[0]; return Promise.resolve( createAudioStreamForDevice( selectedDevice.deviceId, selectedDevice.label, ), ); }); const stressManager = new MicrophoneManager( call, { enabled: true, storageKey }, 'disable-tracks', ); try { const finalDevice = mockAudioDevices[2]; emitDeviceIds(mockAudioDevices); await Promise.allSettled([ stressManager.enable(), stressManager.select(mockAudioDevices[1].deviceId), stressManager.toggle(), stressManager.select(finalDevice.deviceId), stressManager.toggle(), stressManager.enable(), ]); await stressManager.statusChangeSettled(); await stressManager.select(finalDevice.deviceId); await stressManager.enable(); await stressManager.statusChangeSettled(); expect(stressManager.state.selectedDevice).toBe(finalDevice.deviceId); expect(stressManager.state.status).toBe('enabled'); const persistedBeforeUnplug = toPreferenceList( readPreferences(storageKey).microphone, ); expect(persistedBeforeUnplug[0]).toEqual({ selectedDeviceId: finalDevice.deviceId, selectedDeviceLabel: finalDevice.label, muted: false, }); emitDeviceIds( mockAudioDevices.filter((d) => d.deviceId !== finalDevice.deviceId), ); await vi.waitFor(() => { expect(stressManager.state.selectedDevice).toBe(undefined); expect(stressManager.state.status).toBe('disabled'); }); const persistedAfterUnplug = toPreferenceList( readPreferences(storageKey).microphone, ); expect(persistedAfterUnplug[0]).toEqual({ selectedDeviceId: defaultDeviceId, selectedDeviceLabel: '', muted: true, }); expect(persistedAfterUnplug).toContainEqual({ selectedDeviceId: finalDevice.deviceId, selectedDeviceLabel: finalDevice.label, muted: true, }); } finally { stressManager.dispose(); Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow, }); } }); }); afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules(); }); });