react-native-webrtc: SessionDescription is NULL.

I’m currently trying to setup a RTCPeerConnection between a server and my react native client. However when I set the remote description it fails with this error message:

{
    name: 'SetRemoteDescriptionFailed',
    message: 'SessionDescription is NULL.'
}

Code

const peer = new RTCPeerConnection({ iceServers: [...] })
const localOffer = await peer.createOffer();
await peer.setLocalDescription(localOffer);
// send local offer to server
// got sdp from server:
await peer.setRemoteDescription(new RTCSessionDescription({ sdp: sdp + "\n", type: "offer" }));

I have seen the duplicate issue #538 and tried adding \n at the end without success.

SDP

m=audio 50009 ICE/SDP
a=fingerprint:sha-256 4A:79:94:16:44:3F:BD:05:41:5A:C7:20:F3:12:54:70:00:73:5D:33:00:2D:2C:80:9B:39:E1:9F:2D:A7:49:87
c=IN IP4 109.200.198.210
a=rtcp:50009
a=ice-ufrag:n+TW
a=ice-pwd:wzVglE/Kni4AIslkP7XUOE
a=fingerprint:sha-256 4A:79:94:16:44:3F:BD:05:41:5A:C7:20:F3:12:54:70:00:73:5D:33:00:2D:2C:80:9B:39:E1:9F:2D:A7:49:87
a=candidate:1 1 UDP 4261412862 109.200.198.210 50009 typ host

I’m not sure if this is a bug or an issue on my side, but I’m glad for any help. Many thanks in advance and for taking the time to read this.

Platform information

  • React Native version: 0.66.4
  • Plugin version: 1.94.1
  • OS: iOS 15.1.1

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 22 (5 by maintainers)

Most upvoted comments

@saghul Will share after some investigation 😉

@LightKnight3r It is probably not applicable for your project, because it is the way discord generates SDP but here you are:

import transform, { MediaDescription, SessionDescription } from "sdp-transform";
import { DiscordCall } from "./Call";
import { Participant } from "../Core/Call";

const codecs = [
	{
		name: "opus",
		type: "audio",
		priority: 1000,
		payload_type: 111,
		rtx_payload_type: null,
	},
	{
		name: "H264",
		type: "video",
		priority: 1000,
		payload_type: 102,
		rtx_payload_type: 122,
	},
	{
		name: "VP8",
		type: "video",
		priority: 2000,
		payload_type: 96,
		rtx_payload_type: 97,
	},
	{
		name: "VP9",
		type: "video",
		priority: 3000,
		payload_type: 98,
		rtx_payload_type: 99,
	},
];

export interface StreamInfo {
	type: "video" | "audio";
	ssrc: number;
	rtx_ssrc?: number;
	mid?: string;
	user_id?: string;
}

export class WebRTC {
	codecs = codecs;
	pc!: RTCPeerConnection;
	localSDP!: SessionDescription;
	remoteMedia?: {
		type: string;
		port: number;
		protocol: string;
		payloads?: string | undefined;
	} & MediaDescription;
	audioSSRC = 0;
	videoSSRC = 0;
	rtxVideoSSRC = 0;
	streams_inbound: StreamInfo[] = [];
	negotiationPromise?: {
		resolve: (value?: unknown) => void;
		reject: (error?: Error) => void;
	};

	constructor(public call: DiscordCall) {}

	truncateSDP = () => {
		var o = new RegExp("^a=ice|a=extmap|a=fingerprint|opus|VP8|a=rtpmap:(96|97)", "i");
		return {
			sdp: this.pc
				.localDescription!.sdp.split(/\r\n/)
				.filter(function (e) {
					return o.test(e);
				})
				.unique()
				.join("\n"),
			codecs: this.codecs,
		};
	};

	init() {
		this.destroy();

		this.pc = new RTCPeerConnection({
			bundlePolicy: "max-bundle",
			// @ts-ignore
			sdpSemantics: "unified-plan",
		});
		this.pc.addEventListener("negotiationneeded", async (e) => {
			try {
				await this.handleNegotiation();
			} catch (error) {
				console.error("[WebRTC] negotiationneeded error =>", error);
			}
		});
		this.pc.addEventListener("icecandidateerror", (e) => {
			this.call.setState("[WebRTC] Error: " + e);
		});
		this.pc.addEventListener("signalingstatechange", (e) => {
			this.call.setState("[WebRTC] signalingState => " + this.pc?.signalingState);
		});
		this.pc.addEventListener("track", (e) => {
			console.log("[WebRTC] track =>", e);

			var stream = e.streams[0];
			if (!stream) {
				stream = new MediaStream();
				stream.addTrack(e.track);
			}
			if (this.call.streams.some((x) => x.id === stream.id)) return;

			this.call.streams.push(stream);
		});
		this.pc.addEventListener("connectionstatechange", (e) => {
			this.call.setState("[WebRTC] connectionState => " + this.pc?.connectionState);
		});
	}

