8feb934169
This is an initial, experimental implementation of the feature proposed in https://github.com/bigbluebutton/bigbluebutton/issues/14021. The intention is to phase out the explicit listen only mode with two overarching goals: - Reduce UX friction and increase familiarity: the existence of a separate listen only mode is a source of confusion for the majority of users Reduce average server-side CPU usage while also making it possible for having full audio-only meetings. The proof-of-concept works based on the assumption that a "many concurrent active talkers" scenario is both rare and not useful. With that in mind, this including two server-side triggers: - On microphone inactivity (currently mute action that is sustained for 4 seconds, configurable): FreeSWITCH channels are held (which translates to much lower CPU usage, virtually 0%). Receiving channels are switched, server side, to a listening mode (SFU, mediasoup). * This required an extension to mediasoup two allow re-assigning producers to already established consumers. No re-negotiation is done. - On microphone activity (currently unmute action, immediate): FreeSWITCH channels are unheld, listening mode is deactivated and the mute state is updated accordingly (in this order). This is *off by default*. It needs to be enabled in two places: - `/etc/bigbluebutton/bbb-webrtc-sfu/production.yml` -> `transparentListenOnly: true` - End users: * Server wide: `/etc/bigbluebutton/bbb-html5.yml` -> `public.media.transparentListenOnly: true` * Per user: `userdata-bbb_transparent_listen_only=true`
252 lines
6.8 KiB
JavaScript
252 lines
6.8 KiB
JavaScript
import logger from '/imports/startup/client/logger';
|
|
import BaseBroker from '/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker';
|
|
import WebRtcPeer from '/imports/ui/services/webrtc-base/peer';
|
|
|
|
const ON_ICE_CANDIDATE_MSG = 'iceCandidate';
|
|
const SUBSCRIBER_ANSWER = 'subscriberAnswer';
|
|
const DTMF = 'dtmf';
|
|
|
|
const SFU_COMPONENT_NAME = 'audio';
|
|
|
|
class AudioBroker extends BaseBroker {
|
|
constructor(
|
|
wsUrl,
|
|
role,
|
|
options = {},
|
|
) {
|
|
super(SFU_COMPONENT_NAME, wsUrl);
|
|
this.role = role;
|
|
this.offering = true;
|
|
|
|
// Optional parameters are:
|
|
// clientSessionNumber
|
|
// iceServers,
|
|
// offering,
|
|
// mediaServer,
|
|
// extension,
|
|
// constraints,
|
|
// stream,
|
|
// signalCandidates
|
|
// traceLogs
|
|
// networkPriority
|
|
// gatheringTimeout
|
|
// transparentListenOnly
|
|
Object.assign(this, options);
|
|
}
|
|
|
|
getLocalStream() {
|
|
if (this.webRtcPeer && typeof this.webRtcPeer.getLocalStream === 'function') {
|
|
return this.webRtcPeer.getLocalStream();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
setLocalStream(stream) {
|
|
if (this.webRtcPeer == null || this.webRtcPeer.peerConnection == null) {
|
|
throw new Error('Missing peer connection');
|
|
}
|
|
|
|
const { peerConnection } = this.webRtcPeer;
|
|
const newTracks = stream.getAudioTracks();
|
|
const localStream = this.getLocalStream();
|
|
const oldTracks = localStream ? localStream.getAudioTracks() : [];
|
|
|
|
peerConnection.getSenders().forEach((sender, index) => {
|
|
if (sender.track && sender.track.kind === 'audio') {
|
|
const newTrack = newTracks[index];
|
|
if (newTrack == null) return;
|
|
|
|
// Cleanup old tracks in the local MediaStream
|
|
const oldTrack = oldTracks[index];
|
|
sender.replaceTrack(newTrack);
|
|
if (oldTrack) {
|
|
oldTrack.stop();
|
|
localStream.removeTrack(oldTrack);
|
|
}
|
|
localStream.addTrack(newTrack);
|
|
}
|
|
});
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
_join() {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const options = {
|
|
audioStream: this.stream,
|
|
mediaConstraints: {
|
|
audio: this.constraints ? this.constraints : true,
|
|
video: false,
|
|
},
|
|
configuration: this.populatePeerConfiguration(),
|
|
onicecandidate: !this.signalCandidates ? null : (candidate) => {
|
|
this.onIceCandidate(candidate, this.role);
|
|
},
|
|
trace: this.traceLogs,
|
|
networkPriorities: this.networkPriority ? { audio: this.networkPriority } : undefined,
|
|
mediaStreamFactory: this.mediaStreamFactory,
|
|
gatheringTimeout: this.gatheringTimeout,
|
|
};
|
|
|
|
const peerRole = this.role === 'sendrecv' ? this.role : 'recvonly';
|
|
this.webRtcPeer = new WebRtcPeer(peerRole, options);
|
|
this.webRtcPeer.iceQueue = [];
|
|
this.webRtcPeer.start();
|
|
this.webRtcPeer.peerConnection.onconnectionstatechange = this.handleConnectionStateChange.bind(this);
|
|
|
|
if (this.offering) {
|
|
this.webRtcPeer.generateOffer()
|
|
.then(this.sendStartReq.bind(this))
|
|
.catch(this._handleOfferGenerationFailure.bind(this));
|
|
} else {
|
|
this.sendStartReq();
|
|
}
|
|
|
|
resolve();
|
|
} catch (error) {
|
|
// 1305: "PEER_NEGOTIATION_FAILED",
|
|
const normalizedError = BaseBroker.assembleError(1305);
|
|
logger.error({
|
|
logCode: `${this.logCodePrefix}_peer_creation_failed`,
|
|
extraInfo: {
|
|
errorMessage: error.name || error.message || 'Unknown error',
|
|
errorCode: normalizedError.errorCode,
|
|
sfuComponent: this.sfuComponent,
|
|
started: this.started,
|
|
},
|
|
}, 'Audio peer creation failed');
|
|
this.onerror(normalizedError);
|
|
reject(normalizedError);
|
|
}
|
|
});
|
|
}
|
|
|
|
joinAudio() {
|
|
return this.openWSConnection()
|
|
.then(this._join.bind(this));
|
|
}
|
|
|
|
onWSMessage(message) {
|
|
const parsedMessage = JSON.parse(message.data);
|
|
|
|
switch (parsedMessage.id) {
|
|
case 'startResponse':
|
|
this.onRemoteDescriptionReceived(parsedMessage);
|
|
break;
|
|
case 'iceCandidate':
|
|
this.handleIceCandidate(parsedMessage.candidate);
|
|
break;
|
|
case 'webRTCAudioSuccess':
|
|
this.onstart(parsedMessage.success);
|
|
this.started = true;
|
|
break;
|
|
case 'webRTCAudioError':
|
|
case 'error':
|
|
this.handleSFUError(parsedMessage);
|
|
break;
|
|
case 'pong':
|
|
break;
|
|
default:
|
|
logger.debug({
|
|
logCode: `${this.logCodePrefix}_invalid_req`,
|
|
extraInfo: { messageId: parsedMessage.id || 'Unknown', sfuComponent: this.sfuComponent },
|
|
}, 'Discarded invalid SFU message');
|
|
}
|
|
}
|
|
|
|
handleSFUError(sfuResponse) {
|
|
const { code, reason, role } = sfuResponse;
|
|
const error = BaseBroker.assembleError(code, reason);
|
|
|
|
logger.error({
|
|
logCode: `${this.logCodePrefix}_sfu_error`,
|
|
extraInfo: {
|
|
errorCode: code,
|
|
errorMessage: error.errorMessage,
|
|
role,
|
|
sfuComponent: this.sfuComponent,
|
|
started: this.started,
|
|
},
|
|
}, 'Audio failed in SFU');
|
|
this.onerror(error);
|
|
}
|
|
|
|
sendLocalDescription(localDescription) {
|
|
const message = {
|
|
id: SUBSCRIBER_ANSWER,
|
|
type: this.sfuComponent,
|
|
role: this.role,
|
|
sdpOffer: localDescription,
|
|
};
|
|
|
|
this.sendMessage(message);
|
|
}
|
|
|
|
onRemoteDescriptionReceived(sfuResponse) {
|
|
if (this.offering) {
|
|
return this.processAnswer(sfuResponse);
|
|
}
|
|
|
|
return this.processOffer(sfuResponse);
|
|
}
|
|
|
|
sendStartReq(offer) {
|
|
const message = {
|
|
id: 'start',
|
|
type: this.sfuComponent,
|
|
role: this.role,
|
|
clientSessionNumber: this.clientSessionNumber,
|
|
sdpOffer: offer,
|
|
mediaServer: this.mediaServer,
|
|
extension: this.extension,
|
|
transparentListenOnly: this.transparentListenOnly,
|
|
};
|
|
|
|
logger.debug({
|
|
logCode: `${this.logCodePrefix}_offer_generated`,
|
|
extraInfo: { sfuComponent: this.sfuComponent, role: this.role },
|
|
}, 'SFU audio offer generated');
|
|
|
|
this.sendMessage(message);
|
|
}
|
|
|
|
_handleOfferGenerationFailure(error) {
|
|
if (error) {
|
|
logger.error({
|
|
logCode: `${this.logCodePrefix}_offer_failure`,
|
|
extraInfo: {
|
|
errorMessage: error.name || error.message || 'Unknown error',
|
|
sfuComponent: this.sfuComponent,
|
|
},
|
|
}, 'Audio offer generation failed');
|
|
// 1305: "PEER_NEGOTIATION_FAILED",
|
|
this.onerror(error);
|
|
}
|
|
}
|
|
|
|
dtmf(tones) {
|
|
const message = {
|
|
id: DTMF,
|
|
type: this.sfuComponent,
|
|
tones,
|
|
};
|
|
|
|
this.sendMessage(message);
|
|
}
|
|
|
|
onIceCandidate(candidate, role) {
|
|
const message = {
|
|
id: ON_ICE_CANDIDATE_MSG,
|
|
role,
|
|
type: this.sfuComponent,
|
|
candidate,
|
|
};
|
|
|
|
this.sendMessage(message);
|
|
}
|
|
}
|
|
|
|
export default AudioBroker;
|