utils/SdpParser.js

import { SDPInfo, MediaInfo, Direction } from 'semantic-sdp'
import Logger from '../Logger'
import UserAgent from './UserAgent'

const logger = Logger.get('SdpParser')

const firstPayloadTypeLowerRange = 35
const lastPayloadTypeLowerRange = 65

const firstPayloadTypeUpperRange = 96
const lastPayloadTypeUpperRange = 127

const payloadTypeLowerRange = Array.from({ length: (lastPayloadTypeLowerRange - firstPayloadTypeLowerRange) + 1 }, (_, i) => i + firstPayloadTypeLowerRange)
const payloadTypeUppperRange = Array.from({ length: (lastPayloadTypeUpperRange - firstPayloadTypeUpperRange) + 1 }, (_, i) => i + firstPayloadTypeUpperRange)

const firstHeaderExtensionIdLowerRange = 1
const lastHeaderExtensionIdLowerRange = 14

const firstHeaderExtensionIdUpperRange = 16
const lastHeaderExtensionIdUpperRange = 255

const headerExtensionIdLowerRange = Array.from({ length: (lastHeaderExtensionIdLowerRange - firstHeaderExtensionIdLowerRange) + 1 }, (_, i) => i + firstHeaderExtensionIdLowerRange)
const headerExtensionIdUppperRange = Array.from({ length: (lastHeaderExtensionIdUpperRange - firstHeaderExtensionIdUpperRange) + 1 }, (_, i) => i + firstHeaderExtensionIdUpperRange)

/**
 * @module SdpParser
 * @description Simplify SDP parser.
 */
