import { getLogger } from '@jitsi/logger'; import MediaDirection from '../../service/RTC/MediaDirection'; import * as MediaType from '../../service/RTC/MediaType'; import { getSourceNameForJitsiTrack } from '../../service/RTC/SignalingLayer'; import VideoType from '../../service/RTC/VideoType'; import FeatureFlags from '../flags/FeatureFlags'; import { SdpTransformWrap } from './SdpTransformUtil'; const logger = getLogger(__filename); /** * Fakes local SDP exposed to {@link JingleSessionPC} through the local * description getter. Modifies the SDP, so that it will contain muted local * video tracks description, even though their underlying {MediaStreamTrack}s * are no longer in the WebRTC peerconnection. That prevents from SSRC updates * being sent to Jicofo/remote peer and prevents sRD/sLD cycle on the remote * side. */ export default class LocalSdpMunger { /** * Creates new LocalSdpMunger instance. * * @param {TraceablePeerConnection} tpc * @param {string} localEndpointId - The endpoint id of the local user. */ constructor(tpc, localEndpointId) { this.tpc = tpc; this.localEndpointId = localEndpointId; } /** * Makes sure that muted local video tracks associated with the parent * {@link TraceablePeerConnection} are described in the local SDP. It's done * in order to prevent from sending 'source-remove'/'source-add' Jingle * notifications when local video track is muted (MediaStream is * removed from the peerconnection). * * NOTE 1 video track is assumed * * @param {SdpTransformWrap} transformer the transformer instance which will * be used to process the SDP. * @return {boolean} true if there were any modifications to * the SDP wrapped by transformer. * @private */ _addMutedLocalVideoTracksToSDP(transformer) { // Go over each video tracks and check if the SDP has to be changed const localVideos = this.tpc.getLocalTracks(MediaType.VIDEO); if (!localVideos.length) { return false; } else if (localVideos.length !== 1) { logger.error( `${this.tpc} there is more than 1 video track ! ` + 'Strange things may happen !', localVideos); } const videoMLine = transformer.selectMedia('video'); if (!videoMLine) { logger.debug( `${this.tpc} unable to hack local video track SDP` + '- no "video" media'); return false; } let modified = false; for (const videoTrack of localVideos) { const muted = videoTrack.isMuted(); const mediaStream = videoTrack.getOriginalStream(); const isCamera = videoTrack.videoType === VideoType.CAMERA; // During the mute/unmute operation there are periods of time when // the track's underlying MediaStream is not added yet to // the PeerConnection. The SDP needs to be munged in such case. const isInPeerConnection = mediaStream && this.tpc.isMediaStreamInPc(mediaStream); const shouldFakeSdp = isCamera && (muted || !isInPeerConnection); if (!shouldFakeSdp) { continue; // eslint-disable-line no-continue } // Inject removed SSRCs const requiredSSRCs = this.tpc.isSimulcastOn() ? this.tpc.simulcast.ssrcCache : [ this.tpc.sdpConsistency.cachedPrimarySsrc ]; if (!requiredSSRCs.length) { logger.error(`No SSRCs stored for: ${videoTrack} in ${this.tpc}`); continue; // eslint-disable-line no-continue } modified = true; // We need to fake sendrecv. // NOTE the SDP produced here goes only to Jicofo and is never set // as localDescription. That's why // TraceablePeerConnection.mediaTransferActive is ignored here. videoMLine.direction = MediaDirection.SENDRECV; // Check if the recvonly has MSID const primarySSRC = requiredSSRCs[0]; // FIXME The cname could come from the stream, but may turn out to // be too complex. It is fine to come up with any value, as long as // we only care about the actual SSRC values when deciding whether // or not an update should be sent. const primaryCname = `injected-${primarySSRC}`; for (const ssrcNum of requiredSSRCs) { // Remove old attributes videoMLine.removeSSRC(ssrcNum); // Inject videoMLine.addSSRCAttribute({ id: ssrcNum, attribute: 'cname', value: primaryCname }); videoMLine.addSSRCAttribute({ id: ssrcNum, attribute: 'msid', value: videoTrack.storedMSID }); } if (requiredSSRCs.length > 1) { const group = { ssrcs: requiredSSRCs.join(' '), semantics: 'SIM' }; if (!videoMLine.findGroup(group.semantics, group.ssrcs)) { // Inject the group videoMLine.addSSRCGroup(group); } } // Insert RTX // FIXME in P2P RTX is used by Chrome regardless of config option // status. Because of that 'source-remove'/'source-add' // notifications are still sent to remove/add RTX SSRC and FID group if (!this.tpc.options.disableRtx) { this.tpc.rtxModifier.modifyRtxSsrcs2(videoMLine); } } return modified; } /** * Returns a string that can be set as the MSID attribute for a source. * * @param {string} mediaType - Media type of the source. * @param {string} trackId - Id of the MediaStreamTrack associated with the source. * @param {string} streamId - Id of the MediaStream associated with the source. * @returns {string|null} */ _generateMsidAttribute(mediaType, trackId, streamId = null) { if (!(mediaType && trackId)) { logger.warn(`Unable to munge local MSID - track id=${trackId} or media type=${mediaType} is missing`); return null; } const pcId = this.tpc.id; // Handle a case on Firefox when the browser doesn't produce a 'a:ssrc' line with the 'msid' attribute or has // '-' for the stream id part of the msid line. Jicofo needs an unique identifier to be associated with a ssrc // and uses the msid for that. if (streamId === '-' || !streamId) { return `${this.localEndpointId}-${mediaType}-${pcId} ${trackId}-${pcId}`; } return `${streamId}-${pcId} ${trackId}-${pcId}`; } /** * Modifies 'cname', 'msid', 'label' and 'mslabel' by appending * the id of {@link LocalSdpMunger#tpc} at the end, preceding by a dash * sign. * * @param {MLineWrap} mediaSection - The media part (audio or video) of the * session description which will be modified in place. * @returns {void} * @private */ _transformMediaIdentifiers(mediaSection) { const pcId = this.tpc.id; for (const ssrcLine of mediaSection.ssrcs) { switch (ssrcLine.attribute) { case 'cname': case 'label': case 'mslabel': ssrcLine.value = ssrcLine.value && `${ssrcLine.value}-${pcId}`; break; case 'msid': { if (ssrcLine.value) { const streamAndTrackIDs = ssrcLine.value.split(' '); if (streamAndTrackIDs.length === 2) { ssrcLine.value = this._generateMsidAttribute( mediaSection.mLine?.type, streamAndTrackIDs[1], streamAndTrackIDs[0]); } else { logger.warn(`Unable to munge local MSID - weird format detected: ${ssrcLine.value}`); } } break; } } } // Additional transformations related to MSID are applicable to Unified-plan implementation only. if (!this.tpc.usesUnifiedPlan()) { return; } // If the msid attribute is missing, then remove the ssrc from the transformed description so that a // source-remove is signaled to Jicofo. This happens when the direction of the transceiver (or m-line) // is set to 'inactive' or 'recvonly' on Firefox, Chrome (unified) and Safari. const mediaDirection = mediaSection.mLine?.direction; if (mediaDirection === MediaDirection.RECVONLY || mediaDirection === MediaDirection.INACTIVE) { mediaSection.ssrcs = undefined; mediaSection.ssrcGroups = undefined; // Add the msid attribute if it is missing when the direction is sendrecv/sendonly. Firefox doesn't produce a // a=ssrc line with msid attribute for p2p connection. } else { const msidLine = mediaSection.mLine?.msid; const trackId = msidLine && msidLine.split(' ')[1]; const sources = [ ...new Set(mediaSection.mLine?.ssrcs?.map(s => s.id)) ]; for (const source of sources) { const msidExists = mediaSection.ssrcs .find(ssrc => ssrc.id === source && ssrc.attribute === 'msid'); if (!msidExists) { const generatedMsid = this._generateMsidAttribute(mediaSection.mLine?.type, trackId); mediaSection.ssrcs.push({ id: source, attribute: 'msid', value: generatedMsid }); } } } } /** * Maybe modifies local description to fake local video tracks SDP when * those are muted. * * @param {object} desc the WebRTC SDP object instance for the local * description. * @returns {RTCSessionDescription} */ maybeAddMutedLocalVideoTracksToSDP(desc) { if (!desc) { throw new Error('No local description passed in.'); } const transformer = new SdpTransformWrap(desc.sdp); if (this._addMutedLocalVideoTracksToSDP(transformer)) { return new RTCSessionDescription({ type: desc.type, sdp: transformer.toRawSDP() }); } return desc; } /** * This transformation will make sure that stream identifiers are unique * across all of the local PeerConnections even if the same stream is used * by multiple instances at the same time. * Each PeerConnection assigns different SSRCs to the same local * MediaStream, but the MSID remains the same as it's used to identify * the stream by the WebRTC backend. The transformation will append * {@link TraceablePeerConnection#id} at the end of each stream's identifier * ("cname", "msid", "label" and "mslabel"). * * @param {RTCSessionDescription} sessionDesc - The local session * description (this instance remains unchanged). * @return {RTCSessionDescription} - Transformed local session description * (a modified copy of the one given as the input). */ transformStreamIdentifiers(sessionDesc) { // FIXME similar check is probably duplicated in all other transformers if (!sessionDesc || !sessionDesc.sdp || !sessionDesc.type) { return sessionDesc; } const transformer = new SdpTransformWrap(sessionDesc.sdp); const audioMLine = transformer.selectMedia('audio'); if (audioMLine) { this._transformMediaIdentifiers(audioMLine); this._injectSourceNames(audioMLine); } const videoMLine = transformer.selectMedia('video'); if (videoMLine) { this._transformMediaIdentifiers(videoMLine); this._injectSourceNames(videoMLine); } return new RTCSessionDescription({ type: sessionDesc.type, sdp: transformer.toRawSDP() }); } /** * Injects source names. Source names are need to for multiple streams per endpoint support. The final plan is to * use the "mid" attribute for source names, but because the SDP to Jingle conversion still operates in the Plan-B * semantics (one source name per media), a custom "name" attribute is injected into SSRC lines.. * * @param {MLineWrap} mediaSection - The media part (audio or video) of the session description which will be * modified in place. * @returns {void} * @private */ _injectSourceNames(mediaSection) { if (!FeatureFlags.isSourceNameSignalingEnabled()) { return; } const sources = [ ...new Set(mediaSection.mLine?.ssrcs?.map(s => s.id)) ]; const mediaType = mediaSection.mLine?.type; if (!mediaType) { throw new Error('_transformMediaIdentifiers - no media type in mediaSection'); } for (const source of sources) { const nameExists = mediaSection.ssrcs.find(ssrc => ssrc.id === source && ssrc.attribute === 'name'); if (!nameExists) { // Inject source names as a=ssrc:3124985624 name:endpointA-v0 mediaSection.ssrcs.push({ id: source, attribute: 'name', value: getSourceNameForJitsiTrack(this.localEndpointId, mediaType, 0) }); } } } }