diff --git a/ts/calling/audioDeviceModule.ts b/ts/calling/audioDeviceModule.ts new file mode 100644 index 000000000..9d7e36488 --- /dev/null +++ b/ts/calling/audioDeviceModule.ts @@ -0,0 +1,14 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { makeEnumParser } from '../util/enum'; + +export enum AudioDeviceModule { + Default = 'Default', + WindowsAdm2 = 'WindowsAdm2', +} + +export const parseAudioDeviceModule = makeEnumParser( + AudioDeviceModule, + AudioDeviceModule.Default +); diff --git a/ts/calling/findBestMatchingDevice.ts b/ts/calling/findBestMatchingDevice.ts new file mode 100644 index 000000000..16df07099 --- /dev/null +++ b/ts/calling/findBestMatchingDevice.ts @@ -0,0 +1,66 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { AudioDevice } from 'ringrtc'; +import { AudioDeviceModule } from './audioDeviceModule'; + +export function findBestMatchingAudioDeviceIndex({ + available, + preferred, + previousAudioDeviceModule, + currentAudioDeviceModule, +}: Readonly<{ + available: ReadonlyArray; + preferred: undefined | AudioDevice; + previousAudioDeviceModule: AudioDeviceModule; + currentAudioDeviceModule: AudioDeviceModule; +}>): undefined | number { + if (!preferred) { + return available.length > 0 ? 0 : undefined; + } + + if ( + (currentAudioDeviceModule === AudioDeviceModule.WindowsAdm2 && + preferred.index === 0) || + (previousAudioDeviceModule === AudioDeviceModule.WindowsAdm2 && + preferred.index === 1 && + available.length >= 2) + ) { + return preferred.index; + } + + if (preferred.uniqueId) { + const idMatchIndex = available.findIndex( + d => d.uniqueId === preferred.uniqueId + ); + if (idMatchIndex !== -1) { + return idMatchIndex; + } + } + + const nameMatchIndex = available.findIndex(d => d.name === preferred.name); + if (nameMatchIndex !== -1) { + return nameMatchIndex; + } + + return available.length > 0 ? 0 : undefined; +} + +export function findBestMatchingCameraId( + available: ReadonlyArray, + preferred?: string +): undefined | string { + const matchingId = available.filter(d => d.deviceId === preferred); + const nonInfrared = available.filter(d => !d.label.includes('IR Camera')); + + // By default, pick the first non-IR camera (but allow the user to pick the + // infrared if they so desire) + if (matchingId.length > 0) { + return matchingId[0].deviceId; + } + if (nonInfrared.length > 0) { + return nonInfrared[0].deviceId; + } + + return undefined; +} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 17b712ed6..0fcf1e9b5 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -53,8 +53,17 @@ import { PresentedSource, ProcessGroupCallRingRequestResult, } from '../types/Calling'; +import { + AudioDeviceModule, + parseAudioDeviceModule, +} from '../calling/audioDeviceModule'; +import { + findBestMatchingAudioDeviceIndex, + findBestMatchingCameraId, +} from '../calling/findBestMatchingDevice'; import { LocalizerType } from '../types/Util'; import { UUID } from '../types/UUID'; +import * as OS from '../OS'; import { ConversationModel } from '../models/conversations'; import * as Bytes from '../Bytes'; import { uuidToBytes, bytesToUuid } from '../Crypto'; @@ -63,6 +72,7 @@ import { getOwn } from '../util/getOwn'; import { isNormalNumber } from '../util/isNormalNumber'; import * as durations from '../util/durations'; import { handleMessageSend } from '../util/handleMessageSend'; +import { isAlpha, isBeta } from '../util/version'; import { fetchMembershipProof, getMembershipList, @@ -212,6 +222,10 @@ export class CallingClass { private lastMediaDeviceSettings?: MediaDeviceSettings; + private previousAudioDeviceModule?: AudioDeviceModule; + + private currentAudioDeviceModule?: AudioDeviceModule; + private deviceReselectionTimer?: NodeJS.Timeout; private callsByConversation: { [conversationId: string]: Call | GroupCall }; @@ -237,6 +251,24 @@ export class CallingClass { this.sfuUrl = sfuUrl; + this.previousAudioDeviceModule = parseAudioDeviceModule( + window.storage.get('previousAudioDeviceModule') + ); + this.currentAudioDeviceModule = + OS.isWindows() && + (isAlpha(window.getVersion()) || isBeta(window.getVersion())) + ? AudioDeviceModule.WindowsAdm2 + : AudioDeviceModule.Default; + window.storage.put( + 'previousAudioDeviceModule', + this.currentAudioDeviceModule + ); + + RingRTC.setConfig({ + use_new_audio_device_module: + this.currentAudioDeviceModule === AudioDeviceModule.WindowsAdm2, + }); + RingRTC.handleOutgoingSignaling = this.handleOutgoingSignaling.bind(this); RingRTC.handleIncomingCall = this.handleIncomingCall.bind(this); RingRTC.handleAutoEndedIncomingCallRequest = this.handleAutoEndedIncomingCallRequest.bind( @@ -1263,6 +1295,13 @@ export class CallingClass { } async getMediaDeviceSettings(): Promise { + const { previousAudioDeviceModule, currentAudioDeviceModule } = this; + if (!previousAudioDeviceModule || !currentAudioDeviceModule) { + throw new Error( + 'Calling#getMediaDeviceSettings cannot be called before audio device settings are set' + ); + } + const { availableCameras, availableMicrophones, @@ -1270,27 +1309,31 @@ export class CallingClass { } = await this.getAvailableIODevices(); const preferredMicrophone = window.Events.getPreferredAudioInputDevice(); - const selectedMicIndex = this.findBestMatchingDeviceIndex( - availableMicrophones, - preferredMicrophone - ); + const selectedMicIndex = findBestMatchingAudioDeviceIndex({ + available: availableMicrophones, + preferred: preferredMicrophone, + previousAudioDeviceModule, + currentAudioDeviceModule, + }); const selectedMicrophone = selectedMicIndex !== undefined ? availableMicrophones[selectedMicIndex] : undefined; const preferredSpeaker = window.Events.getPreferredAudioOutputDevice(); - const selectedSpeakerIndex = this.findBestMatchingDeviceIndex( - availableSpeakers, - preferredSpeaker - ); + const selectedSpeakerIndex = findBestMatchingAudioDeviceIndex({ + available: availableSpeakers, + preferred: preferredSpeaker, + previousAudioDeviceModule, + currentAudioDeviceModule, + }); const selectedSpeaker = selectedSpeakerIndex !== undefined ? availableSpeakers[selectedSpeakerIndex] : undefined; const preferredCamera = window.Events.getPreferredVideoInputDevice(); - const selectedCamera = this.findBestMatchingCamera( + const selectedCamera = findBestMatchingCameraId( availableCameras, preferredCamera ); @@ -1305,49 +1348,6 @@ export class CallingClass { }; } - findBestMatchingDeviceIndex( - available: Array, - preferred: AudioDevice | undefined - ): number | undefined { - if (preferred) { - // Match by uniqueId first, if available - if (preferred.uniqueId) { - const matchIndex = available.findIndex( - d => d.uniqueId === preferred.uniqueId - ); - if (matchIndex !== -1) { - return matchIndex; - } - } - // Match by name second - const matchingNames = available.filter(d => d.name === preferred.name); - if (matchingNames.length > 0) { - return matchingNames[0].index; - } - } - // Nothing matches or no preference; take the first device if there are any - return available.length > 0 ? 0 : undefined; - } - - findBestMatchingCamera( - available: Array, - preferred?: string - ): string | undefined { - const matchingId = available.filter(d => d.deviceId === preferred); - const nonInfrared = available.filter(d => !d.label.includes('IR Camera')); - - // By default, pick the first non-IR camera (but allow the user to pick the - // infrared if they so desire) - if (matchingId.length > 0) { - return matchingId[0].deviceId; - } - if (nonInfrared.length > 0) { - return nonInfrared[0].deviceId; - } - - return undefined; - } - setPreferredMicrophone(device: AudioDevice): void { log.info('MediaDevice: setPreferredMicrophone', device); window.Events.setPreferredAudioInputDevice(device); diff --git a/ts/test-both/calling/findBestMatchingDevice_test.ts b/ts/test-both/calling/findBestMatchingDevice_test.ts new file mode 100644 index 000000000..ed0b5d96a --- /dev/null +++ b/ts/test-both/calling/findBestMatchingDevice_test.ts @@ -0,0 +1,237 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { AudioDeviceModule } from '../../calling/audioDeviceModule'; + +import { findBestMatchingAudioDeviceIndex } from '../../calling/findBestMatchingDevice'; + +describe('"find best matching device" helpers', () => { + describe('findBestMatchingAudioDeviceIndex', () => { + type AdmOptionsType = Readonly<{ + previousAudioDeviceModule: AudioDeviceModule; + currentAudioDeviceModule: AudioDeviceModule; + }>; + + const itReturnsUndefinedIfNoDevicesAreAvailable = ( + admOptions: AdmOptionsType + ) => { + it('returns undefined if no devices are available', () => { + [ + undefined, + { name: 'Big Microphone', index: 1, uniqueId: 'abc123' }, + ].forEach(preferred => { + assert.isUndefined( + findBestMatchingAudioDeviceIndex({ + available: [], + preferred, + ...admOptions, + }) + ); + }); + }); + }; + + const itReturnsTheFirstAvailableDeviceIfNoneIsPreferred = ( + admOptions: AdmOptionsType + ) => { + it('returns the first available device if none is preferred', () => { + assert.strictEqual( + findBestMatchingAudioDeviceIndex({ + available: [ + { name: 'A', index: 123, uniqueId: 'device-A' }, + { name: 'B', index: 456, uniqueId: 'device-B' }, + { name: 'C', index: 789, uniqueId: 'device-C' }, + ], + preferred: undefined, + ...admOptions, + }), + 0 + ); + }); + }; + + const testUniqueIdMatch = (admOptions: AdmOptionsType) => { + assert.strictEqual( + findBestMatchingAudioDeviceIndex({ + available: [ + { name: 'A', index: 123, uniqueId: 'device-A' }, + { name: 'B', index: 456, uniqueId: 'device-B' }, + { name: 'C', index: 789, uniqueId: 'device-C' }, + ], + preferred: { name: 'Ignored', index: 99, uniqueId: 'device-C' }, + ...admOptions, + }), + 2 + ); + }; + + const testNameMatch = (admOptions: AdmOptionsType) => { + assert.strictEqual( + findBestMatchingAudioDeviceIndex({ + available: [ + { name: 'A', index: 123, uniqueId: 'device-A' }, + { name: 'B', index: 456, uniqueId: 'device-B' }, + { name: 'C', index: 789, uniqueId: 'device-C' }, + ], + preferred: { name: 'C', index: 99, uniqueId: 'ignored' }, + ...admOptions, + }), + 2 + ); + }; + + const itReturnsTheFirstAvailableDeviceIfThePreferredDeviceIsNotFound = ( + admOptions: AdmOptionsType + ) => { + it('returns the first available device if the preferred device is not found', () => { + assert.strictEqual( + findBestMatchingAudioDeviceIndex({ + available: [ + { name: 'A', index: 123, uniqueId: 'device-A' }, + { name: 'B', index: 456, uniqueId: 'device-B' }, + { name: 'C', index: 789, uniqueId: 'device-C' }, + ], + preferred: { name: 'X', index: 123, uniqueId: 'Y' }, + ...admOptions, + }), + 0 + ); + }); + }; + + describe('with default audio device module', () => { + const admOptions = { + previousAudioDeviceModule: AudioDeviceModule.Default, + currentAudioDeviceModule: AudioDeviceModule.Default, + }; + + itReturnsUndefinedIfNoDevicesAreAvailable(admOptions); + + itReturnsTheFirstAvailableDeviceIfNoneIsPreferred(admOptions); + + it('returns a unique ID match if it exists', () => { + testUniqueIdMatch(admOptions); + }); + + it('returns a name match if it exists', () => { + testNameMatch(admOptions); + }); + + itReturnsTheFirstAvailableDeviceIfThePreferredDeviceIsNotFound( + admOptions + ); + }); + + describe('when going from the default to Windows ADM2', () => { + const admOptions = { + previousAudioDeviceModule: AudioDeviceModule.Default, + currentAudioDeviceModule: AudioDeviceModule.WindowsAdm2, + }; + + itReturnsUndefinedIfNoDevicesAreAvailable(admOptions); + + itReturnsTheFirstAvailableDeviceIfNoneIsPreferred(admOptions); + + it('returns 0 if that was the previous preferred index (and a device is available)', () => { + assert.strictEqual( + findBestMatchingAudioDeviceIndex({ + available: [ + { name: 'A', index: 123, uniqueId: 'device-A' }, + { name: 'B', index: 456, uniqueId: 'device-B' }, + ], + preferred: { name: 'B', index: 0, uniqueId: 'device-B' }, + ...admOptions, + }), + 0 + ); + }); + + it('returns a unique ID match if it exists and the preferred index is not 0', () => { + testUniqueIdMatch(admOptions); + }); + + it('returns a name match if it exists and the preferred index is not 0', () => { + testNameMatch(admOptions); + }); + + itReturnsTheFirstAvailableDeviceIfThePreferredDeviceIsNotFound( + admOptions + ); + }); + + describe('when going "backwards" from Windows ADM2 to the default', () => { + const admOptions = { + previousAudioDeviceModule: AudioDeviceModule.WindowsAdm2, + currentAudioDeviceModule: AudioDeviceModule.Default, + }; + + itReturnsUndefinedIfNoDevicesAreAvailable(admOptions); + + itReturnsTheFirstAvailableDeviceIfNoneIsPreferred(admOptions); + + it('returns a unique ID match if it exists', () => { + testUniqueIdMatch(admOptions); + }); + + it('returns a name match if it exists', () => { + testNameMatch(admOptions); + }); + + itReturnsTheFirstAvailableDeviceIfThePreferredDeviceIsNotFound( + admOptions + ); + }); + + describe('with Windows ADM2', () => { + const admOptions = { + previousAudioDeviceModule: AudioDeviceModule.WindowsAdm2, + currentAudioDeviceModule: AudioDeviceModule.WindowsAdm2, + }; + + itReturnsUndefinedIfNoDevicesAreAvailable(admOptions); + + itReturnsTheFirstAvailableDeviceIfNoneIsPreferred(admOptions); + + [0, 1].forEach(index => { + it(`returns ${index} if that was the previous preferred index (and a device is available)`, () => { + assert.strictEqual( + findBestMatchingAudioDeviceIndex({ + available: [ + { name: 'A', index: 123, uniqueId: 'device-A' }, + { name: 'B', index: 456, uniqueId: 'device-B' }, + { name: 'C', index: 789, uniqueId: 'device-C' }, + ], + preferred: { name: 'C', index, uniqueId: 'device-C' }, + ...admOptions, + }), + index + ); + }); + }); + + it("returns 0 if the previous preferred index was 1 but there's only 1 audio device", () => { + assert.strictEqual( + findBestMatchingAudioDeviceIndex({ + available: [{ name: 'A', index: 123, uniqueId: 'device-A' }], + preferred: { name: 'C', index: 1, uniqueId: 'device-C' }, + ...admOptions, + }), + 0 + ); + }); + + it('returns a unique ID match if it exists and the preferred index is not 0 or 1', () => { + testUniqueIdMatch(admOptions); + }); + + it('returns a name match if it exists and the preferred index is not 0 or 1', () => { + testNameMatch(admOptions); + }); + + itReturnsTheFirstAvailableDeviceIfThePreferredDeviceIsNotFound( + admOptions + ); + }); + }); +}); diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index c33327b2a..87292fe14 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -6,6 +6,7 @@ import type { CustomColorsItemType, DefaultConversationColorType, } from './Colors'; +import type { AudioDeviceModule } from '../calling/audioDeviceModule'; import type { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; import type { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; import type { RetryItemType } from '../util/retryPlaceholders'; @@ -113,6 +114,7 @@ export type StorageAccessType = { 'preferred-video-input-device': string; 'preferred-audio-input-device': AudioDevice; 'preferred-audio-output-device': AudioDevice; + previousAudioDeviceModule: AudioDeviceModule; remoteConfig: RemoteConfigType; unidentifiedDeliveryIndicators: boolean; groupCredentials: Array; diff --git a/ts/types/StorageUIKeys.ts b/ts/types/StorageUIKeys.ts index 0e7dad3d6..625e676e7 100644 --- a/ts/types/StorageUIKeys.ts +++ b/ts/types/StorageUIKeys.ts @@ -26,6 +26,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray = [ 'preferred-audio-input-device', 'preferred-audio-output-device', 'preferredReactionEmoji', + 'previousAudioDeviceModule', 'skinTone', 'zoomFactor', ];