	destroy() {
		if (!this.pc) return;

		try {
			this.pc._unregisterEvents();
		} catch (error) {}

		this.pc.close();
		this.pc = null as any;
	}

	setStream(stream: MediaStream) {
		console.log("[WebRTC] setStream =>", stream);
		var transceivers = this.pc.getTransceivers();

		stream.getTracks().forEach((track) => {
			var t = transceivers.shift();
			if (t) t.direction = "sendrecv";
			else t = this.pc.addTransceiver(track, { direction: "sendonly" });

			t.sender.replaceTrack(track);
			console.log("outgoing transceiver =>", t);

			globalThis.t = t;
		});

		return new Promise((resolve, reject) => {
			this.negotiationPromise = { resolve, reject };
		});
	}

	setRemoteTruncatedSDP(sdp: string, video_codec: string, audio_codec: string) {
		const { media } = transform.parse(sdp);
		if (!media.length) return;

		this.remoteMedia = media[0];
		return this.handleNegotiation();
	}

	getRemoteSDP() {
		if (!this.remoteMedia) throw new Error("Remote SDP not yet set");
		var remoteSDP: SessionDescription = JSON.parse(JSON.stringify(this.localSDP));
		// remote sdp is the simulated remote SDP that discord would send, if they followed the spec.
		// We are constructing the SDP ourselves with the information we got over the websocket (ssrc, user id)
		// We are using the local SDP as a template and replacing the information we got from discord

		remoteSDP = {
			...remoteSDP,
			version: 0,
			timing: {
				start: 0,
				stop: 0,
			},
			origin: {
				address: "127.0.0.1",
				ipVer: 4,
				netType: "IN",
				sessionId: "1420070400000",
				sessionVersion: 0,
				username: "-",
			},
			name: "-",
			msidSemantic: {
				semantic: "WMS",
				token: "*",
			},
		};

		remoteSDP.media.forEach((media) => {
			// for each transceiver we generate an answer
			// for all active transceivers, we return the required information (ssrc + active direction)

			// set properties that are required for all transceivers
			// candidates, connection, fingerprint, icepwd, iceufrag, port, protocol, rtcp, type (fmtp[],rtp[])
			media.candidates = this.remoteMedia!.candidates;
			media.connection = this.remoteMedia!.connection;
			media.fingerprint = this.remoteMedia!.fingerprint;
			media.icePwd = this.remoteMedia!.icePwd;
			media.iceUfrag = this.remoteMedia!.iceUfrag;
			media.port = this.remoteMedia!.port;
			media.rtcp = this.remoteMedia!.rtcp;
			media.setup = "passive";
			media.protocol = "UDP/TLS/RTP/SAVPF";
			media.rtcpMux = "rtcp-mux";
			media.maxptime = 60;

			if (media.type === "audio") {
				// set audio specific properties
				media.payloads = "111";
				media.rtp = [
					{
						payload: 111,
						codec: "opus",
						rate: 48000,
						encoding: 2,
					},
				];
				media.fmtp = [
					{
						payload: 111,
						config: "minptime=10;useinbandfec=1;usedtx=1",
					},
				];
				media.rtcpFb = [
					{
						payload: 111,
						type: "transport-cc",
					},
				];
				media.ext = [
					{
						value: 1,
						uri: "urn:ietf:params:rtp-hdrext:ssrc-audio-level",
					},
					{
						value: 3,
						uri: "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01",
					},
				];
			} else {
				// set video specific properties
				media.payloads = "102 122";
				
				media.rtp = [
					{
						payload: 102,
						codec: "H264",
						rate: 90000,
					},
					{
						payload: 122,
						codec: "rtx",
						rate: 90000,
					},
				];
				media.bandwidth = [{ type: "AS", limit: 3000 }];
				media.fmtp = [
					{
						payload: 102,
						config: `level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f`,
					},
					{
						payload: 122,
						config: "apt=102",
					},
				];
				media.rtcpFb = [
					{
						payload: 102,
						type: "ccm",
						subtype: "fir",
					},
					{
						payload: 102,
						type: "nack",
					},
					{
						payload: 102,
						type: "nack",
						subtype: "pli",
					},
					{
						payload: 102,
						type: "goog-remb",
					},
					{
						payload: 102,
						type: "transport-cc",
					},
				];
			}

			// delete local only properties, that are not allowed/needed in the remote SDP
			delete media.iceOptions;
			delete media.ssrcGroups;
			delete media.msid;
			delete media.rtcpRsize;

			const inbound = this.streams_inbound.find(
				(x) => (x.mid == media.mid || x.mid == null) && x.type === media.type
			);

			if (media.ssrcs) {
				// active transceivers that are being recorded and sent to discord
				if (media.direction !== "sendrecv") media.direction = "recvonly";
				delete media.ssrcs; // local ssrcs, not used for remote
			} else if (inbound && media.direction !== "sendonly") {
				// active transceivers that are rceived from discord
				if (media.direction != "sendrecv") media.direction = "sendonly";
				inbound.mid = media.mid;
				media.ssrcs = [
					{
						id: inbound.ssrc,
						value: inbound.user_id + "-" + inbound.ssrc,
						attribute: "cname",
					},
				];
				if (inbound.rtx_ssrc) {
					// used for video
					media.ssrcs.push({
						id: inbound.rtx_ssrc!,
						value: inbound.user_id + "-" + inbound.ssrc,
						attribute: "cname",
					});
					media.ssrcGroups = [
						{
							semantics: "FID",
							ssrcs: inbound.ssrc + " " + inbound.rtx_ssrc,
						},
					];
				}
				media.msid = `${inbound.user_id}-${inbound.ssrc} ${inbound.type === "audio" ? "a" : "v"}${
					inbound.user_id
				}-${inbound.ssrc}`;
				console.log("attached inbound stream", inbound, media);
			} else {
				// inactive transceivers

				media.direction = "inactive";
				delete media.ssrcs;
			}
		});

		return new RTCSessionDescription({ type: "answer", sdp: transform.write(remoteSDP) });
	}

