From d2c2919aefad3122457425b80ec7d6df714cce91 Mon Sep 17 00:00:00 2001 From: Andrei Gavrilescu <51706180+andrei-gavrilescu@users.noreply.github.com> Date: Mon, 20 Jan 2020 20:00:12 +0200 Subject: [PATCH] feat: display noise detection notification (#4952) * feat: display noise detection notification * address code review p1 * Address code review p2 * bump lib-jitsi-meet version --- config.js | 8 ++- lang/main.json | 2 + package-lock.json | 4 +- package.json | 2 +- react/features/app/components/App.web.js | 1 + react/features/no-audio-signal/middleware.js | 12 ---- react/features/noise-detection/actionTypes.js | 11 ++++ react/features/noise-detection/actions.js | 21 +++++++ react/features/noise-detection/constants.js | 6 ++ react/features/noise-detection/index.js | 4 ++ react/features/noise-detection/middleware.js | 57 ++++++++++++++++++ react/features/noise-detection/reducer.js | 17 ++++++ react/features/noise-detection/sounds.js | 6 ++ sounds/noisyAudioInput.mp3 | Bin 0 -> 9613 bytes 14 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 react/features/noise-detection/actionTypes.js create mode 100644 react/features/noise-detection/actions.js create mode 100644 react/features/noise-detection/constants.js create mode 100644 react/features/noise-detection/index.js create mode 100644 react/features/noise-detection/middleware.js create mode 100644 react/features/noise-detection/reducer.js create mode 100644 react/features/noise-detection/sounds.js create mode 100644 sounds/noisyAudioInput.mp3 diff --git a/config.js b/config.js index da1c05d2b..f774216f6 100644 --- a/config.js +++ b/config.js @@ -76,7 +76,13 @@ var config = { // Enabling this will run the lib-jitsi-meet no audio detection module which // will notify the user if the current selected microphone has no audio // input and will suggest another valid device if one is present. - // enableNoAudioDetection: false + enableNoAudioDetection: true, + + // Enabling this will run the lib-jitsi-meet noise detection module which will + // notify the user if there is noise, other than voice, coming from the current + // selected microphone. The purpose it to let the user know that the input could + // be potentially unpleasant for other meeting participants. + enableNoisyMicDetection: true, // Start the conference in audio only mode (no video is being received nor // sent). diff --git a/lang/main.json b/lang/main.json index 987d0193e..43649e622 100644 --- a/lang/main.json +++ b/lang/main.json @@ -634,6 +634,8 @@ "noAudioSignalTitle": "There is no input coming from your mic!", "noAudioSignalDesc": "If you did not purposely mute it from system settings or hardware, consider changing the device.", "noAudioSignalDescSuggestion": "If you did not purposely mute it from system settings or hardware, consider using the following device:", + "noisyAudioInputTitle": "Your microphone appears to be noisy!", + "noisyAudioInputDesc": "Jitsi has detected noise coming from your microphone, please consider muting or changing the device.", "openChat": "Open chat", "pip": "Enter Picture-in-Picture mode", "privateMessage": "Send private message", diff --git a/package-lock.json b/package-lock.json index a6fb81fa6..a55bfc924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10869,8 +10869,8 @@ } }, "lib-jitsi-meet": { - "version": "github:jitsi/lib-jitsi-meet#8deeaf6bd2b233ab163303e42febfee87201b2d9", - "from": "github:jitsi/lib-jitsi-meet#8deeaf6bd2b233ab163303e42febfee87201b2d9", + "version": "github:jitsi/lib-jitsi-meet#e4b523d0fab83a1cf1408abbf41f5d0ca1169900", + "from": "github:jitsi/lib-jitsi-meet#e4b523d0fab83a1cf1408abbf41f5d0ca1169900", "requires": { "@jitsi/sdp-interop": "0.1.14", "@jitsi/sdp-simulcast": "0.2.2", diff --git a/package.json b/package.json index 8a1b207e6..495baa8c7 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "js-utils": "github:jitsi/js-utils#400ce825d3565019946ee75d86ed773c6f21e117", "jsrsasign": "8.0.12", "jwt-decode": "2.2.0", - "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#8deeaf6bd2b233ab163303e42febfee87201b2d9", + "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#e4b523d0fab83a1cf1408abbf41f5d0ca1169900", "libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d", "lodash": "4.17.13", "moment": "2.19.4", diff --git a/react/features/app/components/App.web.js b/react/features/app/components/App.web.js index 670183a95..56b23c0d5 100644 --- a/react/features/app/components/App.web.js +++ b/react/features/app/components/App.web.js @@ -8,6 +8,7 @@ import '../../base/user-interaction'; import '../../chat'; import '../../external-api'; import '../../no-audio-signal'; +import '../../noise-detection'; import '../../power-monitor'; import '../../room-lock'; import '../../talk-while-muted'; diff --git a/react/features/no-audio-signal/middleware.js b/react/features/no-audio-signal/middleware.js index bf30d1ed7..148e9fa7c 100644 --- a/react/features/no-audio-signal/middleware.js +++ b/react/features/no-audio-signal/middleware.js @@ -48,13 +48,10 @@ MiddlewareRegistry.register(store => next => async action => { async function _handleNoAudioSignalNotification({ dispatch, getState }, action) { const { conference } = action; - let confAudioInputState; conference.on(JitsiConferenceEvents.AUDIO_INPUT_STATE_CHANGE, hasAudioInput => { const { noAudioSignalNotificationUid } = getState()['features/no-audio-signal']; - confAudioInputState = hasAudioInput; - // In case the notification is displayed but the conference detected audio input signal we hide it. if (noAudioSignalNotificationUid && hasAudioInput) { dispatch(hideNotification(noAudioSignalNotificationUid)); @@ -71,17 +68,8 @@ async function _handleNoAudioSignalNotification({ dispatch, getState }, action) return; } - // Force the flag to false in case AUDIO_INPUT_STATE_CHANGE is received after the notification is displayed, - // possibly preventing the notification from displaying because of an outdated state. - confAudioInputState = false; - - const activeDevice = await JitsiMeetJS.getActiveAudioDevice(); - if (confAudioInputState) { - return; - } - // In case there is a previous notification displayed just hide it. const { noAudioSignalNotificationUid } = getState()['features/no-audio-signal']; diff --git a/react/features/noise-detection/actionTypes.js b/react/features/noise-detection/actionTypes.js new file mode 100644 index 000000000..a97d99fe0 --- /dev/null +++ b/react/features/noise-detection/actionTypes.js @@ -0,0 +1,11 @@ +/** + * The type of Redux action which sets the pending notification UID + * to use it when hiding the notification is necessary, or unset it when + * undefined (or no param) is passed. + * + * { + * type: SET_NOISY_AUDIO_INPUT_NOTIFICATION_UID + * uid: ?number + * } + */ +export const SET_NOISY_AUDIO_INPUT_NOTIFICATION_UID = 'SET_NOISY_AUDIO_INPUT_NOTIFICATION_UID'; diff --git a/react/features/noise-detection/actions.js b/react/features/noise-detection/actions.js new file mode 100644 index 000000000..a4bdb2266 --- /dev/null +++ b/react/features/noise-detection/actions.js @@ -0,0 +1,21 @@ +// @flow + +import { SET_NOISY_AUDIO_INPUT_NOTIFICATION_UID } from './actionTypes'; + +/** + * Sets UID of the the pending notification to use it when hiding + * the notification is necessary, or unset it when undefined (or no param) is + * passed. + * + * @param {?number} uid - The UID of the notification. + * @returns {{ + * type: SET_NOISY_AUDIO_INPUT_NOTIFICATION_UID, + * uid: number + * }} + */ +export function setNoisyAudioInputNotificationUid(uid: ?number) { + return { + type: SET_NOISY_AUDIO_INPUT_NOTIFICATION_UID, + uid + }; +} diff --git a/react/features/noise-detection/constants.js b/react/features/noise-detection/constants.js new file mode 100644 index 000000000..231300e86 --- /dev/null +++ b/react/features/noise-detection/constants.js @@ -0,0 +1,6 @@ +/** + * The identifier of the sound to be played when we display a you are noisy notification. + * + * @type {string} + */ +export const NOISY_AUDIO_INPUT_SOUND_ID = 'NOISY_AUDIO_INPUT_SOUND_ID'; diff --git a/react/features/noise-detection/index.js b/react/features/noise-detection/index.js new file mode 100644 index 000000000..d040422d0 --- /dev/null +++ b/react/features/noise-detection/index.js @@ -0,0 +1,4 @@ +// @flow + +import './middleware'; +import './reducer'; diff --git a/react/features/noise-detection/middleware.js b/react/features/noise-detection/middleware.js new file mode 100644 index 000000000..2e7df5361 --- /dev/null +++ b/react/features/noise-detection/middleware.js @@ -0,0 +1,57 @@ +// @flow + +import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app'; +import { CONFERENCE_JOINED } from '../base/conference'; +import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; +import { MiddlewareRegistry } from '../base/redux'; +import { playSound, registerSound, unregisterSound } from '../base/sounds'; +import { hideNotification, showNotification } from '../notifications'; + +import { setNoisyAudioInputNotificationUid } from './actions'; +import { NOISY_AUDIO_INPUT_SOUND_ID } from './constants'; +import { NOISY_AUDIO_INPUT_SOUND_FILE } from './sounds'; + +MiddlewareRegistry.register(store => next => action => { + const result = next(action); + + switch (action.type) { + case APP_WILL_MOUNT: + store.dispatch(registerSound(NOISY_AUDIO_INPUT_SOUND_ID, NOISY_AUDIO_INPUT_SOUND_FILE)); + break; + case APP_WILL_UNMOUNT: + store.dispatch(unregisterSound(NOISY_AUDIO_INPUT_SOUND_ID)); + break; + case CONFERENCE_JOINED: { + const { dispatch, getState } = store; + const { conference } = action; + + conference.on( + JitsiConferenceEvents.TRACK_MUTE_CHANGED, + track => { + const { noisyAudioInputNotificationUid } = getState()['features/noise-detection']; + + // Hide the notification in case the user mutes the microphone + if (noisyAudioInputNotificationUid && track.isAudioTrack() && track.isLocal() && track.isMuted()) { + dispatch(hideNotification(noisyAudioInputNotificationUid)); + dispatch(setNoisyAudioInputNotificationUid()); + } + }); + conference.on( + JitsiConferenceEvents.NOISY_MIC, () => { + const notification = showNotification({ + titleKey: 'toolbar.noisyAudioInputTitle', + descriptionKey: 'toolbar.noisyAudioInputDesc' + }); + + dispatch(notification); + dispatch(playSound(NOISY_AUDIO_INPUT_SOUND_ID)); + + // we store the last notification id so we can hide it if the mic is muted + dispatch(setNoisyAudioInputNotificationUid(notification.uid)); + }); + break; + } + } + + return result; +}); diff --git a/react/features/noise-detection/reducer.js b/react/features/noise-detection/reducer.js new file mode 100644 index 000000000..5fbc2ecc7 --- /dev/null +++ b/react/features/noise-detection/reducer.js @@ -0,0 +1,17 @@ +// @flow + +import { ReducerRegistry, set } from '../base/redux'; + +import { SET_NOISY_AUDIO_INPUT_NOTIFICATION_UID } from './actionTypes'; + +/** + * Reduces the redux actions of noise detection feature + */ +ReducerRegistry.register('features/noise-detection', (state = {}, action) => { + switch (action.type) { + case SET_NOISY_AUDIO_INPUT_NOTIFICATION_UID: + return set(state, 'noisyAudioInputNotificationUid', action.uid); + } + + return state; +}); diff --git a/react/features/noise-detection/sounds.js b/react/features/noise-detection/sounds.js new file mode 100644 index 000000000..c949e249e --- /dev/null +++ b/react/features/noise-detection/sounds.js @@ -0,0 +1,6 @@ +/** + * The file used for the noisy audio input notification. + * + * @type {string} + */ +export const NOISY_AUDIO_INPUT_SOUND_FILE = 'noisyAudioInput.mp3'; diff --git a/sounds/noisyAudioInput.mp3 b/sounds/noisyAudioInput.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..cf07fb704cdd0afefe7f87e4f4c7917dd9cdc674 GIT binary patch literal 9613 zcmeI$*H;tTmk00=kS-HD_(j>>5x#Qgcgc)DWddV zq)JslM4AYK^?GHlzghDa%skG1I1gv7a~{rTuf4wOT>PCr2mo?Nef>_)KQ%u1qGb7; z6vol}lX27bsu3gm7(T7pYqLdiTbver(bSpRWrm+;tZHwgTj2 z3%mBiP3*#>8%7HYLw?&>L9QtHd%WHH{?D(*ZtR-|F~VZYM)@Mrja=*n1C z(eXGq3cycLy8pN#ZsXIb5F;iu0UUq(H)JfUj4?Pg!UGY-_|}J$e<@T0`4!yT9P*HL z0<0ay3M)__1;V+jBWVkVO{J3(N<1#*?|?RgjkXTm0GmRSrHt z>gJJI*5v7(cQ^QD{alt(AytZ@q2hsRTy?x`WUI&%@mKx-NJw&eY|HuZ8&wF#i-z$K zh*lh&6~Z1E4mp-{N8T^BKhL)%t0Yqya5v(D|9${3I!f- zOs6r#W_UC62s38{rZc8HC#j1oJ@un>gu`NET%pl@30Y}kuq^FlNzNYgxpiz7I|Mpl z5Y+smyT7EOEKk{cFcS$$N~@qO*)f>+Y@4~^{IAo4|xB~jZ%cwg=ZwoQrZ@-N5RU9|WToqaAH*B9Q0`8Ud)6czW zKf=wXLSkaRrvj*4f8o2O zL%afW>dnu2j7bLmCOgmh^i$>FE|uBuWbV9UK5}Xk^wrcC?W(iV3?L+SCV%^9Xxni! zq~{AC1IiKSMnd|H?q=y#_#b#eP-SD4)*%-!CrUn?ouyXzr;9kcY;_1m6pVd-kPmPU z6*O9<#|iyV#|m*px^sF6cnH(;iAH4(cEifRyK$J_DZn2g`AG0*cAL)eXd$(B_XZ6rN2)1VFvCxfDJpT;(Zyf=GJP+S85sF;clXd}gD0sG5VKVS z@Z-1ybg6c@^843T$uN_4A^-qpc>o&Co+&SVn@v#cvUgNNsrzUI0Q;omEQg2$>)&-P$WWcjR;q;7D{!c1k1vc`dzE{o8Ix}XfZB3dNA z#qY_e@9T}57A@oC#h)2HGnkVytZ&9fbuK3@qioZtaOpZ%Xb58hZ)~3~$uLBCiA+>^ z`{Z1;);jA;Kr;NwQ1z_^S)b2SvM(l%j@>PPtc~xINqe`yid1wxks>7xJ3Qx&%4z&f zC{j1SALLAsdGK-d^+R`Rp-vZ{mS59Ht?PDEUh-42yDj(5eksem{q^*b zdwlpCAug8dH`q_u{F_VM$?=}%qAfL zj6bx%;#;w6(#lMCU;x%zNL<^)mF_4Yi>hK}G)=x^Kv#RfL+P3M`DSi&yzc7c%P$uz zKMg@sRUAk&M@7e)@YR32z4vp+%R2}DM5Wx+w~c!neE)78j7XM;Z2c24X1`omFII!t z|E_#-`|pb-t0(x4RE^>@-w9!1FgLVoyx}#;aPRlXO95-&v%fAbHvYPC((QZOl11pH z^h?9%zN>CBbQQ+k)SqKf>40Hlr6{Y#Jl(4Dd_v{GJ>D-lVIDh=Uads-I!4OU)6%l^ zP9$-tVuabInrKtI0I!@K0i01Obem2k>P5g;F_q$`dB&a=8M=4auFeLURzFXk0SC?7 z=>^WfQ8*3qteVzp_$&%rZ-@$SXcZuWqXj2#yQ4fqyf-BH%r>|)jdNRbsgm6ix+Dg> z;!N2tcT=26W_k}*SHddgYB5Ur@23$uxYZn6_4oIEKKZ#cxlaxepLQqvnlXG!@9a~E z=`INB>^N#3>bxV7IZ3QgdSN_KQBFfINQ3<�j)TGXUi%izyO)j@BcTj*$;=|8^2t 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 literal 0 HcmV?d00001