名称: 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网关
- 创建多方会议系统
最佳实践
- 始终验证SDP结构 - 处理前解析和验证
- 支持多编解码器 - 提供回退选项以兼容
- 使用有效载荷类型96+用于动态编解码器 - 遵循RFC 3551指南
- 为动态类型包括rtpmap - 即使知名,也要明确
- 添加格式参数(fmtp) - 指定编解码器配置细节
- 尊重媒体方向属性 - sendrecv、sendonly、recvonly、inactive
- 处理拒绝的媒体(端口=0) - 优雅处理不支持的媒体
- 在修改时更新会话版本 - 递增o=版本字段
- 包括定时信息 - 即使永久(0 0)也需要t=行
- 设置适当的ptime - 平衡延迟和包开销
- 支持telephone-event - 启用DTMF传输(RFC 4733)
- 使用ICE时添加ICE候选 - 包括所有候选类型
- 为视频配置RTCP反馈 - 启用错误恢复功能
- 按偏好顺序编解码器 - 最偏好的先列在格式列表中
- 在应答中保留编解码器参数 - 匹配提议者的fmtp设置
常见陷阱
- 动态有效载荷缺少rtpmap - 导致编解码器不匹配
- 错误的有效载荷类型编号 - 使用96-127表示动态,0-95表示静态
- 未处理拒绝的媒体 - 假设所有媒体被接受
- 忽略格式参数 - 编解码器可能无法正常工作
- rtpmap中的错误时钟率 - 音频通常使用8000,视频使用90000
- 缺少必要的SDP行 - v=、o=、s=、t=是强制性的
- 未更新会话版本 - 在重新邀请时导致混淆
- 不匹配的有效载荷类型 - 对同一编解码器使用不同的PT
- 忘记Content-Length - SIP需要准确的体长
- 未转义特殊字符 - URI参数必须编码
- 错误的媒体方向逻辑 - sendonly应应答为recvonly
- 缺少连接信息 - c=在会话或媒体级别是必需的
- 不正确的组件编号 - ICE候选的RTP=1,RTCP=2
- 未优先考虑安全编解码器 - 更喜欢加密而非明文
- 硬编码端口 - 为多个会话使用动态端口分配