SIP媒体协商Skill sip-media-negotiation

这个技能专注于SIP(会话初始协议)中的媒体协商,包括SDP(会话描述协议)的解析与生成、编解码器协商算法、提议/应答模型实现,用于开发VoIP(语音 over IP)应用、视频会议系统和实时通信平台。关键词:SIP, SDP, 媒体协商, VoIP, 编解码器协商, RTP, ICE, WebRTC

后端开发 0 次安装 0 次浏览 更新于 3/25/2026

名称: sip-media-negotiation 描述: 处理SIP应用中的SDP提议/应答、编解码器协商、媒体能力以及RTP会话设置时使用。 允许工具:

  • Bash
  • Read

SIP媒体协商

掌握会话描述协议(SDP)提议/应答模型、编解码器协商和媒体会话建立,以构建具有优化媒体处理的鲁棒VoIP应用。

理解SDP和媒体协商

SDP(RFC 4566)在SIP中用于描述多媒体会话。SDP提议/应答模型(RFC 3264)使端点能够协商媒体能力、编解码器和传输参数。

SDP结构和语法

基本SDP消息

v=0
o=alice 2890844526 2890844527 IN IP4 atlanta.example.com
s=VoIP呼叫
c=IN IP4 192.0.2.1
t=0 0
m=audio 49170 RTP/AVP 0 8 97
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:97 iLBC/8000
a=ptime:20
a=maxptime:150
a=sendrecv

SDP行含义

v=  协议版本(始终为0)
o=  源(用户名、会话ID、会话版本、网络类型、地址类型、地址)
s=  会话名称
c=  连接信息
t=  定时(开始时间 停止时间,0 0表示永久)
m=  媒体描述(媒体、端口、协议、格式)
a=  属性(编解码器映射、参数、方向)

SDP解析器实现

完整SDP解析器

interface SdpOrigin {
  username: string;
  sessionId: string;
  sessionVersion: string;
  netType: string;
  addrType: string;
  address: string;
}

interface SdpConnection {
  netType: string;
  addrType: string;
  address: string;
  ttl?: number;
  addressCount?: number;
}

interface SdpMedia {
  media: string;
  port: number;
  portCount?: number;
  protocol: string;
  formats: string[];
  attributes: Map<string, string[]>;
  connection?: SdpConnection;
  bandwidth?: Map<string, number>;
}

interface SdpSession {
  version: number;
  origin: SdpOrigin;
  sessionName: string;
  sessionInfo?: string;
  uri?: string;
  email?: string;
  phone?: string;
  connection?: SdpConnection;
  bandwidth?: Map<string, number>;
  timing: { start: number; stop: number }[];
  attributes: Map<string, string[]>;
  media: SdpMedia[];
}

class SdpParser {
  static parse(sdp: string): SdpSession {
    const lines = sdp.trim().split(/\r?
/);
    const session: Partial<SdpSession> = {
      attributes: new Map(),
      timing: [],
      media: []
    };

    let currentMedia: SdpMedia | null = null;

    for (const line of lines) {
      const type = line.charAt(0);
      const value = line.substring(2);

      switch (type) {
        case 'v':
          session.version = parseInt(value);
          break;

        case 'o':
          session.origin = this.parseOrigin(value);
          break;

        case 's':
          session.sessionName = value;
          break;

        case 'i':
          if (currentMedia) {
            currentMedia.attributes.set('title', [value]);
          } else {
            session.sessionInfo = value;
          }
          break;

        case 'u':
          session.uri = value;
          break;

        case 'e':
          session.email = value;
          break;

        case 'p':
          session.phone = value;
          break;

        case 'c':
          const connection = this.parseConnection(value);
          if (currentMedia) {
            currentMedia.connection = connection;
          } else {
            session.connection = connection;
          }
          break;

        case 'b':
          const [bwType, bandwidth] = value.split(':');
          const bwValue = parseInt(bandwidth);
          if (currentMedia) {
            if (!currentMedia.bandwidth) {
              currentMedia.bandwidth = new Map();
            }
            currentMedia.bandwidth.set(bwType, bwValue);
          } else {
            if (!session.bandwidth) {
              session.bandwidth = new Map();
            }
            session.bandwidth.set(bwType, bwValue);
          }
          break;

        case 't':
          const [start, stop] = value.split(' ').map(Number);
          session.timing!.push({ start, stop });
          break;

        case 'm':
          if (currentMedia) {
            session.media!.push(currentMedia);
          }
          currentMedia = this.parseMedia(value);
          break;

        case 'a':
          const [attrName, attrValue] = this.parseAttribute(value);
          if (currentMedia) {
            if (!currentMedia.attributes.has(attrName)) {
              currentMedia.attributes.set(attrName, []);
            }
            currentMedia.attributes.get(attrName)!.push(attrValue || '');
          } else {
            if (!session.attributes!.has(attrName)) {
              session.attributes!.set(attrName, []);
            }
            session.attributes!.get(attrName)!.push(attrValue || '');
          }
          break;
      }
    }

    if (currentMedia) {
      session.media!.push(currentMedia);
    }

    return session as SdpSession;
  }

