import {SessionDescription} from "sdp-transform"; import * as sdpTransform from "sdp-transform"; import { tr, tra } from "tc-shared/i18n/localize"; export interface RTCNegotiationMediaMapping { direction: "sendrecv" | "recvonly" | "sendonly" | "inactive", ssrc: number } export interface RTCNegotiationIceConfig { username: string, password: string, fingerprint: string, fingerprint_type: string, setup: "active" | "passive" | "actpass", candidates: string[] } export interface RTCNegotiationExtension { id: number; uri: string; media?: "audio" | "video"; direction?: "recvonly" | "sendonly"; config?: string; } export interface RTCNegotiationCodec { payload: number, name: string, channels?: number, rate?: number, fmtp?: string, feedback?: string[] } /** The offer send by the client to the server */ export interface RTCNegotiationOffer { type: "initial-offer" | "negotiation-offer", sessionId: number, ssrcs: number[], ssrc_types: number[], ice: RTCNegotiationIceConfig, /* Only present in initial response */ extension: RTCNegotiationExtension | undefined } /** The offer send by the server to the client */ export interface RTCNegotiationMessage { type: "initial-offer" | "negotiation-offer" | "initial-answer" | "negotiation-answer", sessionId: number, sessionUsername: string, ssrc: number[], ssrc_flags: number[], ice: RTCNegotiationIceConfig, /* Only present in initial answer */ extension: RTCNegotiationExtension[] | undefined, /* Only present in initial answer */ audio_codecs: RTCNegotiationCodec[] | undefined, /* Only present in initial answer */ video_codecs: RTCNegotiationCodec[] | undefined } type RTCDirection = "sendonly" | "recvonly" | "sendrecv" | "inactive"; type SsrcFlags = { type: "audio", mode: RTCDirection } | { type: "video", mode: RTCDirection } namespace SsrcFlags { function parseRtcDirection(direction: number) : RTCDirection { switch (direction) { case 0: return "sendrecv"; case 1: return "sendonly"; case 2: return "recvonly"; case 3: return "inactive"; default: throw tra("invalid rtc direction type {}", direction); } } export function parse(flags: number) : SsrcFlags { switch (flags & 0x7) { case 0: return { type: "audio", mode: parseRtcDirection((flags >> 3) & 0x3) }; case 1: return { type: "video", mode: parseRtcDirection((flags >> 3) & 0x3) }; default: throw tr("invalid ssrc flags"); } } } function generateSdp(session: RTCNegotiationMessage) { const sdp = {} as SessionDescription; sdp.version = 0; sdp.origin = { username: session.sessionUsername, sessionId: session.sessionId, ipVer: 4, netType: "IN", address: "127.0.0.1", sessionVersion: 2 }; sdp.name = "-"; sdp.timing = { start: 0, stop: 0 }; sdp.groups = [{ type: "BUNDLE", mids: [...session.ssrc].map((_, idx) => idx).join(" ") }]; sdp.media = []; const generateMedia = (ssrc: number, flags: SsrcFlags) => { let formats: RTCNegotiationCodec[]; if(flags.type === "audio") { formats = session.audio_codecs; } else if(flags.type === "video") { formats = session.video_codecs; } const index = sdp.media.push({ type: flags.type, port: 9, protocol: "UDP/TLS/RTP/SAVPF", payloads: formats.map(e => e.payload).join(" "), fmtp: [], rtp: [], rtcpFb: [], ext: [], ssrcs: [], invalid: [] }); const tMedia = sdp.media[index - 1]; /* basic properties */ tMedia.mid = (index - 1).toString(); tMedia.direction = flags.mode; tMedia.rtcpMux = "rtcp-mux"; /* ice */ tMedia.iceUfrag = session.ice.username; tMedia.icePwd = session.ice.password; tMedia.invalid.push({ value: "ice-options:trickle" }); tMedia.fingerprint = { hash: session.ice.fingerprint, type: session.ice.fingerprint_type, }; tMedia.setup = session.ice.setup; /* codecs */ for(const codec of formats) { tMedia.rtp.push({ codec: codec.name, payload: codec.payload, rate: codec.rate, encoding: codec.channels }); for(const feedback of codec.feedback || []) { tMedia.rtcpFb.push({ payload: codec.payload, type: feedback }); } if(codec.fmtp) { tMedia.fmtp.push({ payload: codec.payload, config: codec.fmtp }); } } /* extensions */ for(const extension of session.extension || []) { if(extension.media && extension.media !== tMedia.type) { continue; } tMedia.ext.push({ value: extension.id, config: extension.config, direction: extension.direction, uri: extension.uri }); } /* ssrc */ tMedia.ssrcs.push({ id: ssrc >>> 0, attribute: "cname", value: (ssrc >>> 0).toString(16) }); }; for(let index = 0; index < session.ssrc.length; index++) { const flags = SsrcFlags.parse(session.ssrc_flags[index]); generateMedia(session.ssrc[index], flags); } return sdpTransform.write(sdp); } export class RTCNegotiator { private readonly peer: RTCPeerConnection; public callbackData: (data: string) => void; public callbackFailed: (reason: string) => void; private sessionCodecs: RTCNegotiationCodec | undefined; private sessionExtensions: RTCNegotiationExtension | undefined; constructor(peer: RTCPeerConnection) { this.peer = peer; } doInitialNegotiation() { } handleRemoteData(dataString: string) { } } /* TODO: mozilla...THIS_IS_SDPARTA-82.0.3 (Needs to be parsed from the offer) */ /* console.error("XYX\n%s", generateSdp({ sessionId: "1234", audio_media: [ { direction: "sendrecv", ssrc: 123885, }, { direction: "sendrecv", ssrc: 123885, }, { direction: "sendrecv", ssrc: 123885, }, { direction: "sendrecv", ssrc: 123885, } ], video_media: [ { direction: "sendrecv", ssrc: 123885, }, { direction: "sendrecv", ssrc: 123885, }, { direction: "sendrecv", ssrc: 123885, }, { direction: "sendrecv", ssrc: 123885, } ], audio_codecs: [{ payload: 88, channels: 2, feedback: ["nack"], fmtp: "minptime=10;useinbandfec=1", name: "opus", rate: 48000 }], video_codecs: [{ payload: 89, name: "VP8", feedback: ["nack", "nack pli"] }], extension: [{ id: 1, uri: "urn:ietf:params:rtp-hdrext:ssrc-audio-level" }], ice: { candidates: [], fingerprint: "E6:C3:F3:17:71:11:4B:E5:1A:DD:EC:3C:AA:F2:BB:48:08:3B:A5:69:18:44:4A:97:59:62:BF:B4:43:F1:5D:00", fingerprint_type: "sha-256", password: "passwd", username: "uname", setup: "actpass" } }, "-") ); throw "dummy load error"; */