	reverseDirection = (e: string) => {
		switch (e) {
			case "recvonly":
				return "sendonly";
			case "sendonly":
				return "recvonly";
			case "sendrecv":
				return "sendrecv";
			default:
				return "inactive";
		}
	};

	createUser = (userId: string, audioSSRC?: number, videoSSRC?: number, rtxSSRC?: number) => {
		if (audioSSRC && !this.streams_inbound.find((x) => x.ssrc == audioSSRC)) {
			console.log("createUser audio", userId, audioSSRC);
			this.streams_inbound.push({
				user_id: userId,
				ssrc: audioSSRC,
				type: "audio",
			});
			this.pc.addTransceiver("audio", { direction: "recvonly" });
		}
		if (videoSSRC && !this.streams_inbound.find((x) => x.ssrc == videoSSRC)) {
			console.log("createUser video", userId, videoSSRC);
			this.streams_inbound.push({
				user_id: userId,
				ssrc: videoSSRC,
				rtx_ssrc: rtxSSRC,
				type: "video",
			});

			this.pc.addTransceiver("video", { direction: "recvonly" });
		}

		if (!this.call.participants.has(userId)) {
			this.call.participants.set(
				userId,
				new Participant(
					{
						user_id: userId,
						muted: false,
						ringing: false,
					},
					this.call
				)
			);
		}
	};

	destroyUser = (userId: string, audioSSRC?: number, videoSSRC?: number) => {
		this.call.participants.delete(userId);

		const inbound = this.streams_inbound.find((x) => x.user_id == userId);
		if (!inbound) return;

		this.streams_inbound.remove(inbound);
		this.pc
			.getTransceivers()
			.find((x) => x.mid === inbound.mid)
			?.receiver.track.stop();
	};

	async handleNegotiation() {
		console.log("[WebRTC] handleNegotiation");
		const offer = await this.pc.createOffer({
			iceRestart: false,
			// offerToReceiveAudio: true,
		});

		this.localSDP = transform.parse(offer.sdp!);
		console.log("[WebRTC] Local SDP", this.localSDP);
		await this.pc.setLocalDescription(offer);

		this.audioSSRC = this.localSDP.media.find(
			(x) => x.type === "audio" && (x.direction === "sendonly" || x.direction === "sendrecv")
		)?.ssrcs?.[0].id as number;
		this.videoSSRC = this.localSDP.media.find(
			(x) => x.type === "video" && (x.direction === "sendonly" || x.direction === "sendrecv")
		)?.ssrcs?.[0].id as number;
		this.rtxVideoSSRC = this.localSDP.media.find(
			(x) => x.type === "video" && (x.direction === "sendonly" || x.direction === "sendrecv")
		)?.ssrcs?.[2].id as number;

		if (this.negotiationPromise) {
			this.negotiationPromise.resolve();
			this.negotiationPromise = undefined;
		}

		if (!this.remoteMedia) return;

		const answer = this.getRemoteSDP();
		console.log("[WebRTC] Remote SDP", transform.parse(answer.sdp!));
		await this.pc.setRemoteDescription(answer);
	}
}

I fixed my error by properly generating the SDP. I used sdp-transform together with a custom lightweight transportation alternative to establish the call with much less overhead than the default 100kb SDP. However, there were some issues with the sdp media tracks in my implementation that I was able to fix and resolve the issue.

@Flam3rboy Hi! Having the same problem, have you solved it? Could you share with your solution?

Don’t create a new RTCSessoonDecription, just pass a bare object.