  private static parseOrigin(value: string): SdpOrigin {
    const parts = value.split(' ');
    return {
      username: parts[0],
      sessionId: parts[1],
      sessionVersion: parts[2],
      netType: parts[3],
      addrType: parts[4],
      address: parts[5]
    };
  }

  private static parseConnection(value: string): SdpConnection {
    const parts = value.split(' ');
    const addressParts = parts[2].split('/');

    return {
      netType: parts[0],
      addrType: parts[1],
      address: addressParts[0],
      ttl: addressParts[1] ? parseInt(addressParts[1]) : undefined,
      addressCount: addressParts[2] ? parseInt(addressParts[2]) : undefined
    };
  }

  private static parseMedia(value: string): SdpMedia {
    const parts = value.split(' ');
    const portParts = parts[1].split('/');

    return {
      media: parts[0],
      port: parseInt(portParts[0]),
      portCount: portParts[1] ? parseInt(portParts[1]) : undefined,
      protocol: parts[2],
      formats: parts.slice(3),
      attributes: new Map()
    };
  }

  private static parseAttribute(value: string): [string, string] {
    const colonIndex = value.indexOf(':');
    if (colonIndex === -1) {
      return [value, ''];
    }
    return [value.substring(0, colonIndex), value.substring(colonIndex + 1)];
  }

  static stringify(session: SdpSession): string {
    let sdp = '';

    // 版本
    sdp += `v=${session.version}\r
`;

    // 源
    const o = session.origin;
    sdp += `o=${o.username} ${o.sessionId} ${o.sessionVersion} ${o.netType} ${o.addrType} ${o.address}\r
`;

    // 会话名称
    sdp += `s=${session.sessionName}\r
`;

    // 会话信息
    if (session.sessionInfo) {
      sdp += `i=${session.sessionInfo}\r
`;
    }

    // URI
    if (session.uri) {
      sdp += `u=${session.uri}\r
`;
    }

    // 邮箱
    if (session.email) {
      sdp += `e=${session.email}\r
`;
    }

    // 电话
    if (session.phone) {
      sdp += `p=${session.phone}\r
`;
    }

    // 连接
    if (session.connection) {
      sdp += this.stringifyConnection(session.connection);
    }

    // 带宽
    if (session.bandwidth) {
      for (const [type, value] of session.bandwidth) {
        sdp += `b=${type}:${value}\r
`;
      }
    }

    // 定时
    for (const timing of session.timing) {
      sdp += `t=${timing.start} ${timing.stop}\r
`;
    }

    // 会话属性
    for (const [name, values] of session.attributes) {
      for (const value of values) {
        sdp += value ? `a=${name}:${value}\r
` : `a=${name}\r
`;
      }
    }

    // 媒体
    for (const media of session.media) {
      sdp += this.stringifyMedia(media);
    }

    return sdp;
  }

  private static stringifyConnection(conn: SdpConnection): string {
    let line = `c=${conn.netType} ${conn.addrType} ${conn.address}`;
    if (conn.ttl !== undefined) {
      line += `/${conn.ttl}`;
      if (conn.addressCount !== undefined) {
        line += `/${conn.addressCount}`;
      }
    }
    return line + '\r
';
  }

  private static stringifyMedia(media: SdpMedia): string {
    let sdp = `m=${media.media} ${media.port}`;
    if (media.portCount) {
      sdp += `/${media.portCount}`;
    }
    sdp += ` ${media.protocol} ${media.formats.join(' ')}\r
`;

    // 媒体连接
    if (media.connection) {
      sdp += this.stringifyConnection(media.connection);
    }

    // 媒体带宽
    if (media.bandwidth) {
      for (const [type, value] of media.bandwidth) {
        sdp += `b=${type}:${value}\r
`;
      }
    }

    // 媒体属性
    for (const [name, values] of media.attributes) {
      for (const value of values) {
        sdp += value ? `a=${name}:${value}\r
` : `a=${name}\r
`;
      }
    }

    return sdp;
  }
}

