bigbluebutton-Github/bigbluebutton-html5/imports/api/audio/client/bridge/sfu-audio-bridge.js
prlanzarin 325887e325 feat(audio): rework audio join without listen only
This is a rework of the audio join procedure whithout the explict listen
only separation in mind. It's supposed to be used in conjunction with
the transparent listen only feature so that the distinction between
modes is seamless with minimal server-side impact. An abridged list of
changes:
  - Let the user pick no input device when joining microphone while
    allowing them to set an input device on the fly later on
  - Give the user the option to join audio with no input device whenever
    we fail to obtain input devices, with the option to try re-enabling
    them on the fly later on
  - Add the option to open the audio settings modal (echo test et al)
    via the in-call device selection chevron
  - Rework the SFU audio bridge and its services to support
    adding/removing tracks on the fly without renegotiation
  - Rework the SFU audio bridge and its services to support a new peer
    role called "passive-sendrecv". That role is used by dupled peers
    that have no active input source on start, but might have one later
    on.
  - Remove stale PermissionsOverlay component from the audio modal
  - Rework how permission errors are detected using the Permissions API
  - Rework the local echo test so that it uses a separate media tag
    rather than the remote
  - Add new, separate dialplans that mute/hold FreeSWITCH channels on
    hold based on UA strings. This is orchestrated server-side via
    webrtc-sfu and akka-apps. The basic difference here is that channels
    now join in their desired state rather than waiting for client side
    observers to sync the state up. It also mitigates transparent listen
    only performance edge cases on multiple audio channels joining at
    the same time.

The old, decoupled listen only mode is still present in code while we
validate this new approach. To test this, transparentListenOnly
must be enabled and listen only mode must be disable on audio join so
that the user skips straight through microphone join.
2024-08-15 00:43:28 +00:00

535 lines
17 KiB
JavaScript
Executable File

