import { Mutex } from '@livekit/mutex'; import { EventEmitter } from 'events'; import { parse, write } from 'sdp-transform'; import type { MediaAttributes, MediaDescription, SessionDescription } from 'sdp-transform'; import type TypedEmitter from 'typed-emitter'; import log, { LoggerNames, getLogger } from '../logger'; import { debounce } from './debounce'; import { NegotiationError, UnexpectedConnectionState } from './errors'; import type { LoggerOptions } from './types'; import { ddExtensionURI, isSVCCodec, isSafari } from './utils'; /** @internal */ interface TrackBitrateInfo { cid?: string; transceiver?: RTCRtpTransceiver; codec: string; maxbr: number; isScreenShare?: boolean; } /* * Video codecs use a very low bitrate at the beginning and increase slowly by * the bandwidth estimator until they reach the target bitrate. The process commonly * costs more than 10 seconds causing subscribers to get blurry video at the first * few seconds. We use x-google-start-bitrate to hint the BWE to start higher. * * Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target. * Why same for all codecs: Target bitrate already accounts for codec efficiency * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient). * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks. */ const startBitrateMultiplier = 0.9; /** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */ const maxStartBitrateKbps = 1000; const debounceInterval = 20; /** * Applies the configured start bitrate when this media section belongs to `cid`. * This SDP munging is used for a bitrate setting that cannot be applied through * `RTCRtpEncodingParameters`. * * Returns `undefined` when the section does not belong to the track, `0` when * it does but does not offer the requested codec, and the codec payload when the * requested codec is present (whether the bitrate was added or already set). * * @internal */ export function applyVideoStartBitrate( media: MediaDescription, cid: string, codec: string, maxbr: number, isScreenShare = false, ): number | undefined { if (!media.msid?.includes(cid)) { return undefined; } const codecPayload = media.rtp.find((rtp) => rtp.codec.toUpperCase() === codec.toUpperCase())?.payload ?? 0; if (codecPayload === 0) { return 0; } // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE // from starting too aggressively. Screen share is not capped since text/UI // clarity requires high bitrate from the start. // TODO: dynamically adjust start bitrate based on network conditions (e.g., previous BWE estimate) const calculatedStartBitrate = Math.round(maxbr * startBitrateMultiplier); const startBitrate = isScreenShare ? calculatedStartBitrate : Math.min(calculatedStartBitrate, maxStartBitrateKbps); const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload); if (fmtp) { // If another track's fmtp already has a start bitrate, it cannot be // overridden here because the payload type is shared across the bundle. // This forces every track sharing that payload to use the initial track's // start bitrate. if (!fmtp.config.includes('x-google-start-bitrate')) { fmtp.config += `;x-google-start-bitrate=${startBitrate}`; } } else { // VP8 and some codecs may not have an existing fmtp line. media.fmtp.push({ payload: codecPayload, config: `x-google-start-bitrate=${startBitrate}`, }); } return codecPayload; } export const PCEvents = { NegotiationStarted: 'negotiationStarted', NegotiationComplete: 'negotiationComplete', // Fired with the offerId for every successful publisher answer application, // including answers that immediately recurse into another offer via // `renegotiate`. Use this rather than NegotiationComplete to know that a // specific offer has been negotiated end-to-end. OfferAnswered: 'offerAnswered', RTPVideoPayloadTypes: 'rtpVideoPayloadTypes', } as const; /** @internal */ export default class PCTransport extends (EventEmitter as new () => TypedEmitter) { private _pc: RTCPeerConnection | null; private get pc() { if (!this._pc) { this._pc = this.createPC(); } return this._pc; } private config?: RTCConfiguration; private log = log; private iceLog = log; private loggerOptions: LoggerOptions; private ddExtID = 0; latestOfferId: number = 0; latestAcknowledgedOfferId: number = 0; private offerLock: Mutex; private pendingInitialOffer?: RTCSessionDescriptionInit; pendingCandidates: RTCIceCandidateInit[] = []; restartingIce: boolean = false; renegotiate: boolean = false; trackBitrates: TrackBitrateInfo[] = []; remoteStereoMids: string[] = []; remoteNackMids: string[] = []; onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void; onIceCandidate?: (candidate: RTCIceCandidate) => void; onIceCandidateError?: (ev: Event) => void; onConnectionStateChange?: (state: RTCPeerConnectionState) => void; onIceConnectionStateChange?: (state: RTCIceConnectionState) => void; onSignalingStatechange?: (state: RTCSignalingState) => void; onDataChannel?: (ev: RTCDataChannelEvent) => void; onTrack?: (ev: RTCTrackEvent) => void; constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) { super(); this.loggerOptions = loggerOptions; this.log = getLogger( loggerOptions.loggerName ?? LoggerNames.PCTransport, () => this.logContext, ); this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext); this.config = config; this._pc = this.createPC(); this.offerLock = new Mutex(); } private createPC() { const pc = new RTCPeerConnection(this.config); pc.onicecandidate = (ev) => { if (!ev.candidate) return; this.iceLog.debug('local ICE candidate gathered', { candidate: ev.candidate.candidate }); this.onIceCandidate?.(ev.candidate); }; pc.onicecandidateerror = (ev) => { this.iceLog.debug('ICE candidate error', { event: ev }); this.onIceCandidateError?.(ev); }; pc.oniceconnectionstatechange = () => { this.iceLog.debug(`ICE connection state: ${pc.iceConnectionState}`); this.onIceConnectionStateChange?.(pc.iceConnectionState); }; pc.onsignalingstatechange = () => { this.log.debug(`signaling state: ${pc.signalingState}`); this.onSignalingStatechange?.(pc.signalingState); }; pc.onconnectionstatechange = () => { this.log.debug(`connection state: ${pc.connectionState}`); this.onConnectionStateChange?.(pc.connectionState); }; pc.ondatachannel = (ev) => { this.log.debug('data channel opened by peer', { label: ev.channel.label, id: ev.channel.id, }); this.onDataChannel?.(ev); }; pc.ontrack = (ev) => { this.onTrack?.(ev); }; return pc; } private get logContext() { return { ...this.loggerOptions.loggerContextCb?.(), }; } get isICEConnected(): boolean { return ( this._pc !== null && (this.pc.iceConnectionState === 'connected' || this.pc.iceConnectionState === 'completed') ); } async addIceCandidate(candidate: RTCIceCandidateInit): Promise { if (this.pc.remoteDescription && !this.restartingIce) { return this.pc.addIceCandidate(candidate); } this.iceLog.debug('queuing remote ICE candidate until remote description applied', { pendingCount: this.pendingCandidates.length + 1, }); this.pendingCandidates.push(candidate); } async setRemoteDescription(sd: RTCSessionDescriptionInit, offerId: number): Promise { if ( sd.type === 'answer' && this.latestOfferId > 0 && offerId > 0 && offerId !== this.latestOfferId ) { this.log.warn('ignoring answer for old offer', { offerId, latestOfferId: this.latestOfferId, }); return false; } let mungedSDP: string | undefined = undefined; if (sd.type === 'offer') { let { stereoMids, nackMids } = extractStereoAndNackAudioFromOffer(sd); this.remoteStereoMids = stereoMids; this.remoteNackMids = nackMids; } else if (sd.type === 'answer') { if (this.pendingInitialOffer && this._pc) { const initialOffer = this.pendingInitialOffer; this.pendingInitialOffer = undefined; const sdpParsed = parse(initialOffer.sdp ?? ''); sdpParsed.media.forEach((media) => { ensureIPAddrMatchVersion(media); }); this.log.debug('setting pending initial offer before processing answer'); await this.setMungedSDP(initialOffer, write(sdpParsed)); } const sdpParsed = parse(sd.sdp ?? ''); sdpParsed.media.forEach((media) => { const mid = getMidString(media.mid!); if (media.type === 'audio') { // munge sdp for opus bitrate settings this.trackBitrates.some((trackbr): boolean => { if (!trackbr.transceiver || mid != trackbr.transceiver.mid) { return false; } let codecPayload = 0; media.rtp.some((rtp): boolean => { if (rtp.codec.toUpperCase() === trackbr.codec.toUpperCase()) { codecPayload = rtp.payload; return true; } return false; }); if (codecPayload === 0) { return true; } let fmtpFound = false; for (const fmtp of media.fmtp) { if (fmtp.payload === codecPayload) { fmtp.config = fmtp.config .split(';') .filter((attr) => !attr.includes('maxaveragebitrate')) .join(';'); if (trackbr.maxbr > 0) { fmtp.config += `;maxaveragebitrate=${trackbr.maxbr * 1000}`; } fmtpFound = true; break; } } if (!fmtpFound) { if (trackbr.maxbr > 0) { media.fmtp.push({ payload: codecPayload, config: `maxaveragebitrate=${trackbr.maxbr * 1000}`, }); } } return true; }); } }); // The server's answer sets per-track codec params (e.g. opus `usedtx`) on // the sections mapped to a published track, but leaves the pre-populated // recvonly placeholder sections with defaults. Conform the placeholders so // each shared payload type is consistent across the bundle, otherwise // libwebrtc flags a "bundled payload type collision". const placeholderMids = this.getPlaceholderMids(); if (placeholderMids.size > 0) { conformBundledCodecFmtp(sdpParsed.media, (media) => placeholderMids.has(getMidString(media.mid!)), ); } mungedSDP = write(sdpParsed); } await this.setMungedSDP(sd, mungedSDP, true); if (this.pendingCandidates.length > 0) { this.iceLog.debug('flushing queued ICE candidates', { count: this.pendingCandidates.length, }); } this.pendingCandidates.forEach((candidate) => { this.pc.addIceCandidate(candidate); }); this.pendingCandidates = []; this.restartingIce = false; // Fire OfferAnswered for every successfully applied answer, including the // ones that recurse into another offer via `renegotiate`. Callers waiting // on a specific offerId can resolve as soon as their offer's answer is in. if (sd.type === 'answer') { this.latestAcknowledgedOfferId = offerId; this.emit(PCEvents.OfferAnswered, offerId); } if (this.renegotiate) { this.renegotiate = false; await this.createAndSendOffer(); } else if (sd.type === 'answer') { this.emit(PCEvents.NegotiationComplete); if (sd.sdp) { const sdpParsed = parse(sd.sdp); sdpParsed.media.forEach((media) => { if (media.type === 'video') { this.emit(PCEvents.RTPVideoPayloadTypes, media.rtp); } }); } } return true; } // debounced negotiate interface negotiate = debounce(async (onError?: (e: Error) => void) => { this.emit(PCEvents.NegotiationStarted); try { await this.createAndSendOffer(); } catch (e) { if (onError) { onError(e as Error); } else { throw e; } } }, debounceInterval); async createInitialOffer() { const unlock = await this.offerLock.lock(); try { if (this.pc.signalingState !== 'stable') { this.log.warn('signaling state is not stable, cannot create initial offer'); return; } const offerId = this.latestOfferId + 1; this.latestOfferId = offerId; const offer = await this.pc.createOffer(); this.pendingInitialOffer = { sdp: offer.sdp, type: offer.type }; const sdpParsed = parse(offer.sdp ?? ''); sdpParsed.media.forEach((media) => { ensureIPAddrMatchVersion(media); }); offer.sdp = write(sdpParsed); return { offer, offerId }; } finally { unlock(); } } async createAndSendOffer(options?: RTCOfferOptions) { const unlock = await this.offerLock.lock(); try { if (this.onOffer === undefined) { return; } if (options?.iceRestart) { this.iceLog.debug('restarting ICE'); this.restartingIce = true; } if ( this._pc && (this._pc.signalingState === 'have-local-offer' || this.pendingInitialOffer) ) { // we're waiting for the peer to accept our offer, so we'll just wait // the only exception to this is when ICE restart is needed const currentSD = this._pc.remoteDescription; if (options?.iceRestart && currentSD) { // roll the remote description back in so createOffer produces a valid // ICE-restart offer on top of the already-negotiated state await this._pc.setRemoteDescription(currentSD); } else if (options?.iceRestart) { // ICE restart with no remote description to restart on: `renegotiate` would stall // (the pending offer is never answered), so throw for the caller to recreate the PC. throw new NegotiationError( 'ICE restart requested without a remote description, peer connection must be recreated', ); } else { this.renegotiate = true; this.log.debug('requesting renegotiation'); return; } } else if (!this._pc || this._pc.signalingState === 'closed') { this.log.warn('could not createOffer with closed peer connection'); return; } // actually negotiate this.log.debug('starting to negotiate'); // increase the offer id at the start to ensure the offer is always > 0 so that we can use 0 as a default value for legacy behavior const offerId = this.latestOfferId + 1; this.latestOfferId = offerId; const offer = await this.pc.createOffer(options); this.log.debug('original offer', { sdp: offer.sdp }); const sdpParsed = parse(offer.sdp ?? ''); sdpParsed.media.forEach((media) => { ensureIPAddrMatchVersion(media); if (media.type === 'audio') { ensureAudioNackAndStereo(media, ['all'], []); } else if (media.type === 'video') { this.trackBitrates.some((trackbr): boolean => { if (!trackbr.cid) { return false; } const codecPayload = applyVideoStartBitrate( media, trackbr.cid, trackbr.codec, trackbr.maxbr, trackbr.isScreenShare, ); if (codecPayload === undefined) { return false; } if (codecPayload > 0 && isSVCCodec(trackbr.codec) && !isSafari()) { this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID); } return true; }); } }); // Conform the placeholder sections (pre-populated, or reverted after an // unpublish) so every shared payload type carries identical fmtp across the // bundle, otherwise libwebrtc flags a "bundled payload type collision". // Detection is by transceiver (mids are stable across renegotiations) since // an unpublished section keeps its `a=msid`. const placeholderMids = this.getPlaceholderMids(); if (placeholderMids.size > 0) { conformBundledCodecFmtp(sdpParsed.media, (media) => placeholderMids.has(getMidString(media.mid!)), ); } if (this.latestOfferId > offerId) { this.log.warn('latestOfferId mismatch', { latestOfferId: this.latestOfferId, offerId, }); return; } await this.setMungedSDP(offer, write(sdpParsed)); this.onOffer(offer, this.latestOfferId); } finally { unlock(); } } async createAndSetAnswer(): Promise { const answer = await this.pc.createAnswer(); const sdpParsed = parse(answer.sdp ?? ''); sdpParsed.media.forEach((media) => { ensureIPAddrMatchVersion(media); if (media.type === 'audio') { ensureAudioNackAndStereo(media, this.remoteStereoMids, this.remoteNackMids); } }); await this.setMungedSDP(answer, write(sdpParsed)); return answer; } /** * Returns the mids of transceivers that carry no outgoing track on this * (publisher) connection: the pre-populated placeholders added by * `RTCEngine.applyInitialPublisherLayout`, plus any transceiver that was used * for a track and reverted on unpublish. Their codec fmtp is conformed to the * published tracks so a shared payload type stays consistent across the bundle. */ private getPlaceholderMids(): Set { return placeholderMidsFromTransceivers(this._pc?.getTransceivers() ?? []); } createDataChannel(label: string, dataChannelDict: RTCDataChannelInit) { return this.pc.createDataChannel(label, dataChannelDict); } addTransceiver(mediaStreamTrack: MediaStreamTrack, transceiverInit: RTCRtpTransceiverInit) { return this.pc.addTransceiver(mediaStreamTrack, transceiverInit); } addTransceiverOfKind(kind: 'audio' | 'video', transceiverInit: RTCRtpTransceiverInit) { return this.pc.addTransceiver(kind, transceiverInit); } addTrack(track: MediaStreamTrack) { if (!this._pc) { throw new UnexpectedConnectionState('PC closed, cannot add track'); } return this._pc.addTrack(track); } setTrackCodecBitrate(info: TrackBitrateInfo) { this.trackBitrates.push(info); } setConfiguration(rtcConfig: RTCConfiguration) { if (!this._pc) { throw new UnexpectedConnectionState('PC closed, cannot configure'); } return this._pc?.setConfiguration(rtcConfig); } canRemoveTrack(): boolean { return !!this._pc?.removeTrack; } removeTrack(sender: RTCRtpSender) { return this._pc?.removeTrack(sender); } getConnectionState() { return this._pc?.connectionState ?? 'closed'; } getICEConnectionState() { return this._pc?.iceConnectionState ?? 'closed'; } getSignallingState() { return this._pc?.signalingState ?? 'closed'; } getTransceivers() { return this._pc?.getTransceivers() ?? []; } getSenders() { return this._pc?.getSenders() ?? []; } getLocalDescription() { return this._pc?.localDescription; } getRemoteDescription() { return this.pc?.remoteDescription; } getStats() { return this.pc.getStats(); } getMaxMessageSize() { return this._pc?.sctp?.maxMessageSize; } async getConnectedAddress(): Promise { if (!this._pc) { return; } let selectedCandidatePairId = ''; const candidatePairs = new Map(); // id -> candidate ip const candidates = new Map(); const stats: RTCStatsReport = await this._pc.getStats(); stats.forEach((v) => { switch (v.type) { case 'transport': selectedCandidatePairId = v.selectedCandidatePairId; break; case 'candidate-pair': if (selectedCandidatePairId === '' && v.selected) { selectedCandidatePairId = v.id; } candidatePairs.set(v.id, v); break; case 'remote-candidate': candidates.set(v.id, `${v.address}:${v.port}`); break; default: } }); if (selectedCandidatePairId === '') { return undefined; } const selectedID = candidatePairs.get(selectedCandidatePairId)?.remoteCandidateId; if (selectedID === undefined) { return undefined; } return candidates.get(selectedID); } close = () => { if (!this._pc) { return; } this.log.debug('closing peer connection'); this.pendingInitialOffer = undefined; this._pc.close(); this._pc.onconnectionstatechange = null; this._pc.oniceconnectionstatechange = null; this._pc.onicegatheringstatechange = null; this._pc.ondatachannel = null; this._pc.onnegotiationneeded = null; this._pc.onsignalingstatechange = null; this._pc.onicecandidate = null; this._pc.ondatachannel = null; this._pc.ontrack = null; this._pc.onconnectionstatechange = null; this._pc.oniceconnectionstatechange = null; this._pc = null; }; private async setMungedSDP(sd: RTCSessionDescriptionInit, munged?: string, remote?: boolean) { const originalSdp = sd.sdp; if (munged) { sd.sdp = munged; try { this.log.debug(`setting munged ${remote ? 'remote' : 'local'} description`); if (remote) { await this.pc.setRemoteDescription(sd); } else { await this.pc.setLocalDescription(sd); } return; } catch (e) { this.log.warn(`not able to set ${sd.type}, falling back to unmodified sdp`, { error: e, mungedSdp: munged, originalSdp, }); sd.sdp = originalSdp; } } try { if (remote) { await this._pc?.setRemoteDescription(sd); } else { await this._pc?.setLocalDescription(sd); } } catch (e) { let msg = 'unknown error'; if (e instanceof Error) { msg = e.message; } else if (typeof e === 'string') { msg = e; } const fields: any = { error: msg, sdp: sd.sdp, }; if (munged && munged !== originalSdp) { fields.mungedSdp = munged; } if (!remote && this.pc.remoteDescription) { fields.remoteSdp = this.pc.remoteDescription; } this.log.error(`unable to set ${sd.type}`, { fields }); throw new NegotiationError(msg); } } } /** * Adds the AV1 dependency descriptor extension to `media` unless it is already there, and * returns the id it is mapped to so callers can pass it back in as `ddExtID` (0 when no id has * been chosen yet). * * A bundle has to map one URI to one id, so an id already in use for the extension anywhere in * `sdp` wins over both the cached one and a fresh one: Chrome advertises the extension itself on * sections it can send on, and an earlier offer may have munged it into others. * @internal */ export function ensureVideoDDExtension( media: { type: string; port: number; protocol: string; payloads?: string | undefined; } & MediaDescription, sdp: SessionDescription, ddExtID: number, ): number { const id = ddExtensionIDFor(sdp, ddExtID); if (id === undefined) { return ddExtID; } if (!media.ext?.some((ext) => ext.uri === ddExtensionURI)) { media.ext ??= []; media.ext.push({ value: id, uri: ddExtensionURI, }); } return id; } /** * The id to map the dependency descriptor to throughout `sdp`, or undefined when no id would be * consistent for the whole bundle and the extension therefore has to be left out. */ function ddExtensionIDFor(sdp: SessionDescription, cachedID: number): number | undefined { const mapped = mappedExtensionID(sdp, ddExtensionURI); if (mapped !== undefined) { // Adopting an id that also stands for another URI is what the browser rejects the bundle // over, and its own half of the map is not ours to renumber, so give up on this offer. return usedForOtherURI(sdp, mapped, ddExtensionURI) ? undefined : mapped; } // Reusing the id from the last offer keeps the mapping stable across renegotiations, but only // while nothing else has taken it: the browser assigns ids to its own extensions without // knowing about ours, so an id that was free when we picked it can since have been claimed — // typically by the fuller extension set that arrives with the first section we send on. if (cachedID !== 0 && !usedForOtherURI(sdp, cachedID, ddExtensionURI)) { return cachedID; } return unusedExtensionID(sdp); } /** The id `uri` is mapped to in `sdp`, if any section maps it. */ function mappedExtensionID(sdp: SessionDescription, uri: string): number | undefined { for (const media of sdp.media) { const ext = media.ext?.find((candidate) => candidate.uri === uri); if (ext) { return ext.value; } } return undefined; } /** Whether `id` stands for anything in `sdp` other than `uri`. */ function usedForOtherURI(sdp: SessionDescription, id: number, uri: string): boolean { return sdp.media.some((media) => media.ext?.some((ext) => ext.value === id && ext.uri !== uri)); } /** * An id no extension in `sdp` uses. Stays above every id in use rather than filling gaps, so it * is less likely to be an id the browser goes on to allocate to another extension, and steps * over 15, which RFC 8285 reserves. */ function unusedExtensionID(sdp: SessionDescription): number { let maxID = 0; sdp.media.forEach((media) => { media.ext?.forEach((ext) => { if (ext.value > maxID) { maxID = ext.value; } }); }); return maxID + 1 === 15 ? 16 : maxID + 1; } /** * Checks whether an fmtp config declares `param` as an exact, `;`-delimited * token. A plain substring check conflates distinct opus parameters — e.g. * `stereo=1` is a substring of `sprop-stereo=1` — so `param` must match a whole * parameter, not appear anywhere within the config string. * @internal */ export function fmtpConfigHasParam(config: string, param: string): boolean { return config.split(';').some((entry) => entry.trim() === param); } /** @internal */ export function ensureAudioNackAndStereo( media: { type: string; port: number; protocol: string; payloads?: string | undefined; } & MediaDescription, stereoMids: string[], nackMids: string[], ) { // sdp-transform types don't include number however the parser outputs mids as numbers in some cases const mid = getMidString(media.mid!); // found opus codec to add nack fb let opusPayload = 0; media.rtp.some((rtp): boolean => { // rtpmap encoding names are case-insensitive (RFC 4855) if (rtp.codec.toLowerCase() === 'opus') { opusPayload = rtp.payload; return true; } return false; }); // add nack rtcpfb if not exist if (opusPayload > 0) { if (!media.rtcpFb) { media.rtcpFb = []; } if ( nackMids.includes(mid) && !media.rtcpFb.some((fb) => fb.payload === opusPayload && fb.type === 'nack') ) { media.rtcpFb.push({ payload: opusPayload, type: 'nack', }); } if (stereoMids.includes(mid) || (stereoMids.length === 1 && stereoMids[0] === 'all')) { media.fmtp.some((fmtp): boolean => { if (fmtp.payload === opusPayload) { if (!fmtpConfigHasParam(fmtp.config, 'stereo=1')) { fmtp.config += ';stereo=1'; } return true; } return false; }); } } } /** * Returns the mids of transceivers that carry no outgoing track: the * pre-populated placeholders added by `RTCEngine.applyInitialPublisherLayout`, * plus any transceiver that was used for a track and reverted on unpublish. The * `sender.track` check is the reliable signal — an unpublished section keeps its * `a=msid` (and its stale send-derived fmtp), so it can't be told apart from a * real send by SDP alone. Transceiver mids are stable across renegotiations, so * this works for every offer/answer after the first. * @internal */ export function placeholderMidsFromTransceivers( transceivers: readonly RTCRtpTransceiver[], ): Set { const mids = new Set(); for (const transceiver of transceivers) { if (transceiver.mid && !transceiver.sender.track) { mids.add(transceiver.mid); } } return mids; } /** * Within a BUNDLE group a payload type must map to identical codec parameters * across every m-line. When the same payload type carries different fmtp between * sections — e.g. opus `usedtx=1` on the published microphone but not on the * pre-populated recvonly placeholders, or H.265 with different `level-id` between * a published video track and a placeholder — libwebrtc flags a "bundled payload * type collision". * * Rewrite the placeholder sections so every shared payload type carries the * same fmtp. Real (non-placeholder) sections always win the canonical value, so * a published track's encoder parameters are never altered. When no real * section declares a payload type — e.g. a placeholder that was reused for a * track and then reverted to recvonly keeps its send-derived `level-id` while * fresh placeholders use the default — the placeholders still converge on the * first value seen, so two placeholders can't disagree either. Only placeholder * sections are ever rewritten, and it is codec-agnostic (opus, H.265, ...). * `isPlaceholder` identifies the sections to conform. * @internal */ export function conformBundledCodecFmtp( media: MediaDescription[], isPlaceholder: (media: MediaDescription) => boolean, ) { // Canonical fmtp per payload type. Payload types are unique within a BUNDLE // group, so keying by payload alone (across audio and video) is safe. A real // section's value always takes precedence; otherwise the first placeholder // value seen is used so divergent placeholders still converge. const canonicalByPayload = new Map(); const fromRealSection = new Set(); for (const m of media) { const placeholder = isPlaceholder(m); for (const fmtp of m.fmtp ?? []) { if (!placeholder) { canonicalByPayload.set(fmtp.payload, fmtp.config); fromRealSection.add(fmtp.payload); } else if (!canonicalByPayload.has(fmtp.payload)) { canonicalByPayload.set(fmtp.payload, fmtp.config); } } } if (canonicalByPayload.size === 0) { return; } // Conform placeholder sections to the canonical fmtp for each shared payload. for (const m of media) { if (!isPlaceholder(m)) { continue; } for (const fmtp of m.fmtp ?? []) { const config = canonicalByPayload.get(fmtp.payload); if (config !== undefined && fmtp.config !== config) { fmtp.config = config; } } } } /** @internal */ export function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): { stereoMids: string[]; nackMids: string[]; } { const stereoMids: string[] = []; const nackMids: string[] = []; const sdpParsed = parse(offer.sdp ?? ''); let opusPayload = 0; sdpParsed.media.forEach((media) => { const mid = getMidString(media.mid!); if (media.type === 'audio') { media.rtp.some((rtp): boolean => { // rtpmap encoding names are case-insensitive (RFC 4855) if (rtp.codec.toLowerCase() === 'opus') { opusPayload = rtp.payload; return true; } return false; }); if (media.rtcpFb?.some((fb) => fb.payload === opusPayload && fb.type === 'nack')) { nackMids.push(mid); } media.fmtp.some((fmtp): boolean => { if (fmtp.payload === opusPayload) { if (fmtpConfigHasParam(fmtp.config, 'sprop-stereo=1')) { stereoMids.push(mid); } return true; } return false; }); } }); return { stereoMids, nackMids }; } function ensureIPAddrMatchVersion(media: MediaDescription) { // Chrome could generate sdp with c = IN IP4 // in edge case and return error when set sdp.This is not a // sdk error but correct it if the issue detected. if (media.connection) { const isV6 = media.connection.ip.indexOf(':') >= 0; if ((media.connection.version === 4 && isV6) || (media.connection.version === 6 && !isV6)) { // fallback to dummy address media.connection.ip = '0.0.0.0'; media.connection.version = 4; } } } function getMidString(mid: string | number) { return typeof mid === 'number' ? mid.toFixed(0) : mid; } type PCTransportEventCallbacks = { negotiationStarted: () => void; negotiationComplete: () => void; offerAnswered: (offerId: number) => void; rtpVideoPayloadTypes: (attributes: MediaAttributes['rtp']) => void; };