编解码器协商

编解码器注册和管理

interface Codec {
  payloadType: number;
  name: string;
  clockRate: number;
  channels?: number;
  parameters?: Map<string, string>;
}

class CodecRegistry {
  private static staticCodecs = new Map<number, Codec>([
    // 音频编解码器 (RFC 3551)
    [0, { payloadType: 0, name: 'PCMU', clockRate: 8000 }],
    [3, { payloadType: 3, name: 'GSM', clockRate: 8000 }],
    [4, { payloadType: 4, name: 'G723', clockRate: 8000 }],
    [5, { payloadType: 5, name: 'DVI4', clockRate: 8000 }],
    [6, { payloadType: 6, name: 'DVI4', clockRate: 16000 }],
    [7, { payloadType: 7, name: 'LPC', clockRate: 8000 }],
    [8, { payloadType: 8, name: 'PCMA', clockRate: 8000 }],
    [9, { payloadType: 9, name: 'G722', clockRate: 8000 }],
    [10, { payloadType: 10, name: 'L16', clockRate: 44100, channels: 2 }],
    [11, { payloadType: 11, name: 'L16', clockRate: 44100 }],
    [12, { payloadType: 12, name: 'QCELP', clockRate: 8000 }],
    [13, { payloadType: 13, name: 'CN', clockRate: 8000 }],
    [14, { payloadType: 14, name: 'MPA', clockRate: 90000 }],
    [15, { payloadType: 15, name: 'G728', clockRate: 8000 }],
    [16, { payloadType: 16, name: 'DVI4', clockRate: 11025 }],
    [17, { payloadType: 17, name: 'DVI4', clockRate: 22050 }],
    [18, { payloadType: 18, name: 'G729', clockRate: 8000 }],

    // 视频编解码器
    [26, { payloadType: 26, name: 'JPEG', clockRate: 90000 }],
    [31, { payloadType: 31, name: 'H261', clockRate: 90000 }],
    [32, { payloadType: 32, name: 'MPV', clockRate: 90000 }],
    [33, { payloadType: 33, name: 'MP2T', clockRate: 90000 }],
    [34, { payloadType: 34, name: 'H263', clockRate: 90000 }]
  ]);

  private dynamicCodecs = new Map<number, Codec>();

  // 从rtpmap属性注册动态编解码器
  registerCodec(payloadType: number, rtpmap: string): void {
    const [encodingName, clockRateAndChannels] = rtpmap.split('/');
    const parts = clockRateAndChannels.split('/');
    const clockRate = parseInt(parts[0]);
    const channels = parts[1] ? parseInt(parts[1]) : undefined;

    this.dynamicCodecs.set(payloadType, {
      payloadType,
      name: encodingName,
      clockRate,
      channels
    });
  }

  // 按有效载荷类型获取编解码器
  getCodec(payloadType: number): Codec | undefined {
    return this.dynamicCodecs.get(payloadType) ||
           CodecRegistry.staticCodecs.get(payloadType);
  }

  // 添加格式参数 (fmtp)
  addFormatParameters(payloadType: number, fmtp: string): void {
    const codec = this.getCodec(payloadType);
    if (!codec) {
      return;
    }

    if (!codec.parameters) {
      codec.parameters = new Map();
    }

    // 解析格式参数
    const params = fmtp.split(';').map(p => p.trim());
    for (const param of params) {
      const [key, value] = param.split('=').map(s => s.trim());
      codec.parameters.set(key, value);
    }
  }

  // 获取所有支持的编解码器
  getSupportedCodecs(): Codec[] {
    const codecs: Codec[] = [];
    codecs.push(...CodecRegistry.staticCodecs.values());
    codecs.push(...this.dynamicCodecs.values());
    return codecs;
  }

  // 在本地和远程之间查找公共编解码器
  findCommonCodecs(
    localFormats: string[],
    remoteFormats: string[],
    remoteSdp: SdpMedia
  ): Codec[] {
    const common: Codec[] = [];

    for (const format of localFormats) {
      if (remoteFormats.includes(format)) {
        const payloadType = parseInt(format);
        const codec = this.getCodec(payloadType);
        if (codec) {
          common.push(codec);
        }
      }
    }

    return common;
  }
}

