twilio-video
Version:
Twilio Video JavaScript Library
493 lines (442 loc) • 18.6 kB
JavaScript
;
const { difference, flatMap } = require('../');
const setSimulcastInMediaSection = require('./simulcast');
const ptToFixedBitrateAudioCodecName = {
0: 'PCMU',
8: 'PCMA'
};
/**
* A payload type
* @typedef {number} PT
*/
/**
* An {@link AudioCodec} or {@link VideoCodec}
* @typedef {AudioCodec|VideoCodec} Codec
*/
/**
* Create a Codec Map for the given m= section.
* @param {string} section - The given m= section
* @returns {Map<Codec, Array<PT>>}
*/
function createCodecMapForMediaSection(section) {
return Array.from(createPtToCodecName(section)).reduce((codecMap, pair) => {
const pt = pair[0];
const codecName = pair[1];
const pts = codecMap.get(codecName) || [];
return codecMap.set(codecName, pts.concat(pt));
}, new Map());
}
/**
* Create a Map of MIDs to m= sections for the given SDP.
* @param {string} sdp
* @returns {Map<string, string>}
*/
function createMidToMediaSectionMap(sdp) {
return getMediaSections(sdp).reduce((midsToMediaSections, mediaSection) => {
const mid = getMidForMediaSection(mediaSection);
return mid ? midsToMediaSections.set(mid, mediaSection) : midsToMediaSections;
}, new Map());
}
/**
* Create a Map from PTs to codec names for the given m= section.
* @param {string} mediaSection - The given m= section.
* @returns {Map<PT, Codec>} ptToCodecName
*/
function createPtToCodecName(mediaSection) {
return getPayloadTypesInMediaSection(mediaSection).reduce((ptToCodecName, pt) => {
// NOTE(lrivas): Ignore repeated PTs to prevent RFC non‑compliant SDP generation.
// See, https://github.com/twilio/twilio-video.js/issues/2122.
if (ptToCodecName.has(pt)) {
return ptToCodecName;
}
const rtpmapPattern = new RegExp(`a=rtpmap:${pt} ([^/]+)`);
const matches = mediaSection.match(rtpmapPattern);
const codecName = matches
? matches[1].toLowerCase()
: ptToFixedBitrateAudioCodecName[pt]
? ptToFixedBitrateAudioCodecName[pt].toLowerCase()
: '';
return ptToCodecName.set(pt, codecName);
}, new Map());
}
/**
* Get the associated fmtp attributes for the given Payload Type in an m= section.
* @param {PT} pt
* @param {string} mediaSection
* @returns {?object}
*/
function getFmtpAttributesForPt(pt, mediaSection) {
// In "a=fmtp:<pt> <name>=<value>[;<name>=<value>]*", the regex matches the codec
// profile parameters expressed as name/value pairs separated by ";".
const fmtpRegex = new RegExp(`^a=fmtp:${pt} (.+)$`, 'm');
const matches = mediaSection.match(fmtpRegex);
return matches && matches[1].split(';').reduce((attrs, nvPair) => {
const [name, value] = nvPair.split('=');
attrs[name] = isNaN(value) ? value : parseInt(value, 10);
return attrs;
}, {});
}
/**
* Get the MID for the given m= section.
* @param {string} mediaSection
* @return {?string}
*/
function getMidForMediaSection(mediaSection) {
// In "a=mid:<mid>", the regex matches <mid>.
const midMatches = mediaSection.match(/^a=mid:(.+)$/m);
return midMatches && midMatches[1];
}
/**
* Get the m= sections of a particular kind and direction from an sdp.
* @param {string} sdp - SDP string
* @param {string} [kind] - Pattern for matching kind
* @param {string} [direction] - Pattern for matching direction
* @returns {Array<string>} mediaSections
*/
function getMediaSections(sdp, kind, direction) {
return sdp.replace(/\r\n\r\n$/, '\r\n').split('\r\nm=').slice(1).map(mediaSection => `m=${mediaSection}`).filter(mediaSection => {
const kindPattern = new RegExp(`m=${kind || '.*'}`, 'gm');
const directionPattern = new RegExp(`a=${direction || '.*'}`, 'gm');
return kindPattern.test(mediaSection) && directionPattern.test(mediaSection);
});
}
/**
* Get the Codec Payload Types present in the first line of the given m= section
* @param {string} section - The m= section
* @returns {Array<PT>} Payload Types
*/
function getPayloadTypesInMediaSection(section) {
const mLine = section.split('\r\n')[0];
// In "m=<kind> <port> <proto> <payload_type_1> <payload_type_2> ... <payload_type_n>",
// the regex matches <port> and the Payload Types.
const matches = mLine.match(/([0-9]+)/g);
// This should not happen, but in case there are no Payload Types in
// the m= line, return an empty array.
if (!matches) {
return [];
}
// Since only the Payload Types are needed, we discard the <port>.
return matches.slice(1).map(match => parseInt(match, 10));
}
/**
* Set the given Codec Payload Types in the first line of the given m= section.
* @param {Array<PT>} payloadTypes - Payload Types
* @param {string} section - Given m= section
* @returns {string} - Updated m= section
*/
function setPayloadTypesInMediaSection(payloadTypes, section) {
const lines = section.split('\r\n');
let mLine = lines[0];
const otherLines = lines.slice(1);
mLine = mLine.replace(/([0-9]+\s?)+$/, payloadTypes.join(' '));
return [mLine].concat(otherLines).join('\r\n');
}
/**
* Return a new SDP string with simulcast settings.
* @param {string} sdp
* @param {Map<Track.ID, TrackAttributes>} trackIdsToAttributes
* @returns {string} Updated SDP string
*/
function setSimulcast(sdp, trackIdsToAttributes) {
const mediaSections = getMediaSections(sdp);
const session = sdp.split('\r\nm=')[0];
return [session].concat(mediaSections.map(section => {
section = section.replace(/\r\n$/, '');
if (!/^m=video/.test(section)) {
return section;
}
const codecMap = createCodecMapForMediaSection(section);
const payloadTypes = getPayloadTypesInMediaSection(section);
const vp8PayloadTypes = new Set(codecMap.get('vp8') || []);
const hasVP8PayloadType = payloadTypes.some(payloadType => vp8PayloadTypes.has(payloadType));
return hasVP8PayloadType
? setSimulcastInMediaSection(section, trackIdsToAttributes)
: section;
})).concat('').join('\r\n');
}
/**
* Return a new SDP string after reverting simulcast for non vp8 sections in remote sdp.
* @param localSdp - simulcast enabled local sdp
* @param localSdpWithoutSimulcast - local sdp before simulcast was set
* @param remoteSdp - remote sdp
* @param revertForAll - when true simulcast will be reverted for all codecs. when false it will be reverted
* only for non-vp8 codecs.
* @return {string} Updated SDP string
*/
function revertSimulcast(localSdp, localSdpWithoutSimulcast, remoteSdp, revertForAll = false) {
const remoteMidToMediaSections = createMidToMediaSectionMap(remoteSdp);
const localMidToMediaSectionsWithoutSimulcast = createMidToMediaSectionMap(localSdpWithoutSimulcast);
const mediaSections = getMediaSections(localSdp);
const session = localSdp.split('\r\nm=')[0];
return [session].concat(mediaSections.map(section => {
section = section.replace(/\r\n$/, '');
if (!/^m=video/.test(section)) {
return section;
}
const midMatches = section.match(/^a=mid:(.+)$/m);
const mid = midMatches && midMatches[1];
if (!mid) {
return section;
}
const remoteSection = remoteMidToMediaSections.get(mid);
const remotePtToCodecs = createPtToCodecName(remoteSection);
const remotePayloadTypes = getPayloadTypesInMediaSection(remoteSection);
const isVP8ThePreferredCodec = remotePayloadTypes.length && remotePtToCodecs.get(remotePayloadTypes[0]) === 'vp8';
const shouldRevertSimulcast = revertForAll || !isVP8ThePreferredCodec;
return shouldRevertSimulcast ? localMidToMediaSectionsWithoutSimulcast.get(mid).replace(/\r\n$/, '') : section;
})).concat('').join('\r\n');
}
/**
* Add or rewrite MSIDs for new m= sections in the given SDP with their corresponding
* local MediaStreamTrack IDs. These can be different when previously removed MediaStreamTracks
* are added back (or Track IDs may not be present in the SDPs at all once browsers implement
* the latest WebRTC spec).
* @param {string} sdp
* @param {Map<string, Track.ID>} activeMidsToTrackIds
* @param {Map<Track.Kind, Array<Track.ID>>} trackIdsByKind
* @returns {string}
*/
function addOrRewriteNewTrackIds(sdp, activeMidsToTrackIds, trackIdsByKind) {
// NOTE(mmalavalli): The m= sections for the new MediaStreamTracks are usually
// present after the m= sections for the existing MediaStreamTracks, in order
// of addition.
const newMidsToTrackIds = Array.from(trackIdsByKind).reduce((midsToTrackIds, [kind, trackIds]) => {
const mediaSections = getMediaSections(sdp, kind, 'send(only|recv)');
const newMids = mediaSections.map(getMidForMediaSection).filter(mid => !activeMidsToTrackIds.has(mid));
newMids.forEach((mid, i) => midsToTrackIds.set(mid, trackIds[i]));
return midsToTrackIds;
}, new Map());
return addOrRewriteTrackIds(sdp, newMidsToTrackIds);
}
/**
* Add or rewrite MSIDs in the given SDP with their corresponding local MediaStreamTrack IDs.
* These IDs need not be the same (or Track IDs may not be present in the SDPs at all once
* browsers implement the latest WebRTC spec).
* @param {string} sdp
* @param {Map<string, Track.ID>} midsToTrackIds
* @returns {string}
*/
function addOrRewriteTrackIds(sdp, midsToTrackIds) {
const mediaSections = getMediaSections(sdp);
const session = sdp.split('\r\nm=')[0];
return [session].concat(mediaSections.map(mediaSection => {
// Do nothing if the m= section represents neither audio nor video.
if (!/^m=(audio|video)/.test(mediaSection)) {
return mediaSection;
}
// This shouldn't happen, but in case there is no MID for the m= section, do nothing.
const mid = getMidForMediaSection(mediaSection);
if (!mid) {
return mediaSection;
}
// In case there is no Track ID for the given MID in the map, do nothing.
const trackId = midsToTrackIds.get(mid);
if (!trackId) {
return mediaSection;
}
// This shouldn't happen, but in case there is no a=msid: line, do nothing.
const attributes = (mediaSection.match(/^a=msid:(.+)$/m) || [])[1];
if (!attributes) {
return mediaSection;
}
// If the a=msid: line contains the "appdata" field, then replace it with the Track ID,
// otherwise append the Track ID.
const [msid, trackIdToRewrite] = attributes.split(' ');
const msidRegex = new RegExp(`msid:${msid}${trackIdToRewrite ? ` ${trackIdToRewrite}` : ''}$`, 'gm');
return mediaSection.replace(msidRegex, `msid:${msid} ${trackId}`);
})).join('\r\n');
}
/**
* Removes specified ssrc attributes from given sdp.
* @param {string} sdp
* @param {Array<string>} ssrcAttributesToRemove
* @returns {string}
*/
function removeSSRCAttributes(sdp, ssrcAttributesToRemove) {
return sdp.split('\r\n').filter(line =>
!ssrcAttributesToRemove.find(srcAttribute => new RegExp('a=ssrc:.*' + srcAttribute + ':', 'g').test(line))
).join('\r\n');
}
/**
* Disable RTX in a given sdp.
* @param {string} sdp
* @returns {string} sdp without RTX
*/
function disableRtx(sdp) {
const mediaSections = getMediaSections(sdp);
const session = sdp.split('\r\nm=')[0];
return [session].concat(mediaSections.map(mediaSection => {
// Do nothing if the m= section does not represent a video track.
if (!/^m=video/.test(mediaSection)) {
return mediaSection;
}
// Create a map of codecs to payload types.
const codecsToPts = createCodecMapForMediaSection(mediaSection);
// Get the RTX payload types.
const rtxPts = codecsToPts.get('rtx');
// Do nothing if there are no RTX payload types.
if (!rtxPts) {
return mediaSection;
}
// Remove the RTX payload types.
const pts = new Set(getPayloadTypesInMediaSection(mediaSection));
rtxPts.forEach(rtxPt => pts.delete(rtxPt));
// Get the RTX SSRC.
const rtxSSRCMatches = mediaSection.match(/a=ssrc-group:FID [0-9]+ ([0-9]+)/);
const rtxSSRC = rtxSSRCMatches && rtxSSRCMatches[1];
// Remove the following lines associated with the RTX payload types:
// 1. "a=fmtp:<rtxPt> apt=<pt>"
// 2. "a=rtpmap:<rtxPt> rtx/..."
// 3. "a=ssrc:<rtxSSRC> cname:..."
// 4. "a=ssrc-group:FID <SSRC> <rtxSSRC>"
const filterRegexes = [
/^a=fmtp:.+ apt=.+$/,
/^a=rtpmap:.+ rtx\/.+$/,
/^a=ssrc-group:.+$/
].concat(rtxSSRC
? [new RegExp(`^a=ssrc:${rtxSSRC} .+$`)]
: []);
mediaSection = mediaSection.split('\r\n')
.filter(line => filterRegexes.every(regex => !regex.test(line)))
.join('\r\n');
// Reconstruct the m= section without the RTX payload types.
return setPayloadTypesInMediaSection(Array.from(pts), mediaSection);
})).join('\r\n');
}
/**
* Generate an a=fmtp: line from the given payload type and attributes.
* @param {PT} pt
* @param {*} fmtpAttrs
* @returns {string}
*/
function generateFmtpLineFromPtAndAttributes(pt, fmtpAttrs) {
const serializedFmtpAttrs = Object.entries(fmtpAttrs).map(([name, value]) => {
return `${name}=${value}`;
}).join(';');
return `a=fmtp:${pt} ${serializedFmtpAttrs}`;
}
/**
* Remove codec-related attributes (fmtp, rtpmap, rtcp-fb) for payload types
* that are not present in the m= line. This works around a Firefox bug where
* setCodecPreferences leaves unreferenced attributes after filtering codecs.
* @param {string} sdp
* @returns {string} sdp without unreferenced codec attributes
*/
function removeUnreferencedCodecAttributes(sdp) {
const mediaSections = getMediaSections(sdp);
const session = sdp.split('\r\nm=')[0];
return [session].concat(mediaSections.map(mediaSection => {
if (!/^m=(audio|video)/.test(mediaSection)) {
return mediaSection;
}
const validPts = new Set(getPayloadTypesInMediaSection(mediaSection));
return mediaSection.split('\r\n').filter(line => {
const ptMatch = line.match(/^a=(fmtp|rtpmap|rtcp-fb):(\d+)/);
if (!ptMatch) {
return true;
}
const pt = parseInt(ptMatch[2], 10);
return validPts.has(pt);
}).join('\r\n');
})).join('\r\n');
}
/**
* Enable DTX for opus in the m= sections for the given MIDs.`
* @param {string} sdp
* @param {Array<string>} [mids] - If not specified, enables opus DTX for all
* audio m= lines.
* @returns {string}
*/
function enableDtxForOpus(sdp, mids) {
const mediaSections = getMediaSections(sdp);
const session = sdp.split('\r\nm=')[0];
mids = mids || mediaSections
.filter(section => /^m=audio/.test(section))
.map(getMidForMediaSection);
return [session].concat(mediaSections.map(section => {
// Do nothing if the m= section is not audio.
if (!/^m=audio/.test(section)) {
return section;
}
// Build a map codecs to payload types.
const codecsToPts = createCodecMapForMediaSection(section);
// Do nothing if a payload type for opus does not exist.
const opusPt = codecsToPts.get('opus');
if (!opusPt) {
return section;
}
// If no fmtp attributes are found for opus, do nothing.
const opusFmtpAttrs = getFmtpAttributesForPt(opusPt, section);
if (!opusFmtpAttrs) {
return section;
}
// Add usedtx=1 to the a=fmtp: line for opus.
const origOpusFmtpLine = generateFmtpLineFromPtAndAttributes(opusPt, opusFmtpAttrs);
const origOpusFmtpRegex = new RegExp(origOpusFmtpLine);
// If the m= section's MID is in the list of MIDs, then enable dtx. Otherwise disable it.
const mid = getMidForMediaSection(section);
if (mids.includes(mid)) {
opusFmtpAttrs.usedtx = 1;
} else {
delete opusFmtpAttrs.usedtx;
}
const opusFmtpLineWithDtx = generateFmtpLineFromPtAndAttributes(opusPt, opusFmtpAttrs);
return section.replace(origOpusFmtpRegex, opusFmtpLineWithDtx);
})).join('\r\n');
}
/**
* Create the reordered Codec Payload Types based on the preferred Codec Names.
* @param {Map<Codec, Array<PT>>} codecMap - Codec Map
* @param {Array<AudioCodecSettings|VideoCodecSettings>} preferredCodecs - Preferred Codecs
* @returns {Array<PT>} Reordered Payload Types
*/
function getReorderedPayloadTypes(codecMap, preferredCodecs) {
preferredCodecs = preferredCodecs.map(({ codec }) => codec.toLowerCase());
const preferredPayloadTypes = flatMap(preferredCodecs, codecName => codecMap.get(codecName) || []);
const remainingCodecs = difference(Array.from(codecMap.keys()), preferredCodecs);
const remainingPayloadTypes = flatMap(remainingCodecs, codecName => codecMap.get(codecName));
return preferredPayloadTypes.concat(remainingPayloadTypes);
}
/**
* Return a new SDP string with the re-ordered codec preferences.
* Used for remote descriptions to enable asymmetric codec support in P2P.
* @param {string} sdp
* @param {Array<AudioCodec>} preferredAudioCodecs - If empty, the existing order of audio codecs is preserved
* @param {Array<VideoCodecSettings>} preferredVideoCodecs - If empty, the existing order of video codecs is preserved
* @returns {string} Updated SDP string
*/
function setCodecPreferences(sdp, preferredAudioCodecs, preferredVideoCodecs) {
const mediaSections = getMediaSections(sdp);
const session = sdp.split('\r\nm=')[0];
return [session].concat(mediaSections.map(section => {
// Codec preferences should not be applied to m=application sections.
if (!/^m=(audio|video)/.test(section)) {
return section;
}
const kind = section.match(/^m=(audio|video)/)[1];
const codecMap = createCodecMapForMediaSection(section);
const preferredCodecs = kind === 'audio' ? preferredAudioCodecs : preferredVideoCodecs;
const payloadTypes = getReorderedPayloadTypes(codecMap, preferredCodecs);
const newSection = setPayloadTypesInMediaSection(payloadTypes, section);
const pcmaPayloadTypes = codecMap.get('pcma') || [];
const pcmuPayloadTypes = codecMap.get('pcmu') || [];
const fixedBitratePayloadTypes = kind === 'audio'
? new Set(pcmaPayloadTypes.concat(pcmuPayloadTypes))
: new Set();
return fixedBitratePayloadTypes.has(payloadTypes[0])
? newSection.replace(/\r\nb=(AS|TIAS):([0-9]+)/g, '')
: newSection;
})).join('\r\n');
}
exports.addOrRewriteNewTrackIds = addOrRewriteNewTrackIds;
exports.addOrRewriteTrackIds = addOrRewriteTrackIds;
exports.createCodecMapForMediaSection = createCodecMapForMediaSection;
exports.createPtToCodecName = createPtToCodecName;
exports.disableRtx = disableRtx;
exports.enableDtxForOpus = enableDtxForOpus;
exports.getMediaSections = getMediaSections;
exports.removeUnreferencedCodecAttributes = removeUnreferencedCodecAttributes;
exports.removeSSRCAttributes = removeSSRCAttributes;
exports.revertSimulcast = revertSimulcast;
exports.setCodecPreferences = setCodecPreferences;
exports.setSimulcast = setSimulcast;