const SdpParser = {
  /**
   * @function
   * @name setSimulcast
   * @description Parse SDP for support simulcast.
   * **Only available in Google Chrome.**
   * @param {String} sdp - Current SDP.
   * @param {String} codec - Codec.
   * @returns {String} SDP parsed with simulcast support.
   * @example SdpParser.setSimulcast(sdp, 'h264')
   */
  setSimulcast (sdp, codec) {
    logger.info('Setting simulcast. Codec: ', codec)
    const browserData = new UserAgent()
    if (!browserData.isChrome()) {
      logger.warn('Simulcast is only available in Google Chrome browser')
      return sdp
    }
    if (codec !== 'h264' && codec !== 'vp8') {
      logger.warn('Simulcast is only available in h264 and vp8 codecs')
      return sdp
    }

    try {
      const reg1 = /m=video.*?a=ssrc:(\d*) cname:(.+?)\r\n/s
      const reg2 = /m=video.*?a=ssrc:(\d*) msid:(.+?)\r\n/s
      // Get ssrc and cname and msid
      const res = reg1.exec(sdp)
      const ssrc = res[1]
      const cname = res[2]
      const msid = reg2.exec(sdp)[2]
      // Add simulcasts ssrcs
      const num = 2
      const ssrcs = [ssrc]
      for (let i = 0; i < num; ++i) {
        // Create new ssrcs
        const ssrc = 100 + i * 2
        const rtx = ssrc + 1
        // Add to ssrc list
        ssrcs.push(ssrc)
        // Add sdp stuff
        sdp += 'a=ssrc-group:FID ' + ssrc + ' ' + rtx + '\r\n' +
            'a=ssrc:' + ssrc + ' cname:' + cname + '\r\n' +
            'a=ssrc:' + ssrc + ' msid:' + msid + '\r\n' +
            'a=ssrc:' + rtx + ' cname:' + cname + '\r\n' +
            'a=ssrc:' + rtx + ' msid:' + msid + '\r\n'
      }
      // Add SIM group
      sdp += 'a=ssrc-group:SIM ' + ssrcs.join(' ') + '\r\n'

      logger.info('Simulcast setted')
      logger.debug('Simulcast SDP: ', sdp)
      return sdp
    } catch (e) {
      logger.error('Error setting SDP for simulcast: ', e)
      throw e
    }
  },

  /**
   * @function
   * @name setStereo
   * @description Parse SDP for support stereo.
   * @param {String} sdp - Current SDP.
   * @returns {String} SDP parsed with stereo support.
   * @example SdpParser.setStereo(sdp)
   */
  setStereo (sdp) {
    logger.info('Replacing SDP response for support stereo')
    sdp = sdp.replace(
      /useinbandfec=1/g,
      'useinbandfec=1; stereo=1'
    )
    logger.info('Replaced SDP response for support stereo')
    logger.debug('New SDP value: ', sdp)
    return sdp
  },

  /**
   * @function
   * @name setDTX
   * @description Set DTX (Discontinuous Transmission) to the connection. Advanced configuration of the opus audio codec that allows for a large reduction in the audio traffic. For example, when a participant is silent, the audio packets won't be transmitted.
   * @param {String} sdp - Current SDP.
   * @returns {String} SDP parsed with dtx support.
   * @example SdpParser.setDTX(sdp)
   */
  setDTX (sdp) {
    logger.info('Replacing SDP response for support dtx')
    sdp = sdp.replace(
      'useinbandfec=1',
      'useinbandfec=1; usedtx=1'
    )
    logger.info('Replaced SDP response for support dtx')
    logger.debug('New SDP value: ', sdp)
    return sdp
  },

  /**
   * @function
   * @name setAbsoluteCaptureTime
   * @description Mangle SDP for adding absolute capture time header extension.
   * @param {String} sdp - Current SDP.
   * @returns {String} SDP mungled with abs-capture-time header extension.
   * @example SdpParser.setAbsoluteCaptureTime(sdp)
   */
  setAbsoluteCaptureTime (sdp) {
    const id = SdpParser.getAvailableHeaderExtensionIdRange(sdp)[0]
    const header = 'a=extmap:' + id + ' http://www.webrtc.org/experiments/rtp-hdrext/abs-capture-time\r\n'

    const regex = /(m=.*\r\n(?:.*\r\n)*?)(a=extmap.*\r\n)/gm

    sdp = sdp.replace(regex, (match, p1, p2) => p1 + header + p2)

    logger.info('Replaced SDP response for setting absolute capture time')
    logger.debug('New SDP value: ', sdp)

    return sdp
  },

  /**
   * @function
   * @name setDependencyDescriptor
   * @description Mangle SDP for adding dependency descriptor header extension.
   * @param {String} sdp - Current SDP.
   * @returns {String} SDP mungled with abs-capture-time header extension.
   * @example SdpParser.setAbsoluteCaptureTime(sdp)
   */
  setDependencyDescriptor (sdp) {
    const id = SdpParser.getAvailableHeaderExtensionIdRange(sdp)[0]
    const header = 'a=extmap:' + id + ' https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension\r\n'

    const regex = /(m=.*\r\n(?:.*\r\n)*?)(a=extmap.*\r\n)/gm

    sdp = sdp.replace(regex, (match, p1, p2) => p1 + header + p2)

    logger.info('Replaced SDP response for setting depency descriptor')
    logger.debug('New SDP value: ', sdp)

    return sdp
  },

  /**
   * @function
   * @name setVideoBitrate
   * @description Parse SDP for desired bitrate.
   * @param {String} sdp - Current SDP.
   * @param {Number} bitrate - Bitrate value in kbps or 0 for unlimited bitrate.
   * @returns {String} SDP parsed with desired bitrate.
   * @example SdpParser.setVideoBitrate(sdp, 1000)
   */
  setVideoBitrate (sdp, bitrate) {
    if (bitrate < 1) {
      logger.info('Remove bitrate restrictions')
      sdp = sdp.replace(/b=AS:.*\r\n/, '').replace(/b=TIAS:.*\r\n/, '')
    } else {
      const offer = SDPInfo.parse(sdp)
      const videoOffer = offer.getMedia('video')

      logger.info('Setting video bitrate')
      videoOffer.setBitrate(bitrate)
      sdp = offer.toString()
    }
    return sdp
  },

  /**
   * @function
   * @name removeSdpLine
   * @description Remove SDP line.
   * @param {String} sdp - Current SDP.
   * @param {String} sdpLine - SDP line to remove.
   * @returns {String} SDP without the line.
   * @example SdpParser.removeSdpLine(sdp, 'custom line')
   */
  removeSdpLine (sdp, sdpLine) {
    logger.debug('SDP before trimming: ', sdp)
    sdp = sdp
      .split('\n')
      .filter((line) => {
        return line.trim() !== sdpLine
      })
      .join('\n')
    logger.debug('SDP trimmed result: ', sdp)
    return sdp
  },

  /**
   * @function
   * @name adaptCodecName
   * @description Replace codec name of a SDP.
   * @param {String} sdp - Current SDP.
   * @param {String} codec - Codec name to be replaced.
   * @param {String} newCodecName - New codec name to replace.
   * @returns {String} SDP updated with new codec name.
   */
  adaptCodecName (sdp, codec, newCodecName) {
    if (!sdp) {
      return sdp
    }
    const regex = new RegExp(`${codec}`, 'i')

    return sdp.replace(regex, newCodecName)
  },

  /**
   * @function
   * @name setMultiopus
   * @description Parse SDP for support multiopus.
   * **Only available in Google Chrome.**
   * @param {String} sdp - Current SDP.
   * @param {MediaStream} mediaStream - MediaStream offered in the stream.
   * @returns {String} SDP parsed with multiopus support.
   * @example SdpParser.setMultiopus(sdp, mediaStream)
   */
  setMultiopus (sdp, mediaStream) {
    const browserData = new UserAgent()
    if (!browserData.isFirefox() && (!mediaStream || hasAudioMultichannel(mediaStream))) {
      if (!sdp.includes('multiopus/48000/6')) {
        logger.info('Setting multiopus')
        // Find the audio m-line
        const res = /m=audio 9 UDP\/TLS\/RTP\/SAVPF (.*)\r\n/.exec(sdp)
        // Get audio line
        const audio = res[0]
        // Get free payload number for multiopus
        const pt = SdpParser.getAvailablePayloadTypeRange(sdp)[0]
        // Add multiopus
        const multiopus = audio.replace('\r\n', ' ') + pt + '\r\n' +
              'a=rtpmap:' + pt + ' multiopus/48000/6\r\n' +
              'a=fmtp:' + pt + ' channel_mapping=0,4,1,2,3,5;coupled_streams=2;minptime=10;num_streams=4;useinbandfec=1\r\n'
        // Change sdp
        sdp = sdp.replace(audio, multiopus)
        logger.info('Multiopus offer created')
        logger.debug('SDP parsed for multioups: ', sdp)
      } else {
        logger.info('Multiopus already setted')
      }
    }
    return sdp
  },

  /**
   * @function
   * @name getAvailablePayloadTypeRange
   * @description Gets all available payload type IDs of the current Session Description.
   * @param {String} sdp - Current SDP.
   * @returns {Array<Number>} All available payload type ids.
   */
  getAvailablePayloadTypeRange (sdp) {
    const regex = /m=(?:.*) (?:.*) UDP\/TLS\/RTP\/SAVPF (.*)\r\n/gm

    const matches = sdp.matchAll(regex)
    let ptAvailable = payloadTypeUppperRange.concat(payloadTypeLowerRange)

    for (const match of matches) {
      const usedNumbers = match[1].split(' ').map(n => parseInt(n))
      ptAvailable = ptAvailable.filter(n => !usedNumbers.includes(n))
    }

    return ptAvailable
  },

  /**
   * @function
   * @name getAvailableHeaderExtensionIdRange
   * @description Gets all available header extension IDs of the current Session Description.
   * @param {String} sdp - Current SDP.
   * @returns {Array<Number>} All available header extension IDs.
   */
  getAvailableHeaderExtensionIdRange (sdp) {
    const regex = /a=extmap:(\d+)(?:.*)\r\n/gm

    const matches = sdp.matchAll(regex)
    let idAvailable = headerExtensionIdLowerRange.concat(headerExtensionIdUppperRange)

    for (const match of matches) {
      const usedNumbers = match[1].split(' ').map(n => parseInt(n))
      idAvailable = idAvailable.filter(n => !usedNumbers.includes(n))
    }

    return idAvailable
  },

  /**
   * @function
   * @name renegotiate
   * @description Renegotiate remote sdp based on previous description.
   * This function will fill missing m-lines cloning on the remote description by cloning the codec and extensions already negotiated for that media
   * @param {String} localDescription - Updated local sdp
   * @param {String} remoteDescription - Previous remote sdp
   */
  renegotiate (localDescription, remoteDescription) {
    const offer = SDPInfo.parse(localDescription)
    const answer = SDPInfo.parse(remoteDescription)

    // Check all transceivers on the offer are on the answer
    for (const offeredMedia of offer.getMedias()) {
      // Get associated mid on the answer
      let answeredMedia = answer.getMediaById(offeredMedia.getId())
      // If not found in answer
      if (!answeredMedia) {
        // Create new one
        answeredMedia = new MediaInfo(offeredMedia.getId(), offeredMedia.getType())
        // Set direction
        answeredMedia.setDirection(Direction.reverse(offeredMedia.getDirection()))
        // Find first media line for same kind
        const first = answer.getMedia(offeredMedia.getType())
        // If found
        if (first) {
          // Copy codec info
          answeredMedia.setCodecs(first.getCodecs())
          // Copy extension info
          for (const [id, extension] of first.getExtensions()) {
            // Add it
            answeredMedia.addExtension(id, extension)
          }
        }
        // Add it to answer
        answer.addMedia(answeredMedia)
      }
    }

    return answer.toString()
  },

  /**
   * @function
   * @name updateMissingVideoExtensions
   * @description Adds missing extensions of each video section in the localDescription
   * @param {String} localDescription - Previous local sdp
   * @param {String} remoteDescription - Remote sdp
   * @returns {String} SDP updated with missing extensions.
   */
  updateMissingVideoExtensions (localDescription, remoteDescription) {
    const offer = SDPInfo.parse(localDescription)
    const answer = SDPInfo.parse(remoteDescription)
    // Get extensions of answer
    const remoteVideoExtensions = answer.getMediasByType('video')[0]?.getExtensions()
    if (!remoteVideoExtensions && !remoteVideoExtensions.length) {
      return
    }
    for (const offeredMedia of offer.getMediasByType('video')) {
      const offerExtensions = offeredMedia.getExtensions()
      remoteVideoExtensions.forEach((val, key) => {
        // If the extension is not present in offer then add it
        if (!offerExtensions.get(key)) {
          const id = offeredMedia.getId()
          const header = 'a=extmap:' + key + ' ' + val + '\r\n'
          const regex = new RegExp('(a=mid:' + id + '\r\n(?:.*\r\n)*?)', 'g')
          localDescription = localDescription.replace(regex, (_, p1, p2) => p1 + header)
        }
      })
    }
    return localDescription
  }
}

// Checks if mediaStream has more than 2 audio channels.
const hasAudioMultichannel = (mediaStream) => {
  return mediaStream.getAudioTracks().some(value => value.getSettings().channelCount > 2)
}

export default SdpParser