提议/应答模型实现

完整提议/应答处理器

class SdpOfferAnswer {
  private localSession?: SdpSession;
  private remoteSession?: SdpSession;
  private negotiatedCodecs = new Map<string, Codec[]>();
  private codecRegistry = new CodecRegistry();

  // 创建初始提议
  createOffer(options: {
    audio?: boolean;
    video?: boolean;
    localIp: string;
    audioPort?: number;
    videoPort?: number;
  }): SdpSession {
    const sessionId = this.generateSessionId();
    const sessionVersion = sessionId;

    const session: SdpSession = {
      version: 0,
      origin: {
        username: 'user',
        sessionId,
        sessionVersion,
        netType: 'IN',
        addrType: 'IP4',
        address: options.localIp
      },
      sessionName: 'SIP呼叫',
      connection: {
        netType: 'IN',
        addrType: 'IP4',
        address: options.localIp
      },
      timing: [{ start: 0, stop: 0 }],
      attributes: new Map(),
      media: []
    };

    // 添加音频媒体
    if (options.audio !== false) {
      const audioMedia: SdpMedia = {
        media: 'audio',
        port: options.audioPort || 49170,
        protocol: 'RTP/AVP',
        formats: ['0', '8', '96', '97'],
        attributes: new Map([
          ['rtpmap', [
            '0 PCMU/8000',
            '8 PCMA/8000',
            '96 opus/48000/2',
            '97 telephone-event/8000'
          ]],
          ['fmtp', [
            '96 minptime=10;useinbandfec=1',
            '97 0-16'
          ]],
          ['ptime', ['20']],
          ['maxptime', ['150']],
          ['sendrecv', ['']]
        ])
      };

      // 注册动态编解码器
      this.codecRegistry.registerCodec(96, 'opus/48000/2');
      this.codecRegistry.registerCodec(97, 'telephone-event/8000');

      session.media.push(audioMedia);
    }

    // 添加视频媒体
    if (options.video) {
      const videoMedia: SdpMedia = {
        media: 'video',
        port: options.videoPort || 49172,
        protocol: 'RTP/AVP',
        formats: ['98', '99', '100'],
        attributes: new Map([
          ['rtpmap', [
            '98 VP8/90000',
            '99 H264/90000',
            '100 rtx/90000'
          ]],
          ['fmtp', [
            '99 profile-level-id=42e01f;packetization-mode=1',
            '100 apt=99'
          ]],
          ['rtcp-fb', [
            '98 nack',
            '98 nack pli',
            '98 ccm fir',
            '99 nack',
            '99 nack pli',
            '99 ccm fir'
          ]],
          ['sendrecv', ['']]
        ])
      };

      this.codecRegistry.registerCodec(98, 'VP8/90000');
      this.codecRegistry.registerCodec(99, 'H264/90000');
      this.codecRegistry.registerCodec(100, 'rtx/90000');

      session.media.push(videoMedia);
    }

    this.localSession = session;
    return session;
  }

  // 处理远程提议并创建应答
  createAnswer(remoteOffer: SdpSession, options: {
    localIp: string;
    audioPort?: number;
    videoPort?: number;
  }): SdpSession {
    this.remoteSession = remoteOffer;

    const sessionId = this.generateSessionId();
    const sessionVersion = sessionId;

    const answer: SdpSession = {
      version: 0,
      origin: {
        username: 'user',
        sessionId,
        sessionVersion,
        netType: 'IN',
        addrType: 'IP4',
        address: options.localIp
      },
      sessionName: 'SIP呼叫',
      connection: {
        netType: 'IN',
        addrType: 'IP4',
        address: options.localIp
      },
      timing: [{ start: 0, stop: 0 }],
      attributes: new Map(),
      media: []
    };

    // 处理提议中的每个媒体流
    for (const remoteMedia of remoteOffer.media) {
      const answerMedia = this.createAnswerMedia(remoteMedia, options);
      if (answerMedia) {
        answer.media.push(answerMedia);
      }
    }

    this.localSession = answer;
    return answer;
  }

