diff --git a/conference.js b/conference.js index e8477c7433..79dfb6fdcf 100644 --- a/conference.js +++ b/conference.js @@ -134,6 +134,7 @@ import { isLocalTrackMuted, isUserInteractionRequiredForUnmute } from './react/features/base/tracks/functions'; +import { getLocalJitsiAudioTrackSettings } from './react/features/base/tracks/functions.web'; import { downloadJSON } from './react/features/base/util/downloadJSON'; import { getJitsiMeetGlobalNSConnectionTimes } from './react/features/base/util/helpers'; import { openLeaveReasonDialog } from './react/features/conference/actions.web'; @@ -158,6 +159,7 @@ import { disableReceiver, stopReceiver } from './react/features/remote-control/a import { setScreenAudioShareState } from './react/features/screen-share/actions.web'; import { isScreenAudioShared } from './react/features/screen-share/functions'; import { toggleScreenshotCaptureSummary } from './react/features/screenshot-capture/actions'; +import { setAudioSettings } from './react/features/settings/actions.web'; import { AudioMixerEffect } from './react/features/stream-effects/audio-mixer/AudioMixerEffect'; import { createRnnoiseProcessor } from './react/features/stream-effects/rnnoise'; import { handleToggleVideoMuted } from './react/features/toolbox/actions.any'; @@ -566,7 +568,15 @@ export default { if (browser.isWebKitBased()) { this.muteAudio(true, true); } else { - localTracks = localTracks.filter(track => track.getType() !== MEDIA_TYPE.AUDIO); + localTracks = localTracks.filter(track => { + if (track.getType() === MEDIA_TYPE.AUDIO) { + track.stopStream(); + + return false; + } + + return true; + }); } } @@ -1763,7 +1773,11 @@ export default { return this.useAudioStream(stream); }) .then(() => { - const localAudio = getLocalJitsiAudioTrack(APP.store.getState()); + const state = APP.store.getState(); + const localAudio = getLocalJitsiAudioTrack(state); + const settings = getLocalJitsiAudioTrackSettings(state); + + APP.store.dispatch(setAudioSettings(settings)); if (localAudio && isDefaultMicSelected) { // workaround for the default device to be shown as selected in the diff --git a/lang/main.json b/lang/main.json index 12beef4313..dc21f2d3da 100644 --- a/lang/main.json +++ b/lang/main.json @@ -1380,6 +1380,20 @@ "videounmute": "Start camera" }, "addPeople": "Add people to your call", + "advancedAudioSettings": { + "aec": { + "label": "Acoustic echo cancellation" + }, + "agc": { + "label": "Automatic gain control" + }, + "ns": { + "label": "Noise suppression" + }, + "stereo": { + "label": "Stereo" + } + }, "audioOnlyOff": "Disable low bandwidth mode", "audioOnlyOn": "Enable low bandwidth mode", "audioRoute": "Select the sound device", diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts index 5620577524..4c18c6eeea 100644 --- a/react/features/base/config/configType.ts +++ b/react/features/base/config/configType.ts @@ -1,5 +1,6 @@ import { ToolbarButton } from '../../toolbox/types'; import { ILoggingConfig } from '../logging/types'; +import { IAudioSettings } from '../settings/reducer'; import { DesktopSharingSourceType } from '../tracks/types'; type ButtonsWithNotifyClick = 'camera' | @@ -191,6 +192,7 @@ export interface IConfig { appId?: string; audioLevelsInterval?: number; audioQuality?: { + enableAdvancedAudioSettings?: boolean; opusMaxAverageBitrate?: number | null; stereo?: boolean; }; @@ -237,6 +239,7 @@ export interface IConfig { inactiveDisabled?: boolean; }; constraints?: { + audio?: IAudioSettings; video?: { height?: { ideal?: number; diff --git a/react/features/base/config/functions.any.ts b/react/features/base/config/functions.any.ts index 28ee959ddd..dae1ad4e2a 100644 --- a/react/features/base/config/functions.any.ts +++ b/react/features/base/config/functions.any.ts @@ -6,6 +6,7 @@ import { safeJsonParse } from '@jitsi/js-utils/json'; import { isEmpty, mergeWith, pick } from 'lodash-es'; import { IReduxState } from '../../app/types'; +import { browser } from '../lib-jitsi-meet'; import { getLocalParticipant } from '../participants/functions'; import { isEmbedded } from '../util/embedUtils'; import { parseURLParams } from '../util/parseURLParams'; @@ -256,6 +257,17 @@ export function isDisplayNameVisible(state: IReduxState): boolean { return !state['features/base/config'].hideDisplayName; } +/** + * Selector for determining if the advanced audio settings are enabled. + * + * @param {Object} state - The state of the app. + * @returns {boolean} + */ +export function isAdvancedAudioSettingsEnabled(state: IReduxState): boolean { + + return !browser.isWebKitBased() && Boolean(state['features/base/config']?.audioQuality?.enableAdvancedAudioSettings); +} + /** * Restores a Jitsi Meet config.js from {@code localStorage} if it was * previously downloaded from a specific {@code baseURL} and stored with diff --git a/react/features/base/settings/reducer.ts b/react/features/base/settings/reducer.ts index 766770bee2..ce99186b51 100644 --- a/react/features/base/settings/reducer.ts +++ b/react/features/base/settings/reducer.ts @@ -51,8 +51,15 @@ const DEFAULT_STATE: ISettingsState = { userSelectedMicDeviceLabel: undefined }; +export interface IAudioSettings { + autoGainControl?: boolean; + channelCount?: 1 | 2; + echoCancellation?: boolean; + noiseSuppression?: boolean; +} export interface ISettingsState { audioOutputDeviceId?: string; + audioSettings?: IAudioSettings; audioSettingsVisible?: boolean; avatarURL?: string; cameraDeviceId?: string | boolean; @@ -66,6 +73,7 @@ export interface ISettingsState { localFlipX?: boolean; maxStageParticipants?: number; micDeviceId?: string | boolean; + previewAudioTrack?: any | null; serverURL?: string; showSubtitlesOnStage?: boolean; soundsIncomingMessage?: boolean; diff --git a/react/features/base/tracks/actions.any.ts b/react/features/base/tracks/actions.any.ts index 00aadc235b..89e3b1b97b 100644 --- a/react/features/base/tracks/actions.any.ts +++ b/react/features/base/tracks/actions.any.ts @@ -164,6 +164,7 @@ export function createLocalTracksA(options: ITrackOptions = {}) { = createLocalTracksF( { cameraDeviceId: options.cameraDeviceId, + constraints: options?.constraints, devices: [ device ], facingMode: options.facingMode || getCameraFacingMode(state), diff --git a/react/features/base/tracks/actions.web.ts b/react/features/base/tracks/actions.web.ts index 55ef50d47e..3c983c944e 100644 --- a/react/features/base/tracks/actions.web.ts +++ b/react/features/base/tracks/actions.web.ts @@ -12,6 +12,7 @@ import { setScreenAudioShareState, setScreenshareAudioTrack } from '../../screen import { isAudioOnlySharing, isScreenVideoShared } from '../../screen-share/functions'; import { toggleScreenshotCaptureSummary } from '../../screenshot-capture/actions'; import { isScreenshotCaptureEnabled } from '../../screenshot-capture/functions'; +import { setAudioSettings } from '../../settings/actions.web'; import { AudioMixerEffect } from '../../stream-effects/audio-mixer/AudioMixerEffect'; import { getCurrentConference } from '../conference/functions'; import { notifyCameraError, notifyMicError } from '../devices/actions.web'; @@ -27,6 +28,7 @@ import { } from '../media/constants'; import { IGUMPendingState } from '../media/types'; import { updateSettings } from '../settings/actions'; +import { IAudioSettings } from '../settings/reducer'; import { addLocalTrack, replaceLocalTrack } from './actions.any'; import AllowToggleCameraDialog from './components/web/AllowToggleCameraDialog'; @@ -37,6 +39,7 @@ import { getLocalVideoTrack, isToggleCameraEnabled } from './functions'; +import { applyAudioConstraints, getLocalJitsiAudioTrackSettings } from './functions.web'; import logger from './logger'; import { ICreateInitialTracksOptions, IInitialTracksErrors, IShareOptions, IToggleScreenSharingOptions } from './types'; @@ -329,7 +332,6 @@ export function setGUMPendingStateOnFailedTracks(tracks: Array, dispatch: I export function createAndAddInitialAVTracks(devices: Array) { return async (dispatch: IStore['dispatch']) => { dispatch(gumPending(devices, IGUMPendingState.PENDING_UNMUTE)); - const { tracks, errors } = await dispatch(createInitialAVTracks({ devices })); setGUMPendingStateOnFailedTracks(tracks, dispatch); @@ -541,3 +543,21 @@ export function toggleCamera() { await dispatch(replaceLocalTrack(null, newVideoTrack)); }; } + +/** + * Toggles the audio settings. + * + * @param {IAudioSettings} settings - The settings to apply. + * @returns {Function} + */ +export function toggleUpdateAudioSettings(settings: IAudioSettings) { + return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => { + const state = getState(); + + await applyAudioConstraints(state, settings); + + const updatedSettings = getLocalJitsiAudioTrackSettings(state) as IAudioSettings; + + dispatch(setAudioSettings(updatedSettings)); + }; +} diff --git a/react/features/base/tracks/functions.any.ts b/react/features/base/tracks/functions.any.ts index 9704ee789c..2fa2d8f96c 100644 --- a/react/features/base/tracks/functions.any.ts +++ b/react/features/base/tracks/functions.any.ts @@ -201,6 +201,45 @@ export function getLocalJitsiAudioTrack(state: IReduxState) { return track?.jitsiTrack; } +/** + * Returns audio settings from the local Jitsi audio track. + * + * @param {IReduxState} state - The Redux state. + * @returns {IAudioSettings} The extracted audio settings. + */ +export function getLocalJitsiAudioTrackSettings(state: IReduxState) { + const jitsiTrack = getLocalJitsiAudioTrack(state); + + if (!jitsiTrack) { + const config = state['features/base/config']; + const disableAP = Boolean(config?.disableAP); + const disableAGC = Boolean(config?.disableAGC); + const disableAEC = Boolean(config?.disableAEC); + const disableNS = Boolean(config?.disableNS); + const stereo = Boolean(config?.audioQuality?.stereo); + + return { + autoGainControl: !disableAP && !disableAGC, + channelCount: stereo ? 2 : 1, + echoCancellation: !disableAP && !disableAEC, + noiseSuppression: !disableAP && !disableNS + }; + } + + const hasAudioMixerEffect = Boolean(typeof jitsiTrack._streamEffect?.setMuted === 'function' && jitsiTrack._streamEffect?._originalTrack); + + const track = hasAudioMixerEffect ? jitsiTrack._streamEffect._originalTrack : jitsiTrack.getTrack(); + + const { autoGainControl, channelCount, echoCancellation, noiseSuppression } = track.getSettings(); + + return { + autoGainControl, + channelCount, + echoCancellation, + noiseSuppression + }; +} + /** * Returns track of specified media type for specified participant. * diff --git a/react/features/base/tracks/functions.web.ts b/react/features/base/tracks/functions.web.ts index 9f885eddac..35da6e49bc 100644 --- a/react/features/base/tracks/functions.web.ts +++ b/react/features/base/tracks/functions.web.ts @@ -1,5 +1,6 @@ import { IStore } from '../../app/types'; import { IStateful } from '../app/types'; +import { isAdvancedAudioSettingsEnabled } from '../config/functions.any'; import { isMobileBrowser } from '../environment/utils'; import JitsiMeetJS, { JitsiTrackErrors, browser } from '../lib-jitsi-meet'; import { gumPending, setAudioMuted } from '../media/actions'; @@ -11,9 +12,10 @@ import { getUserSelectedCameraDeviceId, getUserSelectedMicDeviceId } from '../settings/functions.web'; +import { IAudioSettings } from '../settings/reducer'; import { getJitsiMeetGlobalNSConnectionTimes } from '../util/helpers'; -import { getCameraFacingMode } from './functions.any'; +import { getCameraFacingMode, getLocalJitsiAudioTrack, getLocalJitsiAudioTrackSettings } from './functions.any'; import loadEffects from './loadEffects'; import logger from './logger'; import { ITrackOptions } from './types'; @@ -62,7 +64,13 @@ export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore, desktopSharingFrameRate, resolution } = state['features/base/config']; - const constraints = options.constraints ?? state['features/base/config'].constraints; + + const constraints = options.constraints ?? state['features/base/config'].constraints ?? {}; + + if (isAdvancedAudioSettingsEnabled(state) && typeof APP !== 'undefined') { + constraints.audio = state['features/settings'].audioSettings ?? getLocalJitsiAudioTrackSettings(state); + } + return ( loadEffects(store).then((effectsArray: Object[]) => { @@ -214,3 +222,32 @@ export function isToggleCameraEnabled(stateful: IStateful) { return isMobileBrowser() && Number(videoInput?.length) > 1; } +/** + * Applies audio constraints to the local Jitsi audio track. + * + * @param {Function|Object} stateful - The redux store or {@code getState} function. + * @param {IAudioSettings} settings - The audio settings to apply. + * @returns {Promise} + */ +export async function applyAudioConstraints(stateful: IStateful, settings: IAudioSettings) { + const state = toState(stateful); + const track = getLocalJitsiAudioTrack(state); + + if (!track) { + logger.debug('No local audio track found'); + + return; + } + + if (!isAdvancedAudioSettingsEnabled(state)) { + logger.debug('Advanced audio settings disabled'); + + return; + } + + try { + await track.applyConstraints(settings); + } catch (error) { + logger.error('Failed to apply audio constraints ', error); + } +} diff --git a/react/features/base/tracks/middleware.any.ts b/react/features/base/tracks/middleware.any.ts index 4350b97e98..5e92f7b17d 100644 --- a/react/features/base/tracks/middleware.any.ts +++ b/react/features/base/tracks/middleware.any.ts @@ -4,7 +4,6 @@ import { IStore } from '../../app/types'; import { _RESET_BREAKOUT_ROOMS } from '../../breakout-rooms/actionTypes'; import { getCurrentConference } from '../conference/functions'; import { - SET_AUDIO_MUTED, SET_CAMERA_FACING_MODE, SET_SCREENSHARE_MUTED, SET_VIDEO_MUTED, @@ -46,15 +45,6 @@ import './subscriber'; */ MiddlewareRegistry.register(store => next => action => { switch (action.type) { - case SET_AUDIO_MUTED: - if (!action.muted - && isUserInteractionRequiredForUnmute(store.getState())) { - return; - } - - _setMuted(store, action, MEDIA_TYPE.AUDIO); - break; - case SET_CAMERA_FACING_MODE: { // XXX The camera facing mode of a MediaStreamTrack can be specified // only at initialization time and then it can only be toggled. So in diff --git a/react/features/base/tracks/middleware.native.ts b/react/features/base/tracks/middleware.native.ts index b5d64a00be..c3a0e9d905 100644 --- a/react/features/base/tracks/middleware.native.ts +++ b/react/features/base/tracks/middleware.native.ts @@ -1,3 +1,5 @@ +import { IStore } from '../../app/types'; +import { SET_AUDIO_MUTED } from '../media/actionTypes'; import { MEDIA_TYPE, VIDEO_TYPE @@ -8,8 +10,11 @@ import { TRACK_UPDATED } from './actionTypes'; import { - toggleScreensharing + createLocalTracksA, + toggleScreensharing, + trackMuteUnmuteFailed } from './actions.native'; +import { getLocalTrack, setTrackMuted } from './functions.any'; import './middleware.any'; @@ -23,11 +28,15 @@ import './middleware.any'; */ MiddlewareRegistry.register(store => next => action => { switch (action.type) { + case SET_AUDIO_MUTED: { + _setMuted(store, action); + break; + } case TRACK_UPDATED: { const { jitsiTrack, local } = action.track; if (local && jitsiTrack.isMuted() - && jitsiTrack.type === MEDIA_TYPE.VIDEO && jitsiTrack.videoType === VIDEO_TYPE.DESKTOP) { + && jitsiTrack.type === MEDIA_TYPE.VIDEO && jitsiTrack.videoType === VIDEO_TYPE.DESKTOP) { store.dispatch(toggleScreensharing(false)); } break; @@ -36,3 +45,32 @@ MiddlewareRegistry.register(store => next => action => { return next(action); }); + +/** + * Mutes or unmutes a local track with a specific media type. + * + * @param {Store} store - The redux store in which the specified action is dispatched. + * @param {Action} action - The redux action dispatched in the specified store. + * @private + * @returns {void} + */ +function _setMuted(store: IStore, { ensureTrack, muted }: { + ensureTrack: boolean; muted: boolean; }) { + const { dispatch, getState } = store; + const state = getState(); + const localTrack = getLocalTrack(state['features/base/tracks'], MEDIA_TYPE.AUDIO, /* includePending */ true); + + if (localTrack) { + // The `jitsiTrack` property will have a value only for a localTrack for which `getUserMedia` has already + // completed. If there's no `jitsiTrack`, then the `muted` state will be applied once the `jitsiTrack` is + // created. + const { jitsiTrack } = localTrack; + + if (jitsiTrack) { + setTrackMuted(jitsiTrack, muted, state, dispatch) + .catch(() => dispatch(trackMuteUnmuteFailed(localTrack, muted))); + } + } else if (!muted && ensureTrack) { + dispatch(createLocalTracksA({ devices: [ MEDIA_TYPE.AUDIO ] })); + } +} diff --git a/react/features/base/tracks/middleware.web.ts b/react/features/base/tracks/middleware.web.ts index 5bb3283323..dd332aeb46 100644 --- a/react/features/base/tracks/middleware.web.ts +++ b/react/features/base/tracks/middleware.web.ts @@ -3,12 +3,15 @@ import { AnyAction } from 'redux'; import { IStore } from '../../app/types'; import { hideNotification } from '../../notifications/actions'; import { isPrejoinPageVisible } from '../../prejoin/functions'; +import { setAudioSettings } from '../../settings/actions.web'; import { getAvailableDevices } from '../devices/actions.web'; -import { setScreenshareMuted } from '../media/actions'; +import { SET_AUDIO_MUTED } from '../media/actionTypes'; +import { gumPending, setScreenshareMuted } from '../media/actions'; import { MEDIA_TYPE, VIDEO_TYPE } from '../media/constants'; +import { IGUMPendingState } from '../media/types'; import MiddlewareRegistry from '../redux/MiddlewareRegistry'; import { @@ -20,14 +23,19 @@ import { TRACK_UPDATED } from './actionTypes'; import { + createLocalTracksA, showNoDataFromSourceVideoError, toggleScreensharing, + trackMuteUnmuteFailed, trackNoDataFromSourceNotificationInfoChanged } from './actions.web'; import { - getTrackByJitsiTrack, logTracksForParticipant + getLocalJitsiAudioTrackSettings, + getLocalTrack, + getTrackByJitsiTrack, isUserInteractionRequiredForUnmute, logTracksForParticipant, + setTrackMuted } from './functions.web'; -import { ITrack } from './types'; +import { ITrack, ITrackOptions } from './types'; import './middleware.any'; @@ -138,7 +146,15 @@ MiddlewareRegistry.register(store => next => action => { return result; } + case SET_AUDIO_MUTED: { + if (!action.muted + && isUserInteractionRequiredForUnmute(store.getState())) { + return; + } + _setMuted(store, action); + break; + } } return next(action); @@ -207,3 +223,47 @@ function _removeNoDataFromSourceNotification({ getState, dispatch }: IStore, tra dispatch(trackNoDataFromSourceNotificationInfoChanged(jitsiTrack, undefined)); } } + +/** + * Mutes or unmutes a local track with a specific media type. + * + * @param {Store} store - The redux store in which the specified action is + * dispatched. + * @param {Action} action - The redux action dispatched in the specified store. + * @private + * @returns {void} + */ +function _setMuted(store: IStore, { ensureTrack, muted }: { + ensureTrack: boolean; muted: boolean; }) { + const { dispatch, getState } = store; + const state = getState(); + const localTrack = getLocalTrack(state['features/base/tracks'], MEDIA_TYPE.AUDIO, /* includePending */ true); + + if (localTrack) { + // The `jitsiTrack` property will have a value only for a localTrack for which `getUserMedia` has already + // completed. If there's no `jitsiTrack`, then the `muted` state will be applied once the `jitsiTrack` is + // created. + const { jitsiTrack } = localTrack; + + if (jitsiTrack) { + setTrackMuted(jitsiTrack, muted, state, dispatch) + .catch(() => { + dispatch(trackMuteUnmuteFailed(localTrack, muted)); + }); + } + } else if (!muted && ensureTrack) { + // TODO(saghul): reconcile these 2 types. + dispatch(gumPending([ MEDIA_TYPE.AUDIO ], IGUMPendingState.PENDING_UNMUTE)); + + const createTrackOptions: ITrackOptions = { + devices: [ MEDIA_TYPE.AUDIO ], + }; + + dispatch(createLocalTracksA(createTrackOptions)).then(() => { + dispatch(gumPending([ MEDIA_TYPE.AUDIO ], IGUMPendingState.NONE)); + const updatedSettings = getLocalJitsiAudioTrackSettings(getState()); + + dispatch(setAudioSettings(updatedSettings)); + }); + } +} diff --git a/react/features/base/tracks/types.ts b/react/features/base/tracks/types.ts index f5f5ce2885..6cc23dffe3 100644 --- a/react/features/base/tracks/types.ts +++ b/react/features/base/tracks/types.ts @@ -1,8 +1,10 @@ import { MediaType } from '../media/constants'; +import { IAudioSettings } from '../settings/reducer'; export interface ITrackOptions { cameraDeviceId?: string | null; constraints?: { + audio?: IAudioSettings; video?: { height?: { ideal?: number; diff --git a/react/features/device-selection/actions.web.ts b/react/features/device-selection/actions.web.ts index fd150a28b6..b6b9de26ce 100644 --- a/react/features/device-selection/actions.web.ts +++ b/react/features/device-selection/actions.web.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash-es'; + import { createDeviceChangedEvent } from '../analytics/AnalyticsEvents'; import { sendAnalytics } from '../analytics/functions'; import { IStore } from '../app/types'; @@ -7,8 +9,12 @@ import { } from '../base/devices/actions'; import { getDeviceLabelById, setAudioOutputDeviceId } from '../base/devices/functions'; import { updateSettings } from '../base/settings/actions'; +import { toggleUpdateAudioSettings } from '../base/tracks/actions.web'; +import { getLocalJitsiAudioTrack } from '../base/tracks/functions.any'; import { toggleNoiseSuppression } from '../noise-suppression/actions'; -import { setScreenshareFramerate } from '../screen-share/actions'; +import { setScreenshareFramerate } from '../screen-share/actions.web'; +import { setAudioSettings } from '../settings/actions.web'; +import { disposePreviewAudioTrack } from '../settings/functions.web'; import { getAudioDeviceSelectionDialogProps, getVideoDeviceSelectionDialogProps } from './functions'; import logger from './logger'; @@ -22,16 +28,21 @@ import logger from './logger'; * @returns {Function} */ export function submitAudioDeviceSelectionTab(newState: any, isDisplayedOnWelcomePage: boolean) { - return (dispatch: IStore['dispatch'], getState: IStore['getState']) => { + return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => { const currentState = getAudioDeviceSelectionDialogProps(getState(), isDisplayedOnWelcomePage); + const isSelectedAudioInputIdChanged = newState.selectedAudioInputId + && newState.selectedAudioInputId !== currentState.selectedAudioInputId; - if (newState.selectedAudioInputId && newState.selectedAudioInputId !== currentState.selectedAudioInputId) { + if (isSelectedAudioInputIdChanged) { dispatch(updateSettings({ userSelectedMicDeviceId: newState.selectedAudioInputId, userSelectedMicDeviceLabel: getDeviceLabelById(getState(), newState.selectedAudioInputId, 'audioInput') })); + await disposePreviewAudioTrack(getState()); + + dispatch(setAudioSettings(newState.audioSettings)); dispatch(setAudioInputDevice(newState.selectedAudioInputId)); } @@ -58,6 +69,21 @@ export function submitAudioDeviceSelectionTab(newState: any, isDisplayedOnWelcom if (newState.noiseSuppressionEnabled !== currentState.noiseSuppressionEnabled) { dispatch(toggleNoiseSuppression()); } + + if (!isEqual(newState.audioSettings, currentState.audioSettings) && !isSelectedAudioInputIdChanged && !newState.noiseSuppressionEnabled) { + const state = getState(); + const jitsiTrack = getLocalJitsiAudioTrack(state); + + if (!jitsiTrack) { + logger.debug('No local audio track found'); + dispatch(setAudioSettings(newState.audioSettings)); + + return; + } + + await disposePreviewAudioTrack(getState()); + dispatch(toggleUpdateAudioSettings(newState.audioSettings)); + } }; } diff --git a/react/features/device-selection/components/AudioDevicesSelection.web.tsx b/react/features/device-selection/components/AudioDevicesSelection.web.tsx index f1607d7c6a..4e2699e740 100644 --- a/react/features/device-selection/components/AudioDevicesSelection.web.tsx +++ b/react/features/device-selection/components/AudioDevicesSelection.web.tsx @@ -1,30 +1,41 @@ import { Theme } from '@mui/material'; +import clsx from 'clsx'; import React from 'react'; import { WithTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import { withStyles } from 'tss-react/mui'; import { IReduxState, IStore } from '../../app/types'; +import { isAdvancedAudioSettingsEnabled } from '../../base/config/functions.any'; import { getAvailableDevices } from '../../base/devices/actions.web'; import AbstractDialogTab, { type IProps as AbstractDialogTabProps } from '../../base/dialog/components/web/AbstractDialogTab'; import { translate } from '../../base/i18n/functions'; import { createLocalTrack } from '../../base/lib-jitsi-meet/functions.web'; +import { IAudioSettings } from '../../base/settings/reducer'; import Checkbox from '../../base/ui/components/web/Checkbox'; +import { setPreviewAudioTrack } from '../../settings/actions.web'; +import { disposeTrack } from '../../settings/functions.web'; import { iAmVisitor as iAmVisitorCheck } from '../../visitors/functions'; import logger from '../logger'; -import AudioInputPreview from './AudioInputPreview'; -import AudioOutputPreview from './AudioOutputPreview'; +import AudioInputPreview from './AudioInputPreview.web'; +import AudioOutputPreview from './AudioOutputPreview.web'; import DeviceHidContainer from './DeviceHidContainer.web'; import DeviceSelector from './DeviceSelector.web'; + /** * The type of the React {@code Component} props of {@link AudioDevicesSelection}. */ interface IProps extends AbstractDialogTabProps, WithTranslation { + /** + * The audio local track settings. + */ + audioSettings?: IAudioSettings; + /** * All known audio and video devices split by type. This prop comes from * the app state. @@ -98,11 +109,21 @@ interface IProps extends AbstractDialogTabProps, WithTranslation { */ iAmVisitor: boolean; + /** + * Whether the advanced audio settings are enabled from config. + */ + isAdvancedAudioSettingsConfigEnabled: boolean; + /** * Whether noise suppression is on or not. */ noiseSuppressionEnabled: boolean; + /** + * The audio track that is used for previewing the audio input. + */ + previewAudioTrack: any | null; + /** * The id of the audio input device to preview. */ @@ -114,19 +135,20 @@ interface IProps extends AbstractDialogTabProps, WithTranslation { selectedAudioOutputId: string; } -/** - * The type of the React {@code Component} state of {@link AudioDevicesSelection}. - */ -interface IState { - - /** - * The JitsiTrack to use for previewing audio input. - */ - previewAudioTrack?: any | null; -} const styles = (theme: Theme) => { return { + checkbox: { + width: 'max-content' + }, + + checkboxGrid: { + display: 'grid', + gridTemplateColumns: 'auto auto', + gap: theme.spacing(3), + margin: `${theme.spacing(3)} 0` + }, + container: { display: 'flex', flexDirection: 'column' as const, @@ -134,32 +156,33 @@ const styles = (theme: Theme) => { width: '100%' }, + hidden: { + display: 'none' + }, + inputContainer: { marginBottom: theme.spacing(3) }, outputContainer: { - margin: `${theme.spacing(5)} 0`, display: 'flex', - alignItems: 'flex-end' + alignItems: 'flex-end', + margin: `${theme.spacing(5)} 0` }, outputButton: { marginLeft: theme.spacing(3) - }, - - noiseSuppressionContainer: { - marginBottom: theme.spacing(5) } }; }; + /** * React {@code Component} for previewing audio and video input/output devices. * * @augments Component */ -class AudioDevicesSelection extends AbstractDialogTab { +class AudioDevicesSelection extends AbstractDialogTab { /** * Whether current component is mounted or not. @@ -181,9 +204,6 @@ class AudioDevicesSelection extends AbstractDialogTab { constructor(props: IProps) { super(props); - this.state = { - previewAudioTrack: null - }; this._unMounted = true; } @@ -224,9 +244,29 @@ class AudioDevicesSelection extends AbstractDialogTab { */ override componentWillUnmount() { this._unMounted = true; - this._disposeAudioInputPreview(); + disposeTrack(this.props.previewAudioTrack); } + /** + * Toggles the audio settings based on the input change event and updates the state. + * + * @param {Object} e - The key event to handle. + * + * @returns {void} + */ + _onToggleAudioSettings = ({ target: { checked, name } }: React.ChangeEvent) => { + const { audioSettings } = this.props; + + const newValue = name === 'channelCount' ? (checked ? 2 : 1) : checked; + + super._onChange({ + audioSettings: { + ...audioSettings, + [name]: newValue + } + }); + }; + /** * Implements React's {@link Component#render()}. * @@ -234,11 +274,13 @@ class AudioDevicesSelection extends AbstractDialogTab { */ override render() { const { + audioSettings, hasAudioPermission, hideAudioInputPreview, hideAudioOutputPreview, hideDeviceHIDContainer, hideNoiseSuppression, + isAdvancedAudioSettingsConfigEnabled, iAmVisitor, noiseSuppressionEnabled, selectedAudioOutputId, @@ -248,6 +290,13 @@ class AudioDevicesSelection extends AbstractDialogTab { const classes = withStyles.getClasses(this.props); + const isAudioSettingsEnabled = Boolean(audioSettings?.autoGainControl || audioSettings?.channelCount === 2 || audioSettings?.echoCancellation || audioSettings?.noiseSuppression); + + const shouldDisplayNoiseSuppressionCheckbox = !hideNoiseSuppression && !iAmVisitor; + const shouldDisplayAdvancedAudioSettingsCheckboxes = !hideNoiseSuppression && !iAmVisitor && isAdvancedAudioSettingsConfigEnabled && Boolean(audioSettings); + + const shouldDisabledNoiseSupressionCheckbox = shouldDisplayAdvancedAudioSettingsCheckboxes && (isAudioSettingsEnabled && !noiseSuppressionEnabled); + return (
{!iAmVisitor &&
{ className = { classes.inputContainer }> {this._renderSelector(audioInput)}
} + {!hideAudioInputPreview && hasAudioPermission && !iAmVisitor - && } + && } + +
+ + + + + super._onChange({ + noiseSuppressionEnabled: !noiseSuppressionEnabled + }) } /> +
@@ -267,17 +367,7 @@ class AudioDevicesSelection extends AbstractDialogTab { className = { classes.outputButton } deviceId = { selectedAudioOutputId } />}
- {!hideNoiseSuppression && !iAmVisitor && ( -
- super._onChange({ - noiseSuppressionEnabled: !noiseSuppressionEnabled - }) } /> -
- )} + {!hideDeviceHIDContainer && !iAmVisitor && }
@@ -292,13 +382,13 @@ class AudioDevicesSelection extends AbstractDialogTab { * @returns {void} */ _createAudioInputTrack(deviceId: string) { - const { hideAudioInputPreview } = this.props; + const { hideAudioInputPreview, previewAudioTrack } = this.props; if (hideAudioInputPreview) { return; } - return this._disposeAudioInputPreview() + return disposeTrack(previewAudioTrack) .then(() => createLocalTrack('audio', deviceId, 5000)) .then(jitsiLocalTrack => { if (this._unMounted) { @@ -306,29 +396,14 @@ class AudioDevicesSelection extends AbstractDialogTab { return; } + this.props.dispatch(setPreviewAudioTrack(jitsiLocalTrack)); - this.setState({ - previewAudioTrack: jitsiLocalTrack - }); }) .catch(() => { - this.setState({ - previewAudioTrack: null - }); + this.props.dispatch(setPreviewAudioTrack(null)); }); } - /** - * Utility function for disposing the current audio input preview. - * - * @private - * @returns {Promise} - */ - _disposeAudioInputPreview(): Promise { - return this.state.previewAudioTrack - ? this.state.previewAudioTrack.dispose() : Promise.resolve(); - } - /** * Creates a DeviceSelector instance based on the passed in configuration. * @@ -362,8 +437,8 @@ class AudioDevicesSelection extends AbstractDialogTab { id: 'audioInput', label: 'settings.selectMic', onSelect: (selectedAudioInputId: string) => super._onChange({ selectedAudioInputId }), - selectedDeviceId: this.state.previewAudioTrack - ? this.state.previewAudioTrack.getDeviceId() : this.props.selectedAudioInputId + selectedDeviceId: this.props.previewAudioTrack + ? this.props.previewAudioTrack.getDeviceId() : this.props.selectedAudioInputId }; let audioOutput; @@ -389,7 +464,9 @@ class AudioDevicesSelection extends AbstractDialogTab { const mapStateToProps = (state: IReduxState) => { return { availableDevices: state['features/base/devices'].availableDevices ?? {}, - iAmVisitor: iAmVisitorCheck(state) + isAdvancedAudioSettingsConfigEnabled: isAdvancedAudioSettingsEnabled(state), + iAmVisitor: iAmVisitorCheck(state), + previewAudioTrack: state['features/settings'].previewAudioTrack }; }; diff --git a/react/features/device-selection/functions.web.ts b/react/features/device-selection/functions.web.ts index 725a58817c..3c268c1a05 100644 --- a/react/features/device-selection/functions.web.ts +++ b/react/features/device-selection/functions.web.ts @@ -22,6 +22,7 @@ import { getUserSelectedMicDeviceId, getUserSelectedOutputDeviceId } from '../base/settings/functions.web'; +import { getLocalJitsiAudioTrackSettings } from '../base/tracks/functions.any'; import { isNoiseSuppressionEnabled } from '../noise-suppression/functions'; import { isPrejoinPageVisible } from '../prejoin/functions'; import { SS_DEFAULT_FRAME_RATE, SS_SUPPORTED_FRAMERATES } from '../settings/constants'; @@ -50,6 +51,7 @@ export function getAudioDeviceSelectionDialogProps(stateful: IStateful, isDispla const deviceHidSupported = isDeviceHidSupported() && getWebHIDFeatureConfig(state); const noiseSuppressionEnabled = isNoiseSuppressionEnabled(state); const hideNoiseSuppression = isPrejoinPageVisible(state) || isDisplayedOnWelcomePage; + const audioSettings = state['features/settings'].audioSettings ?? getLocalJitsiAudioTrackSettings(state); // When the previews are disabled we don't need multiple audio input support in order to change the mic. This is the // case for Safari on iOS. @@ -71,6 +73,7 @@ export function getAudioDeviceSelectionDialogProps(stateful: IStateful, isDispla // we fill the device selection dialog with the devices that are currently // used or if none are currently used with what we have in settings(user selected) return { + audioSettings, disableAudioInputChange, disableDeviceChange: !JitsiMeetJS.mediaDevices.isDeviceChangeAvailable(), hasAudioPermission: permissions.audio, diff --git a/react/features/settings/actionTypes.ts b/react/features/settings/actionTypes.ts index be77c44253..cfe3392b78 100644 --- a/react/features/settings/actionTypes.ts +++ b/react/features/settings/actionTypes.ts @@ -1,3 +1,13 @@ +/** + * The type of (redux) action which sets the audio settings. + */ +export const SET_AUDIO_SETTINGS = 'SET_AUDIO_SETTINGS'; + +/** + * The type of (redux) action which sets the preview audio track settings. + */ +export const SET_PREVIEW_AUDIO_TRACK = 'SET_PREVIEW_AUDIO_TRACK'; + /** * The type of (redux) action which sets the visibility of the audio settings popup. */ diff --git a/react/features/settings/actions.web.ts b/react/features/settings/actions.web.ts index b7152dd3a3..a7cc2f9a7f 100644 --- a/react/features/settings/actions.web.ts +++ b/react/features/settings/actions.web.ts @@ -16,6 +16,7 @@ import i18next from '../base/i18n/i18next'; import { browser } from '../base/lib-jitsi-meet'; import { getNormalizedDisplayName } from '../base/participants/functions'; import { updateSettings } from '../base/settings/actions'; +import { IAudioSettings } from '../base/settings/reducer'; import { getLocalVideoTrack } from '../base/tracks/functions.web'; import { appendURLHashParam } from '../base/util/uri'; import { disableKeyboardShortcuts, enableKeyboardShortcuts } from '../keyboard-shortcuts/actions'; @@ -23,7 +24,9 @@ import { toggleBackgroundEffect } from '../virtual-background/actions'; import virtualBackgroundLogger from '../virtual-background/logger'; import { + SET_AUDIO_SETTINGS, SET_AUDIO_SETTINGS_VISIBILITY, + SET_PREVIEW_AUDIO_TRACK, SET_VIDEO_SETTINGS_VISIBILITY } from './actionTypes'; import LogoutDialog from './components/web/LogoutDialog'; @@ -36,7 +39,6 @@ import { getShortcutsTabProps } from './functions.web'; - /** * Opens {@code LogoutDialog}. * @@ -340,3 +342,35 @@ export function submitVirtualBackgroundTab(newState: any, isCancel = false) { } }; } + +/** + * Sets the audio preview track. + * + * @param {any} track - The track to set. + * @returns {{ + * type: SET_PREVIEW_AUDIO_TRACK, + * track: any + * }} + */ +export function setPreviewAudioTrack(track: any) { + return { + type: SET_PREVIEW_AUDIO_TRACK, + track + }; +} + +/** + * Sets the audio settings. + * + * @param {IAudioSettings} settings - The settings to set. + * @returns {{ + * type: SET_AUDIO_SETTINGS, + * settings: IAudioSettings + * }} + */ +export function setAudioSettings(settings: IAudioSettings) { + return { + type: SET_AUDIO_SETTINGS, + settings + }; +} diff --git a/react/features/settings/components/web/SettingsDialog.tsx b/react/features/settings/components/web/SettingsDialog.tsx index 1b8b500b60..30334fb85c 100644 --- a/react/features/settings/components/web/SettingsDialog.tsx +++ b/react/features/settings/components/web/SettingsDialog.tsx @@ -17,8 +17,8 @@ import { import DialogWithTabs, { IDialogTab } from '../../../base/ui/components/web/DialogWithTabs'; import { isCalendarEnabled } from '../../../calendar-sync/functions.web'; import { submitAudioDeviceSelectionTab, submitVideoDeviceSelectionTab } from '../../../device-selection/actions.web'; -import AudioDevicesSelection from '../../../device-selection/components/AudioDevicesSelection'; -import VideoDeviceSelection from '../../../device-selection/components/VideoDeviceSelection'; +import AudioDevicesSelection from '../../../device-selection/components/AudioDevicesSelection.web'; +import VideoDeviceSelection from '../../../device-selection/components/VideoDeviceSelection.web'; import { getAudioDeviceSelectionDialogProps, getVideoDeviceSelectionDialogProps @@ -160,6 +160,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) { return { ...newProps, + audioSettings: tabState.audioSettings, noiseSuppressionEnabled: tabState.noiseSuppressionEnabled, selectedAudioInputId: tabState.selectedAudioInputId, selectedAudioOutputId: tabState.selectedAudioOutputId diff --git a/react/features/settings/functions.web.ts b/react/features/settings/functions.web.ts index 2e16ad8f75..6362c30e9c 100644 --- a/react/features/settings/functions.web.ts +++ b/react/features/settings/functions.web.ts @@ -145,3 +145,31 @@ export function shouldShowModeratorSettings(stateful: IStateful) { return hasModeratorRights && !hideModeratorSettingsTab; } + +/** + * Disposes a track. + * + * @param {Object} track - The track to dispose. + * @returns {Promise} + */ +export async function disposeTrack(track: any) { + if (!track) { + return; + } + + await track.dispose(); +} + +/** + * Disposes the audio input preview track from Redux state. + * + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's + * {@code getState} function to be used to retrieve the state. + * @returns {Promise} + */ +export async function disposePreviewAudioTrack(stateful: IStateful) { + const state = toState(stateful); + const previewTrack = state['features/settings']?.previewAudioTrack; + + await disposeTrack(previewTrack); +} diff --git a/react/features/settings/reducer.ts b/react/features/settings/reducer.ts index cd973db8d8..314007f3b6 100644 --- a/react/features/settings/reducer.ts +++ b/react/features/settings/reducer.ts @@ -1,12 +1,17 @@ import ReducerRegistry from '../base/redux/ReducerRegistry'; +import { IAudioSettings } from '../base/settings/reducer'; import { + SET_AUDIO_SETTINGS, SET_AUDIO_SETTINGS_VISIBILITY, + SET_PREVIEW_AUDIO_TRACK, SET_VIDEO_SETTINGS_VISIBILITY } from './actionTypes'; export interface ISettingsState { + audioSettings?: IAudioSettings; audioSettingsVisible?: boolean; + previewAudioTrack?: any | null; videoSettingsVisible?: boolean; } @@ -22,6 +27,16 @@ ReducerRegistry.register('features/settings', (state: ISettingsState = {}, actio ...state, videoSettingsVisible: action.value }; + case SET_PREVIEW_AUDIO_TRACK: + return { + ...state, + previewAudioTrack: action.track + }; + case SET_AUDIO_SETTINGS: + return { + ...state, + audioSettings: action.settings + }; } return state;