import BaseAudioBridge from './base';
import Auth from '/imports/ui/services/auth';
import logger from '/imports/startup/client/logger';
import AudioBroker from '/imports/ui/services/bbb-webrtc-sfu/audio-broker';
import loadAndPlayMediaStream from '/imports/ui/services/bbb-webrtc-sfu/load-play';
import {
fetchWebRTCMappedStunTurnServers,
getMappedFallbackStun,
} from '/imports/utils/fetchStunTurnServers';
import getFromMeetingSettings from '/imports/ui/services/meeting-settings';
import getFromUserSettings from '/imports/ui/services/users-settings';
import browserInfo from '/imports/utils/browserInfo';
import {
getAudioSessionNumber,
getAudioConstraints,
filterSupportedConstraints,
doGUM,
} from '/imports/api/audio/client/bridge/service';
import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils';
const SENDRECV_ROLE = 'sendrecv';
const RECV_ROLE = 'recv';
const PASSIVE_SENDRECV_ROLE = 'passive-sendrecv';
const BRIDGE_NAME = 'fullaudio';
const IS_CHROME = browserInfo.isChrome;
// SFU's base broker has distinct error codes so that it can be reused by different
// modules. Errors that have a valid, localized counterpart in audio manager are
// mapped so that the user gets a localized error message.
// The ones that haven't (ie SFU's servers-side errors), aren't mapped.
const errorCodeMap = {
1301: 1001,
1302: 1002,
1305: 1005,
1307: 1007,
};
// Error codes that are prone to a retry according to RETRY_THROUGH_RELAY
const RETRYABLE_ERRORS = [1007, 1010];
const mapErrorCode = (error) => {
const { errorCode } = error;
const mappedErrorCode = errorCodeMap[errorCode];
if (errorCode == null || mappedErrorCode == null) return error;
// eslint-disable-next-line no-param-reassign
error.errorCode = mappedErrorCode;
return error;
};
const getMediaServerAdapter = (listenOnly = false) => {
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const DEFAULT_LISTENONLY_MEDIA_SERVER = SETTINGS.public.kurento.listenOnlyMediaServer;
const DEFAULT_FULLAUDIO_MEDIA_SERVER = MEDIA.audio.fullAudioMediaServer;
if (listenOnly) {
return getFromMeetingSettings(
'media-server-listenonly',
DEFAULT_LISTENONLY_MEDIA_SERVER,
);
}
return getFromMeetingSettings(
'media-server-fullaudio',
DEFAULT_FULLAUDIO_MEDIA_SERVER,
);
};
const isTransparentListenOnlyEnabled = () => {
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const TRANSPARENT_LISTEN_ONLY = MEDIA.transparentListenOnly;
return getFromUserSettings(
'bbb_transparent_listen_only',
TRANSPARENT_LISTEN_ONLY,
);
};
export default class SFUAudioBridge extends BaseAudioBridge {
static getOfferingRole(isListenOnly) {
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const LISTEN_ONLY_OFFERING = MEDIA.listenOnlyOffering;
const FULLAUDIO_OFFERING = MEDIA.fullAudioOffering;
return isListenOnly && !isTransparentListenOnlyEnabled()
? LISTEN_ONLY_OFFERING
: (!isTransparentListenOnlyEnabled() && FULLAUDIO_OFFERING);
}
constructor(userData) {
super();
this.userId = userData.userId;
this.name = userData.username;
this.sessionToken = userData.sessionToken;
this.broker = null;
this.reconnecting = false;
this.iceServers = [];
this.bridgeName = BRIDGE_NAME;
this.isListenOnly = false;
this.bypassGUM = false;
this.supportsTransparentListenOnly = isTransparentListenOnlyEnabled;
this.handleTermination = this.handleTermination.bind(this);
}
get inputStream() {
// Only return the stream if the broker is active and the role isn't recvonly
// Input stream == actual input-capturing stream, not the one that's being played
if (this.broker && this.role !== RECV_ROLE) {
return this.broker.getLocalStream();
}
return null;
}
get role() {
return this.broker?.role;
}
getBrokerRole({ hasInputStream }) {
if (this.isListenOnly) {
return isTransparentListenOnlyEnabled()
? PASSIVE_SENDRECV_ROLE
: RECV_ROLE;
}
if (this.bypassGUM && !hasInputStream) return PASSIVE_SENDRECV_ROLE;
return SENDRECV_ROLE;
}
setInputStream(stream) {
if (this.broker == null) return null;
return this.broker.setLocalStream(stream);
}
getPeerConnection() {
if (!this.broker) return null;
const { webRtcPeer } = this.broker;
if (webRtcPeer) return webRtcPeer.peerConnection;
return null;
}
// eslint-disable-next-line class-methods-use-this
mediaStreamFactory(constraints) {
return doGUM(constraints, true);
}
setConnectionTimeout() {
if (this.connectionTimeout) this.clearConnectionTimeout();
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const CONNECTION_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 15000;
this.connectionTimeout = setTimeout(() => {
const error = new Error(`ICE negotiation timeout after ${CONNECTION_TIMEOUT_MS / 1000}s`);
error.errorCode = 1010;
// Duplicating key-vals because I can'decide settle on an error pattern - prlanzarin again
error.errorCause = error.message;
error.errorMessage = error.message;
this.handleBrokerFailure(error);
}, CONNECTION_TIMEOUT_MS);
}
clearConnectionTimeout() {
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = null;
}
}
dispatchAutoplayHandlingEvent(mediaElement) {
const tagFailedEvent = new CustomEvent('audioPlayFailed', {
detail: { mediaElement },
});
window.dispatchEvent(tagFailedEvent);
this.callback({ status: this.baseCallStates.autoplayBlocked, bridge: this.bridgeName });
}
reconnect(options = {}) {
// If broker has already started, fire the reconnecting callback so the user
// knows what's going on
if (this.broker.started) {
this.callback({ status: this.baseCallStates.reconnecting, bridge: this.bridgeName });
} else {
// Otherwise: override termination handler so the ended callback doesn't get
// triggered - this is a retry attempt and the user shouldn't be notified
// yet.
this.broker.onended = () => {};
}
this.broker.stop();
this.reconnecting = true;
this._startBroker({ isListenOnly: this.isListenOnly, ...options })
.catch((error) => {
// Error handling is a no-op because it will be "handled" in handleBrokerFailure
logger.debug({
logCode: 'sfuaudio_reconnect_failed',
extraInfo: {
errorMessage: error.errorMessage,
reconnecting: this.reconnecting,
bridge: this.bridgeName,
role: this.role,
},
}, 'SFU audio reconnect failed');
});
}
handleBrokerFailure(error) {
return new Promise((resolve, reject) => {
this.clearConnectionTimeout();
mapErrorCode(error);
const { errorMessage, errorCause, errorCode } = error;
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const RETRY_THROUGH_RELAY = MEDIA.audio.retryThroughRelay || false;
if (!this.reconnecting) {
if (this.broker.started) {
logger.error({
logCode: 'sfuaudio_error_try_to_reconnect',
extraInfo: {
errorMessage,
errorCode,
errorCause,
bridge: this.bridgeName,
role: this.role,
},
}, 'SFU audio failed, try to reconnect');
this.reconnect();
return resolve();
}
if (RETRYABLE_ERRORS.includes(errorCode) && RETRY_THROUGH_RELAY) {
logger.error({
logCode: 'sfuaudio_error_retry_through_relay',
extraInfo: {
errorMessage,
errorCode,
errorCause,
bridge: this.bridgeName,
role: this.role,
},
}, 'SFU audio failed to connect, retry through relay');
this.reconnect({ forceRelay: true });
return resolve();
}
}
// Already tried reconnecting once OR the user handn't successfully
// connected firsthand and retrying isn't an option. Finish the session
// and reject with the error
logger.error({
logCode: 'sfuaudio_error',
extraInfo: {
errorMessage,
errorCode,
errorCause,
reconnecting: this.reconnecting,
bridge: this.bridgeName,
role: this.role,
},
}, 'SFU audio failed');
this.clearConnectionTimeout();
this.broker.stop();
this.callback({
status: this.baseCallStates.failed,
error: errorCode,
bridgeError: errorMessage,
bridge: this.bridgeName,
});
return reject(error);
});
}
handleTermination() {
this.clearConnectionTimeout();
return this.callback({ status: this.baseCallStates.ended, bridge: this.bridgeName });
}
handleStart() {
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const MEDIA_TAG = MEDIA.mediaTag.replace(/#/g, '');
const stream = this.broker.webRtcPeer.getRemoteStream();
const mediaElement = document.getElementById(MEDIA_TAG);
return loadAndPlayMediaStream(stream, mediaElement, false).then(() => {
this.callback({
status: this.baseCallStates.started,
bridge: this.bridgeName,
});
this.clearConnectionTimeout();
this.reconnecting = false;
}).catch((error) => {
// NotAllowedError equals autoplay issues, fire autoplay handling event.
// This will be handled in audio-manager.
if (error.name === 'NotAllowedError') {
logger.error({
logCode: 'sfuaudio_error_autoplay',
extraInfo: {
errorName: error.name,
bridge: this.bridgeName,
role: this.role,
},
}, 'SFU audio media play failed due to autoplay error');
this.dispatchAutoplayHandlingEvent(mediaElement);
// For connection purposes, this worked - the autoplay thing is a client
// side soft issue to be handled at the UI/UX level, not WebRTC/negotiation
// So: clear the connection timer
this.clearConnectionTimeout();
this.reconnecting = false;
} else {
const normalizedError = {
errorCode: 1004,
errorMessage: error.message || 'AUDIO_PLAY_FAILED',
};
this.callback({
status: this.baseCallStates.failed,
error: normalizedError.errorCode,
bridgeError: normalizedError.errorMessage,
bridge: this.bridgeName,
});
throw normalizedError;
}
});
}
async _startBroker(options) {
try {
this.iceServers = await fetchWebRTCMappedStunTurnServers(this.sessionToken);
} catch (error) {
logger.error({ logCode: 'sfuaudio_stun-turn_fetch_failed' },
'SFU audio bridge failed to fetch STUN/TURN info, using default servers');
this.iceServers = getMappedFallbackStun();
}
return new Promise((resolve, reject) => {
const {
isListenOnly,
extension,
inputStream,
forceRelay: _forceRelay = false,
bypassGUM = false,
} = options;
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const SIGNAL_CANDIDATES = SETTINGS.public.kurento.signalCandidates;
const SFU_URL = SETTINGS.public.kurento.wsUrl;
const TRACE_LOGS = SETTINGS.public.kurento.traceLogs;
const GATHERING_TIMEOUT = SETTINGS.public.kurento.gatheringTimeout;
const RETRY_THROUGH_RELAY = MEDIA.audio.retryThroughRelay || false;
const { audio: NETWORK_PRIORITY } = MEDIA.networkPriorities || {};
const handleInitError = (_error) => {
mapErrorCode(_error);
if (RETRYABLE_ERRORS.includes(_error?.errorCode)
|| !RETRY_THROUGH_RELAY
|| this.reconnecting) {
reject(_error);
}
};
try {
this.inEchoTest = !!extension;
this.isListenOnly = isListenOnly;
this.bypassGUM = bypassGUM;
const role = this.getBrokerRole({
hasInputStream: !!inputStream,
});
const brokerOptions = {
clientSessionNumber: getAudioSessionNumber(),
extension,
iceServers: this.iceServers,
mediaServer: getMediaServerAdapter(isListenOnly),
constraints: getAudioConstraints({ deviceId: this.inputDeviceId }),
forceRelay: _forceRelay || shouldForceRelay(),
stream: (inputStream && inputStream.active) ? inputStream : undefined,
offering: SFUAudioBridge.getOfferingRole(this.isListenOnly),
signalCandidates: SIGNAL_CANDIDATES,
traceLogs: TRACE_LOGS,
networkPriority: NETWORK_PRIORITY,
mediaStreamFactory: this.mediaStreamFactory,
gatheringTimeout: GATHERING_TIMEOUT,
transparentListenOnly: isTransparentListenOnlyEnabled(),
bypassGUM,
};
this.broker = new AudioBroker(
Auth.authenticateURL(SFU_URL),
role,
brokerOptions,
);
this.broker.onended = this.handleTermination.bind(this);
this.broker.onerror = (error) => {
this.handleBrokerFailure(error).catch(reject);
};
this.broker.onstart = () => {
this.handleStart().then(resolve).catch(reject);
};
// Set up a connectionTimeout in case the server or network are botching
// negotiation or conn checks.
this.setConnectionTimeout();
this.broker.joinAudio().catch(handleInitError);
} catch (error) {
handleInitError(error);
}
});
}
async joinAudio(options, callback) {
this.callback = callback;
this.reconnecting = false;
return this._startBroker(options);
}
sendDtmf(tones) {
if (this.broker) {
this.broker.dtmf(tones);
}
}
transferCall(onTransferSuccess) {
this.inEchoTest = false;
return this.trackTransferState(onTransferSuccess);
}
async updateAudioConstraints(constraints) {
try {
if (typeof constraints !== 'object') return;
const matchConstraints = filterSupportedConstraints(constraints);
if (IS_CHROME) {
matchConstraints.deviceId = this.inputDeviceId;
const stream = await doGUM({ audio: matchConstraints });
await this.setInputStream(stream);
} else {
this.inputStream.getAudioTracks()
.forEach((track) => track.applyConstraints(matchConstraints));
}
} catch (error) {
logger.error({
logCode: 'sfuaudio_audio_constraint_error',
extraInfo: {
errorCode: error.code,
errorMessage: error.message,
bridgeName: this.bridgeName,
role: this.role,
},
}, 'Failed to update audio constraint');
}
}
trickleIce() {
return new Promise((resolve, reject) => {
try {
fetchWebRTCMappedStunTurnServers(this.sessionToken)
.then((iceServers) => {
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const SFU_URL = SETTINGS.public.kurento.wsUrl;
const TRACE_LOGS = SETTINGS.public.kurento.traceLogs;
const GATHERING_TIMEOUT = SETTINGS.public.kurento.gatheringTimeout;
const LISTEN_ONLY_OFFERING = MEDIA.listenOnlyOffering;
const options = {
clientSessionNumber: getAudioSessionNumber(),
iceServers,
offering: LISTEN_ONLY_OFFERING,
traceLogs: TRACE_LOGS,
gatheringTimeout: GATHERING_TIMEOUT,
};
this.broker = new AudioBroker(
Auth.authenticateURL(SFU_URL),
RECV_ROLE,
options,
);
this.broker.onstart = () => {
const { peerConnection } = this.broker.webRtcPeer;
if (!peerConnection) return resolve(null);
const selectedCandidatePair = peerConnection.getReceivers()[0]
.transport.iceTransport.getSelectedCandidatePair();
const validIceCandidate = [selectedCandidatePair.local];
this.broker.stop();
return resolve(validIceCandidate);
};
this.broker.joinAudio().catch(reject);
});
} catch (error) {
// Rollback
this.exitAudio();
reject(error);
}
});
}
exitAudio() {
const SETTINGS = window.meetingClientSettings;
const MEDIA = SETTINGS.public.media;
const MEDIA_TAG = MEDIA.mediaTag.replace(/#/g, '');
const mediaElement = document.getElementById(MEDIA_TAG);
this.clearConnectionTimeout();
this.reconnecting = false;
if (this.broker) {
this.broker.stop();
this.broker = null;
}
if (mediaElement && typeof mediaElement.pause === 'function') {
mediaElement.pause();
mediaElement.srcObject = null;
}
return Promise.resolve();
}
}