  private createAnswerMedia(
    remoteMedia: SdpMedia,
    options: {
      audioPort?: number;
      videoPort?: number;
    }
  ): SdpMedia | null {
    // 解析远程编解码器
    const remoteCodecs = this.parseMediaCodecs(remoteMedia);

    // 获取我们支持的编解码器
    const localCodecs = this.getSupportedCodecs(remoteMedia.media);

    // 查找公共编解码器
    const commonCodecs = this.findCommonCodecs(localCodecs, remoteCodecs);

    if (commonCodecs.length === 0) {
      // 无公共编解码器,拒绝媒体
      return {
        media: remoteMedia.media,
        port: 0,
        protocol: remoteMedia.protocol,
        formats: ['0'],
        attributes: new Map()
      };
    }

    // 存储协商的编解码器
    this.negotiatedCodecs.set(remoteMedia.media, commonCodecs);

    // 构建应答媒体
    const port = remoteMedia.media === 'audio'
      ? (options.audioPort || 49170)
      : (options.videoPort || 49172);

    const answerMedia: SdpMedia = {
      media: remoteMedia.media,
      port,
      protocol: remoteMedia.protocol,
      formats: commonCodecs.map(c => c.payloadType.toString()),
      attributes: new Map()
    };

    // 添加rtpmap属性
    const rtpmaps: string[] = [];
    const fmtps: string[] = [];

    for (const codec of commonCodecs) {
      let rtpmap = `${codec.payloadType} ${codec.name}/${codec.clockRate}`;
      if (codec.channels && codec.channels > 1) {
        rtpmap += `/${codec.channels}`;
      }
      rtpmaps.push(rtpmap);

      // 复制格式参数(如果存在)
      if (codec.parameters && codec.parameters.size > 0) {
        const params = Array.from(codec.parameters.entries())
          .map(([k, v]) => `${k}=${v}`)
          .join(';');
        fmtps.push(`${codec.payloadType} ${params}`);
      }
    }

    answerMedia.attributes.set('rtpmap', rtpmaps);
    if (fmtps.length > 0) {
      answerMedia.attributes.set('fmtp', fmtps);
    }

    // 复制方向属性或使用sendrecv
    const direction = remoteMedia.attributes.get('sendrecv') ? 'sendrecv' :
                      remoteMedia.attributes.get('sendonly') ? 'recvonly' :
                      remoteMedia.attributes.get('recvonly') ? 'sendonly' :
                      remoteMedia.attributes.get('inactive') ? 'inactive' :
                      'sendrecv';

    answerMedia.attributes.set(direction, ['']);

    // 添加ptime(如果在提议中存在)
    const ptime = remoteMedia.attributes.get('ptime');
    if (ptime) {
      answerMedia.attributes.set('ptime', ptime);
    }

    return answerMedia;
  }

  // 处理远程应答
  processAnswer(remoteAnswer: SdpSession): void {
    this.remoteSession = remoteAnswer;

    // 处理每个媒体流
    for (const remoteMedia of remoteAnswer.media) {
      if (remoteMedia.port === 0) {
        // 媒体被拒绝
        console.log(`媒体 ${remoteMedia.media} 被拒绝`);
        continue;
      }

      // 解析协商的编解码器
      const codecs = this.parseMediaCodecs(remoteMedia);
      this.negotiatedCodecs.set(remoteMedia.media, codecs);
    }
  }

  private parseMediaCodecs(media: SdpMedia): Codec[] {
    const codecs: Codec[] = [];

    // 获取rtpmap属性
    const rtpmaps = media.attributes.get('rtpmap') || [];
    for (const rtpmap of rtpmaps) {
      const match = rtpmap.match(/^(\d+)\s+(.+)$/);
      if (match) {
        const payloadType = parseInt(match[1]);
        this.codecRegistry.registerCodec(payloadType, match[2]);
      }
    }

    // 获取fmtp属性
    const fmtps = media.attributes.get('fmtp') || [];
    for (const fmtp of fmtps) {
      const match = fmtp.match(/^(\d+)\s+(.+)$/);
      if (match) {
        const payloadType = parseInt(match[1]);
        this.codecRegistry.addFormatParameters(payloadType, match[2]);
      }
    }

    // 构建编解码器列表
    for (const format of media.formats) {
      const payloadType = parseInt(format);
      const codec = this.codecRegistry.getCodec(payloadType);
      if (codec) {
        codecs.push(codec);
      }
    }

    return codecs;
  }

