bigbluebutton-Github/bigbluebutton-html5/imports/ui/services/audio-manager/index.js
Mario Jr 2e92de8010 Clean up HTMLAudioElement used for notification sounds
After ending the notification playback, we set the ".src" property to null, which immediately stop the internal player of mobile browser (tested on Chrome for Android -  device list is on #11458).
For the specific list of devices, this prevents the internal buffer error "-61" described in #11458.
Fixes #11458.
2021-02-23 11:12:37 -03:00

649 lines
18 KiB
JavaScript
Executable File

import { Tracker } from 'meteor/tracker';
import KurentoBridge from '/imports/api/audio/client/bridge/kurento';
import Auth from '/imports/ui/services/auth';
import VoiceUsers from '/imports/api/voice-users';
import SIPBridge from '/imports/api/audio/client/bridge/sip';
import logger from '/imports/startup/client/logger';
import { notify } from '/imports/ui/services/notification';
import playAndRetry from '/imports/utils/mediaElementPlayRetry';
import iosWebviewAudioPolyfills from '/imports/utils/ios-webview-audio-polyfills';
import { tryGenerateIceCandidates } from '/imports/utils/safari-webrtc';
import { monitorAudioConnection } from '/imports/utils/stats';
import AudioErrors from './error-codes';
const ENABLE_NETWORK_MONITORING = Meteor.settings.public.networkMonitoring.enableNetworkMonitoring;
const MEDIA = Meteor.settings.public.media;
const MEDIA_TAG = MEDIA.mediaTag;
const ECHO_TEST_NUMBER = MEDIA.echoTestNumber;
const MAX_LISTEN_ONLY_RETRIES = 1;
const LISTEN_ONLY_CALL_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 25000;
const DEFAULT_INPUT_DEVICE_ID = 'default';
const DEFAULT_OUTPUT_DEVICE_ID = 'default';
const CALL_STATES = {
STARTED: 'started',
ENDED: 'ended',
FAILED: 'failed',
RECONNECTING: 'reconnecting',
AUTOPLAY_BLOCKED: 'autoplayBlocked',
};
class AudioManager {
constructor() {
this._inputDevice = {
value: DEFAULT_INPUT_DEVICE_ID,
tracker: new Tracker.Dependency(),
};
this.defineProperties({
isMuted: false,
isConnected: false,
isConnecting: false,
isHangingUp: false,
isListenOnly: false,
isEchoTest: false,
isTalking: false,
isWaitingPermissions: false,
error: null,
outputDeviceId: null,
muteHandle: null,
autoplayBlocked: false,
});
this.useKurento = Meteor.settings.public.kurento.enableListenOnly;
this.failedMediaElements = [];
this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this);
this.monitor = this.monitor.bind(this);
}
init(userData, audioEventHandler) {
this.bridge = new SIPBridge(userData); // no alternative as of 2019-03-08
if (this.useKurento) {
this.listenOnlyBridge = new KurentoBridge(userData);
}
this.userData = userData;
this.initialized = true;
this.audioEventHandler = audioEventHandler;
}
setAudioMessages(messages, intl) {
this.messages = messages;
this.intl = intl;
}
defineProperties(obj) {
Object.keys(obj).forEach((key) => {
const privateKey = `_${key}`;
this[privateKey] = {
value: obj[key],
tracker: new Tracker.Dependency(),
};
Object.defineProperty(this, key, {
set: (value) => {
this[privateKey].value = value;
this[privateKey].tracker.changed();
},
get: () => {
this[privateKey].tracker.depend();
return this[privateKey].value;
},
});
});
}
joinMicrophone() {
this.isListenOnly = false;
this.isEchoTest = false;
return this.onAudioJoining.bind(this)()
.then(() => {
const callOptions = {
isListenOnly: false,
extension: null,
inputStream: this.inputStream,
};
return this.joinAudio(callOptions, this.callStateCallback.bind(this));
});
}
joinEchoTest() {
this.isListenOnly = false;
this.isEchoTest = true;
return this.onAudioJoining.bind(this)()
.then(() => {
const callOptions = {
isListenOnly: false,
extension: ECHO_TEST_NUMBER,
inputStream: this.inputStream,
};
logger.info({ logCode: 'audiomanager_join_echotest', extraInfo: { logType: 'user_action' } }, 'User requested to join audio conference with mic');
return this.joinAudio(callOptions, this.callStateCallback.bind(this));
});
}
joinAudio(callOptions, callStateCallback) {
return this.bridge.joinAudio(callOptions,
callStateCallback.bind(this)).catch((error) => {
const { name } = error;
if (!name) {
throw error;
}
switch (name) {
case 'NotAllowedError':
logger.error({
logCode: 'audiomanager_error_getting_device',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Error getting microphone - {${error.name}: ${error.message}}`);
break;
case 'NotFoundError':
logger.error({
logCode: 'audiomanager_error_device_not_found',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Error getting microphone - {${error.name}: ${error.message}}`);
break;
default:
break;
}
this.isConnecting = false;
this.isWaitingPermissions = false;
throw {
type: 'MEDIA_ERROR',
};
});
}
async joinListenOnly(r = 0) {
let retries = r;
this.isListenOnly = true;
this.isEchoTest = false;
// The kurento bridge isn't a full audio bridge yet, so we have to differ it
const bridge = this.useKurento ? this.listenOnlyBridge : this.bridge;
const callOptions = {
isListenOnly: true,
extension: null,
inputStream: this.createListenOnlyStream(),
};
// WebRTC restrictions may need a capture device permission to release
// useful ICE candidates on recvonly/no-gUM peers
try {
await tryGenerateIceCandidates();
} catch (error) {
logger.error({
logCode: 'listenonly_no_valid_candidate_gum_failure',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Forced gUM to release additional ICE candidates failed due to ${error.name}.`);
}
// Call polyfills for webrtc client if navigator is "iOS Webview"
const userAgent = window.navigator.userAgent.toLocaleLowerCase();
if ((userAgent.indexOf('iphone') > -1 || userAgent.indexOf('ipad') > -1)
&& userAgent.indexOf('safari') === -1) {
iosWebviewAudioPolyfills();
}
// We need this until we upgrade to SIP 9x. See #4690
const listenOnlyCallTimeoutErr = this.useKurento ? 'KURENTO_CALL_TIMEOUT' : 'SIP_CALL_TIMEOUT';
const iceGatheringTimeout = new Promise((resolve, reject) => {
setTimeout(reject, LISTEN_ONLY_CALL_TIMEOUT_MS, listenOnlyCallTimeoutErr);
});
const exitKurentoAudio = () => {
if (this.useKurento) {
bridge.exitAudio();
const audio = document.querySelector(MEDIA_TAG);
audio.muted = false;
}
};
const handleListenOnlyError = (err) => {
if (iceGatheringTimeout) {
clearTimeout(iceGatheringTimeout);
}
const errorReason = (typeof err === 'string' ? err : undefined) || err.errorReason || err.errorMessage;
const bridgeInUse = (this.useKurento ? 'Kurento' : 'SIP');
logger.error({
logCode: 'audiomanager_listenonly_error',
extraInfo: {
errorReason,
audioBridge: bridgeInUse,
retries,
},
}, `Listen only error - ${errorReason} - bridge: ${bridgeInUse}`);
};
logger.info({ logCode: 'audiomanager_join_listenonly', extraInfo: { logType: 'user_action' } }, 'user requested to connect to audio conference as listen only');
window.addEventListener('audioPlayFailed', this.handlePlayElementFailed);
return this.onAudioJoining()
.then(() => Promise.race([
bridge.joinAudio(callOptions, this.callStateCallback.bind(this)),
iceGatheringTimeout,
]))
.catch(async (err) => {
handleListenOnlyError(err);
if (retries < MAX_LISTEN_ONLY_RETRIES) {
// Fallback to SIP.js listen only in case of failure
if (this.useKurento) {
exitKurentoAudio();
this.useKurento = false;
const errorReason = (typeof err === 'string' ? err : undefined) || err.errorReason || err.errorMessage;
logger.info({
logCode: 'audiomanager_listenonly_fallback',
extraInfo: {
logType: 'fallback',
errorReason,
},
}, `Falling back to FreeSWITCH listenOnly - cause: ${errorReason}`);
}
retries += 1;
this.joinListenOnly(retries);
}
return null;
});
}
onAudioJoining() {
this.isConnecting = true;
this.isMuted = false;
this.error = false;
return Promise.resolve();
}
exitAudio() {
if (!this.isConnected) return Promise.resolve();
const bridge = (this.useKurento && this.isListenOnly) ? this.listenOnlyBridge : this.bridge;
this.isHangingUp = true;
return bridge.exitAudio();
}
transferCall() {
this.onTransferStart();
return this.bridge.transferCall(this.onAudioJoin.bind(this));
}
onVoiceUserChanges(fields) {
if (fields.muted !== undefined && fields.muted !== this.isMuted) {
let muteState;
this.isMuted = fields.muted;
if (this.isMuted) {
muteState = 'selfMuted';
this.mute();
} else {
muteState = 'selfUnmuted';
this.unmute();
}
window.parent.postMessage({ response: muteState }, '*');
}
if (fields.talking !== undefined && fields.talking !== this.isTalking) {
this.isTalking = fields.talking;
}
if (this.isMuted) {
this.isTalking = false;
}
}
onAudioJoin() {
this.isConnecting = false;
this.isConnected = true;
// listen to the VoiceUsers changes and update the flag
if (!this.muteHandle) {
const query = VoiceUsers.find({ intId: Auth.userID }, { fields: { muted: 1, talking: 1 } });
this.muteHandle = query.observeChanges({
added: (id, fields) => this.onVoiceUserChanges(fields),
changed: (id, fields) => this.onVoiceUserChanges(fields),
});
}
if (!this.isEchoTest) {
window.parent.postMessage({ response: 'joinedAudio' }, '*');
this.notify(this.intl.formatMessage(this.messages.info.JOINED_AUDIO));
logger.info({ logCode: 'audio_joined' }, 'Audio Joined');
this.audioEventHandler({ name: 'started' });
if (ENABLE_NETWORK_MONITORING) this.monitor();
}
}
onTransferStart() {
this.isEchoTest = false;
this.isConnecting = true;
}
onAudioExit() {
this.isConnected = false;
this.isConnecting = false;
this.isHangingUp = false;
this.autoplayBlocked = false;
this.failedMediaElements = [];
if (this.inputStream) {
window.defaultInputStream.forEach(track => track.stop());
this.inputStream.getTracks().forEach(track => track.stop());
this.inputDevice = { id: 'default' };
}
if (!this.error && !this.isEchoTest) {
this.notify(this.intl.formatMessage(this.messages.info.LEFT_AUDIO), false, 'audio_off');
}
if (!this.isEchoTest) {
this.playHangUpSound();
}
window.parent.postMessage({ response: 'notInAudio' }, '*');
window.removeEventListener('audioPlayFailed', this.handlePlayElementFailed);
}
callStateCallback(response) {
return new Promise((resolve) => {
const {
STARTED,
ENDED,
FAILED,
RECONNECTING,
AUTOPLAY_BLOCKED,
} = CALL_STATES;
const {
status,
error,
bridgeError,
silenceNotifications,
bridge,
} = response;
if (status === STARTED) {
this.onAudioJoin();
resolve(STARTED);
} else if (status === ENDED) {
logger.info({ logCode: 'audio_ended' }, 'Audio ended without issue');
this.onAudioExit();
} else if (status === FAILED) {
const errorKey = this.messages.error[error] || this.messages.error.GENERIC_ERROR;
const errorMsg = this.intl.formatMessage(errorKey, { 0: bridgeError });
this.error = !!error;
logger.error({
logCode: 'audio_failure',
extraInfo: {
errorCode: error,
cause: bridgeError,
bridge,
},
}, `Audio error - errorCode=${error}, cause=${bridgeError}`);
if (silenceNotifications !== true) {
this.notify(errorMsg, true);
this.exitAudio();
this.onAudioExit();
}
} else if (status === RECONNECTING) {
logger.info({ logCode: 'audio_reconnecting' }, 'Attempting to reconnect audio');
this.notify(this.intl.formatMessage(this.messages.info.RECONNECTING_AUDIO), true);
this.playHangUpSound();
} else if (status === AUTOPLAY_BLOCKED) {
this.autoplayBlocked = true;
this.onAudioJoin();
resolve(AUTOPLAY_BLOCKED);
}
});
}
createListenOnlyStream() {
const audio = document.querySelector(MEDIA_TAG);
// Play bogus silent audio to try to circumvent autoplay policy on Safari
if (!audio.src) {
audio.src = 'resources/sounds/silence.mp3';
}
audio.play().catch((e) => {
if (e.name === 'AbortError') {
return;
}
logger.warn({
logCode: 'audiomanager_error_test_audio',
extraInfo: { error: e },
}, 'Error on playing test audio');
});
return {};
}
isUsingAudio() {
return this.isConnected || this.isConnecting
|| this.isHangingUp || this.isEchoTest;
}
setDefaultInputDevice() {
return this.changeInputDevice();
}
setDefaultOutputDevice() {
return this.changeOutputDevice('default');
}
changeInputDevice(deviceId) {
if (!deviceId) {
return Promise.resolve();
}
const handleChangeInputDeviceSuccess = (inputDeviceId) => {
this.inputDevice.id = inputDeviceId;
return Promise.resolve(inputDeviceId);
};
const handleChangeInputDeviceError = (error) => {
logger.error({
logCode: 'audiomanager_error_getting_device',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Error getting microphone - {${error.name}: ${error.message}}`);
const { MIC_ERROR } = AudioErrors;
const disabledSysSetting = error.message.includes('Permission denied by system');
const isMac = navigator.platform.indexOf('Mac') !== -1;
let code = MIC_ERROR.NO_PERMISSION;
if (isMac && disabledSysSetting) code = MIC_ERROR.MAC_OS_BLOCK;
return Promise.reject({
type: 'MEDIA_ERROR',
message: this.messages.error.MEDIA_ERROR,
code,
});
};
return this.bridge.changeInputDeviceId(deviceId)
.then(handleChangeInputDeviceSuccess)
.catch(handleChangeInputDeviceError);
}
async changeOutputDevice(deviceId) {
this.outputDeviceId = await this
.bridge
.changeOutputDevice(deviceId || DEFAULT_OUTPUT_DEVICE_ID);
}
set inputDevice(value) {
this._inputDevice.value = value;
this._inputDevice.tracker.changed();
}
get inputStream() {
this._inputDevice.tracker.depend();
return this._inputDevice.value.stream;
}
get inputDevice() {
return this._inputDevice;
}
get inputDeviceId() {
return (this.bridge && this.bridge.inputDeviceId)
? this.bridge.inputDeviceId : DEFAULT_INPUT_DEVICE_ID;
}
set userData(value) {
this._userData = value;
}
get userData() {
return this._userData;
}
playHangUpSound() {
this.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/LeftCall.mp3');
}
notify(message, error = false, icon = 'unmute') {
const audioIcon = this.isListenOnly ? 'listen' : icon;
notify(
message,
error ? 'error' : 'info',
audioIcon,
);
}
monitor() {
const bridge = (this.useKurento && this.isListenOnly) ? this.listenOnlyBridge : this.bridge;
const peer = bridge.getPeerConnection();
monitorAudioConnection(peer);
}
handleAllowAutoplay() {
window.removeEventListener('audioPlayFailed', this.handlePlayElementFailed);
logger.info({
logCode: 'audiomanager_autoplay_allowed',
}, 'Listen only autoplay allowed by the user');
while (this.failedMediaElements.length) {
const mediaElement = this.failedMediaElements.shift();
if (mediaElement) {
playAndRetry(mediaElement).then((played) => {
if (!played) {
logger.error({
logCode: 'audiomanager_autoplay_handling_failed',
}, 'Listen only autoplay handling failed to play media');
} else {
// logCode is listenonly_* to make it consistent with the other tag play log
logger.info({
logCode: 'listenonly_media_play_success',
}, 'Listen only media played successfully');
}
});
}
}
this.autoplayBlocked = false;
}
handlePlayElementFailed(e) {
const { mediaElement } = e.detail;
e.stopPropagation();
this.failedMediaElements.push(mediaElement);
if (!this.autoplayBlocked) {
logger.info({
logCode: 'audiomanager_autoplay_prompt',
}, 'Prompting user for action to play listen only media');
this.autoplayBlocked = true;
}
}
setSenderTrackEnabled (shouldEnable) {
// If the bridge is set to listen only mode, nothing to do here. This method
// is solely for muting outbound tracks.
if (this.isListenOnly) return;
// Bridge -> SIP.js bridge, the only full audio capable one right now
const peer = this.bridge.getPeerConnection();
if (!peer) {
return;
}
peer.getSenders().forEach(sender => {
const { track } = sender;
if (track && track.kind === 'audio') {
track.enabled = shouldEnable;
}
});
}
mute () {
this.setSenderTrackEnabled(false);
}
unmute () {
this.setSenderTrackEnabled(true);
}
playAlertSound (url) {
if (!url) {
return Promise.resolve();
}
const audioAlert = new Audio(url);
audioAlert.addEventListener('ended', () => { audioAlert.src = null; });
if (this.outputDeviceId && (typeof audioAlert.setSinkId === 'function')) {
return audioAlert
.setSinkId(this.outputDeviceId)
.then(() => audioAlert.play());
}
return audioAlert.play();
}
async updateAudioConstraints(constraints) {
await this.bridge.updateAudioConstraints(constraints);
}
}
const audioManager = new AudioManager();
export default audioManager;