  private getSupportedCodecs(mediaType: string): Codec[] {
    if (mediaType === 'audio') {
      return [
        { payloadType: 0, name: 'PCMU', clockRate: 8000 },
        { payloadType: 8, name: 'PCMA', clockRate: 8000 },
        { payloadType: 96, name: 'opus', clockRate: 48000, channels: 2 },
        { payloadType: 97, name: 'telephone-event', clockRate: 8000 }
      ];
    } else if (mediaType === 'video') {
      return [
        { payloadType: 98, name: 'VP8', clockRate: 90000 },
        { payloadType: 99, name: 'H264', clockRate: 90000 }
      ];
    }
    return [];
  }

  private findCommonCodecs(localCodecs: Codec[], remoteCodecs: Codec[]): Codec[] {
    const common: Codec[] = [];

    for (const remoteCodec of remoteCodecs) {
      const match = localCodecs.find(local =>
        local.name.toLowerCase() === remoteCodec.name.toLowerCase() &&
        local.clockRate === remoteCodec.clockRate &&
        (local.channels || 1) === (remoteCodec.channels || 1)
      );

      if (match) {
        // 使用远程有效载荷类型
        common.push({
          ...match,
          payloadType: remoteCodec.payloadType,
          parameters: remoteCodec.parameters
        });
      }
    }

    return common;
  }

  private generateSessionId(): string {
    return Date.now().toString();
  }

  // 获取媒体类型的协商编解码器
  getNegotiatedCodecs(mediaType: string): Codec[] {
    return this.negotiatedCodecs.get(mediaType) || [];
  }

  // 获取选定的编解码器(协商列表中的第一个)
  getSelectedCodec(mediaType: string): Codec | undefined {
    const codecs = this.negotiatedCodecs.get(mediaType);
    return codecs && codecs.length > 0 ? codecs[0] : undefined;
  }
}

高级媒体功能

ICE候选处理

interface IceCandidate {
  foundation: string;
  component: number;
  transport: string;
  priority: number;
  address: string;
  port: number;
  type: 'host' | 'srflx' | 'prflx' | 'relay';
  relAddr?: string;
  relPort?: number;
}

class IceCandidateHandler {
  // 从SDP属性解析ICE候选
  static parseCandidate(attr: string): IceCandidate | null {
    // a=candidate:foundation component transport priority address port typ type [raddr reladdr] [rport relport]
    const parts = attr.split(' ');

    if (parts.length < 8) {
      return null;
    }

    const candidate: IceCandidate = {
      foundation: parts[0],
      component: parseInt(parts[1]),
      transport: parts[2],
      priority: parseInt(parts[3]),
      address: parts[4],
      port: parseInt(parts[5]),
      type: parts[7] as IceCandidate['type']
    };

    // 解析可选参数
    for (let i = 8; i < parts.length; i += 2) {
      const key = parts[i];
      const value = parts[i + 1];

      if (key === 'raddr') {
        candidate.relAddr = value;
      } else if (key === 'rport') {
        candidate.relPort = parseInt(value);
      }
    }

    return candidate;
  }

  // 生成ICE候选属性
  static generateCandidate(candidate: IceCandidate): string {
    let attr = `candidate:${candidate.foundation} ${candidate.component} ` +
               `${candidate.transport} ${candidate.priority} ` +
               `${candidate.address} ${candidate.port} typ ${candidate.type}`;

    if (candidate.relAddr && candidate.relPort) {
      attr += ` raddr ${candidate.relAddr} rport ${candidate.relPort}`;
    }

    return attr;
  }

  // 计算候选优先级 (RFC 5245)
  static calculatePriority(
    type: IceCandidate['type'],
    component: number,
    localPreference: number = 65535
  ): number {
    const typePreference = {
      'host': 126,
      'srflx': 100,
      'prflx': 110,
      'relay': 0
    }[type];

    return (2 ** 24) * typePreference +
           (2 ** 8) * localPreference +
           (256 - component);
  }

  // 添加ICE候选到SDP
  static addCandidatesToSdp(sdp: SdpSession, candidates: IceCandidate[]): void {
    for (const media of sdp.media) {
      const mediaCandidates = candidates.filter(c =>
        c.component === (media.media === 'audio' ? 1 : 2)
      );

      const candidateAttrs = mediaCandidates.map(c => this.generateCandidate(c));
      media.attributes.set('candidate', candidateAttrs);
    }
  }
}

RTCP反馈配置

class RtcpFeedback {
  // 为视频添加RTCP反馈属性
  static addVideoFeedback(media: SdpMedia, payloadTypes: number[]): void {
    const feedbackTypes = [
      'nack',           // 通用NACK
      'nack pli',       // 图片丢失指示
      'ccm fir',        // 完整帧内请求
      'goog-remb',      // 谷歌接收方估计最大比特率
      'transport-cc'    // 传输端拥塞控制
    ];

    const rtcpFb: string[] = [];

    for (const pt of payloadTypes) {
      for (const fb of feedbackTypes) {
        rtcpFb.push(`${pt} ${fb}`);
      }
    }

    media.attributes.set('rtcp-fb', rtcpFb);
  }

  // 解析RTCP反馈
  static parseFeedback(attr: string): { payloadType: number; type: string; parameter?: string } | null {
    const match = attr.match(/^(\d+|\*)\s+([^\s]+)(?:\s+(.+))?$/);
    if (!match) {
      return null;
    }

    return {
      payloadType: match[1] === '*' ? -1 : parseInt(match[1]),
      type: match[2],
      parameter: match[3]
    };
  }
}

媒体能力协商

同时广播和SVC支持

class MediaCapabilities {
  // 添加同时广播支持
  static addSimulcast(media: SdpMedia, sendRids: string[]): void {
    // 添加RID属性
    const ridAttrs = sendRids.map(rid => `${rid} send`);
    media.attributes.set('rid', ridAttrs);

    // 添加同时广播属性
    const simulcastAttr = `send ${sendRids.join(';')}`;
    media.attributes.set('simulcast', [simulcastAttr]);
  }

  // 解析同时广播配置
  static parseSimulcast(attr: string): {
    send?: string[];
    recv?: string[];
  } {
    const result: { send?: string[]; recv?: string[] } = {};

    // 解析 "send rid1;rid2;rid3 recv rid4;rid5"
    const parts = attr.split(/\s+/);

    for (let i = 0; i < parts.length; i++) {
      if (parts[i] === 'send' && parts[i + 1]) {
        result.send = parts[i + 1].split(';');
        i++;
      } else if (parts[i] === 'recv' && parts[i + 1]) {
        result.recv = parts[i + 1].split(';');
        i++;
      }
    }

    return result;
  }

  // 添加SVC(可扩展视频编码)支持
  static addSvcSupport(media: SdpMedia, payloadType: number): void {
    // 添加依赖描述器扩展
    media.attributes.set('extmap', [
      '1 urn:ietf:params:rtp-hdrext:sdes:mid',
      '2 http://www.webrtc.org/experiments/rtp-hdrext/generic-frame-descriptor-00'
    ]);

    // 添加SVC格式参数
    const fmtps = media.attributes.get('fmtp') || [];
    const svcParams = 'scalability-mode=L3T3';

    const existingIndex = fmtps.findIndex(f => f.startsWith(`${payloadType} `));
    if (existingIndex >= 0) {
      fmtps[existingIndex] += `;${svcParams}`;
    } else {
      fmtps.push(`${payloadType} ${svcParams}`);
    }

    media.attributes.set('fmtp', fmtps);
  }
}

完整SIP提议/应答示例

class SipMediaSession {
  private offerAnswer = new SdpOfferAnswer();

  // UAC(呼叫者)创建提议
  async initiateCall(callee: string, localIp: string): Promise<string> {
    // 创建SDP提议
    const offer = this.offerAnswer.createOffer({
      audio: true,
      video: false,
      localIp,
      audioPort: 49170
    });

    // 构建SIP INVITE
    const sdpBody = SdpParser.stringify(offer);

    const invite = `INVITE sip:${callee} SIP/2.0\r
Via: SIP/2.0/UDP ${localIp}:5060;branch=z9hG4bK${this.generateBranch()}\r
Max-Forwards: 70\r
To: <sip:${callee}>\r
From: <sip:caller@example.com>;tag=${this.generateTag()}\r
Call-ID: ${this.generateCallId()}\r
CSeq: 1 INVITE\r
Contact: <sip:caller@${localIp}:5060>\r
Content-Type: application/sdp\r
Content-Length: ${sdpBody.length}\r
\r
${sdpBody}`;

    return invite;
  }

  // UAS(被叫者)创建应答
  async acceptCall(inviteMessage: string, localIp: string): Promise<string> {
    // 解析INVITE并提取SDP
    const sdpStart = inviteMessage.indexOf('v=0');
    const sdpBody = inviteMessage.substring(sdpStart);
    const offer = SdpParser.parse(sdpBody);

    // 创建SDP应答
    const answer = this.offerAnswer.createAnswer(offer, {
      localIp,
      audioPort: 49170
    });

    // 获取协商的编解码器
    const codec = this.offerAnswer.getSelectedCodec('audio');
    console.log('选定的编解码器:', codec);

    // 构建SIP 200 OK
    const answerSdp = SdpParser.stringify(answer);

    const response = `SIP/2.0 200 OK\r
Via: SIP/2.0/UDP ${localIp}:5060;branch=z9hG4bK${this.generateBranch()}\r
To: <sip:callee@example.com>;tag=${this.generateTag()}\r
From: <sip:caller@example.com>;tag=caller-tag\r
Call-ID: call-id\r
CSeq: 1 INVITE\r
Contact: <sip:callee@${localIp}:5060>\r
Content-Type: application/sdp\r
Content-Length: ${answerSdp.length}\r
\r
${answerSdp}`;

    return response;
  }

  // UAC处理应答
  processAnswer(responseMessage: string): void {
    // 解析200 OK并提取SDP
    const sdpStart = responseMessage.indexOf('v=0');
    const sdpBody = responseMessage.substring(sdpStart);
    const answer = SdpParser.parse(sdpBody);

    // 处理应答
    this.offerAnswer.processAnswer(answer);

    // 获取协商的编解码器
    const audioCodecs = this.offerAnswer.getNegotiatedCodecs('audio');
    console.log('协商的音频编解码器:', audioCodecs);
  }

  private generateBranch(): string {
    return Math.random().toString(36).substring(7);
  }

  private generateTag(): string {
    return Math.random().toString(36).substring(7);
  }

  private generateCallId(): string {
    return `${Date.now()}@example.com`;
  }
}

何时使用此技能

在构建需要以下功能的应用程序时使用sip-media-negotiation:

  • 设置具有编解码器协商的音视频呼叫
  • 实现SDP提议/应答模型
  • 解析和生成SDP消息
  • 协商端点之间的媒体能力
  • 处理多编解码器支持
  • 实现ICE以进行NAT穿越
  • 配置视频的RTCP反馈
  • 支持高级功能如同时广播
  • 构建WebRTC-SIP网关
  • 创建多方会议系统

最佳实践

  1. 始终验证SDP结构 - 处理前解析和验证
  2. 支持多编解码器 - 提供回退选项以兼容
  3. 使用有效载荷类型96+用于动态编解码器 - 遵循RFC 3551指南
  4. 为动态类型包括rtpmap - 即使知名,也要明确
  5. 添加格式参数(fmtp) - 指定编解码器配置细节
  6. 尊重媒体方向属性 - sendrecv、sendonly、recvonly、inactive
  7. 处理拒绝的媒体(端口=0) - 优雅处理不支持的媒体
  8. 在修改时更新会话版本 - 递增o=版本字段
  9. 包括定时信息 - 即使永久(0 0)也需要t=行
  10. 设置适当的ptime - 平衡延迟和包开销
  11. 支持telephone-event - 启用DTMF传输(RFC 4733)
  12. 使用ICE时添加ICE候选 - 包括所有候选类型
  13. 为视频配置RTCP反馈 - 启用错误恢复功能
  14. 按偏好顺序编解码器 - 最偏好的先列在格式列表中
  15. 在应答中保留编解码器参数 - 匹配提议者的fmtp设置

常见陷阱

  1. 动态有效载荷缺少rtpmap - 导致编解码器不匹配
  2. 错误的有效载荷类型编号 - 使用96-127表示动态,0-95表示静态
  3. 未处理拒绝的媒体 - 假设所有媒体被接受
  4. 忽略格式参数 - 编解码器可能无法正常工作
  5. rtpmap中的错误时钟率 - 音频通常使用8000,视频使用90000
  6. 缺少必要的SDP行 - v=、o=、s=、t=是强制性的
  7. 未更新会话版本 - 在重新邀请时导致混淆
  8. 不匹配的有效载荷类型 - 对同一编解码器使用不同的PT
  9. 忘记Content-Length - SIP需要准确的体长
  10. 未转义特殊字符 - URI参数必须编码
  11. 错误的媒体方向逻辑 - sendonly应应答为recvonly
  12. 缺少连接信息 - c=在会话或媒体级别是必需的
  13. 不正确的组件编号 - ICE候选的RTP=1,RTCP=2
  14. 未优先考虑安全编解码器 - 更喜欢加密而非明文
  15. 硬编码端口 - 为多个会话使用动态端口分配

资源