UNPKG

@babblevoice/babble-drachtio-callmanager

Version:
763 lines (647 loc) 17.6 kB
/** * TODO tidy all of teh ts-ignores in favour of defining data structures better. */ const sdptransform = require( "sdp-transform" ) const crypto = require( "crypto" ) /* An SDP Generator. */ let sessionidcounter = Math.floor( Math.random() * 100000 ) const prtpcodecpts = { "pcmu": 0, "pcma": 8, "g722": 9, "ilbc": 97, "2833": 101 } class codecconv { #pt2name = { "0": "pcmu", "8": "pcma", "9": "g722", "97": "ilbc", "101": "2833" } #name2pt = { "pcmu": 0, "pcma": 8, "g722": 9, "ilbc": 97, "2833": 101 } #defs = { "type": { "pcmu": "audio", "pcma": "audio", "g722": "audio", "ilbc": "audio", "2833": "audio", }, "rtp": { "pcmu": { payload: 0, codec: "PCMU", rate: 8000 }, "pcma": { payload: 8, codec: "PCMA", rate: 8000 }, "g722": { payload: 9, codec: "G722", rate: 8000 }, "ilbc": { payload: 97, codec: "ilbc", rate: 8000 }, "2833": { payload: 101, codec: "telephone-event/8000" } }, "fmtp": { "ilbc": { payload: 97, config: "mode=20" }, "2833": { payload: 101, config: "0-16" } /* 0-16 = DTMF */ } } /** * * @param { "pcma" | "pcmu" | "g722" | "ilbc" | "2833" } name * @param { string } codec the codec name as it appears in SDP i.e. "telephone-event" or "G722" * @param { number } pt */ setdynamicpt( name, codec, pt ) { for ( const pt2namept in this.#pt2name ) { if( codec == this.#pt2name[ pt2namept ] ) { delete this.#pt2name[ pt2namept ] delete this.#name2pt[ codec ] break } } this.#pt2name[ pt ] = name this.#name2pt[ codec ] = pt this.#defs.rtp[ name ].payload = pt this.#defs.fmtp[ name ].payload = pt } /** * * @param { string } pt * @returns { string } */ getcodec( pt ) { return this.#pt2name[ pt ] } /*** * @param { string } name * @returns { string } */ getpt( name ) { return this.#name2pt[ name ] } /** * Is it one of our supported codecs * @param { string } name * @returns { boolean } */ hascodec( name ) { return ( name in this.#name2pt ) } /** * * @param { string } pt * @returns { boolean } */ haspt( pt ) { return ( pt in this.#pt2name ) } /** * @returns { object } */ get def() { return this.#defs } static create() { return new codecconv() } } function defaultaudiomedia() { return { "rtp": [], "fmtp": [], "type": "audio", "port": 0, "protocol": "RTP/AVP", "payloads": [], "ptime": 20, "direction": "sendrecv" } } /** * * @param { object } audio * @param { number } pt * @returns { string } */ function getconfigforpt( audio, pt ) { for( const fmtp of audio.fmtp ) { if( pt == fmtp.payload ) return fmtp.config } return "" } class sdp { #dynamicpts #selected constructor( sdp ) { /* defaults inc. static */ this.#dynamicpts = codecconv.create() if ( undefined === sdp ) { sessionidcounter = ( sessionidcounter + 1 ) % 4294967296 this.sdp = { version: 0, origin: { username: "-", sessionId: sessionidcounter, sessionVersion: 0, netType: "IN", ipVer: 4, address: "127.0.0.1" }, name: "project", timing: { start: 0, stop: 0 }, connection: { version: 4, ip: "127.0.0.1" }, //iceUfrag: 'F7gI', //icePwd: 'x9cml/YzichV2+XlhiMu8g', //fingerprint: // { type: 'sha-1', // hash: '42:89:c5:c6:55:9d:6e:c8:e8:83:55:2a:39:f9:b6:eb:e9:a3:a9:e7' }, media: [ { rtp: [], fmtp: [], type: "audio", port: 0, protocol: "RTP/AVP", payloads: [], ptime: 20, direction: "sendrecv" } ] } } else { this.sdp = sdptransform.parse( sdp ) /* Convert payloads to something more consistent. Always an array of Numbers */ this.sdp.media.forEach( ( media, i, a ) => { if ( "audio" === media.type ) { if ( "string" === typeof media.payloads ) { // @ts-ignore media.payloads = media.payloads.split( /[ ,]+/ ) } if ( !Array.isArray( media.payloads ) ) { // @ts-ignore a[ i ].payloads = [ media.payloads ] } // @ts-ignore media.payloads.forEach( ( v, vi, va ) => va[ vi ] = Number( v ) ) /* handle our dynamic payloadtypes */ media.rtp.forEach( ( m ) => { switch( m.codec.toLowerCase() ) { case "ilbc": { if( 8000 == m.rate ) { this.#dynamicpts.setdynamicpt( "ilbc", "ilbc", m.payload ) } return } case "telephone-event": { if( 8000 == m.rate ) { this.#dynamicpts.setdynamicpt( "2833", "telephone-event", m.payload ) } } } } ) } } ) } } /** * Takes a mixed input and outputs an array in the form [ "pcmu", "pcma" ] * @param { string | Array<string> } codecarray * @return { Array< string >} */ alltocodecname( codecarray ) { /* check and convert to array */ if ( "string" === typeof codecarray ) { codecarray = codecarray.split( /[ ,]+/ ) } /* convert to payloads */ const retval = [] for( const oin of codecarray ) { if( this.#dynamicpts.hascodec( oin ) ) { retval.push( oin ) } else if( this.#dynamicpts.haspt( oin ) ) { retval.push( this.#dynamicpts.getcodec( oin ) ) } } return retval } /* Used by our rtpchannel to get the port and address information (and codec). Ideally we replicate the object required for target in our RTP service. */ getaudio() { const m = this.sdp.media.find( mo => "audio" === mo.type ) if ( m ) { let payloads = m.payloads if ( this.#selected !== undefined ) { payloads = [ this.#selected ] } let address let port = m.port // @ts-ignore if ( m.candidates ) { // @ts-ignore for( const c of m.candidates ) { /* { foundation: 842238307, component: 1, transport: 'udp', priority: 2113937151, ip: '2dcfedf6-d4e8-4a56-a0b6-efb390be339d.local', port: 48245, type: 'host', generation: 0, 'network-cost': 999 } */ if( !c.ip.endsWith( ".local" ) ) { address = c.ip port = c.port } } /*console.log( m.candidates )*/ } if( !address ) { if( this.sdp.connection ) address = this.sdp.connection.ip else if( this.sdp.origin.address ) address = this.sdp.origin.address } return { "port": port, "address": address, "audio": { "payloads": payloads } } } return false } /** * select works in conjunction with getaudioremote and allows us to force the * selection of the codec we send to our RTP server. This is used on the offered SDP. * If intersect has been called with firstonly flag set then this has the same effect. * @param { string } codec */ select( codec ) { if ( isNaN( parseInt( codec ) ) ) { if ( undefined === this.#dynamicpts.hascodec( codec ) ) return codec = this.#dynamicpts.getpt( codec ) } this.#selected = codec return this } /** * @returns { object | undefined } * @property { string } name - the name of the codec - i.e. pcma * @property { number } pt - the payload type (static) used for prtp * @property { number } dpt - the dynamic payload type negotiated for this session */ get selected() { if( undefined === this.#selected ) return undefined const name = this.#dynamicpts.getcodec( this.#selected ) return { name, pt: prtpcodecpts[ name ], dpt: this.#selected } } static create( from ) { return new sdp( from ) } setsessionid( i ) { this.sdp.origin.sessionId = i return this } setconnectionaddress( addr ) { this.sdp.connection.ip = addr return this } setoriginaddress( addr ) { this.sdp.origin.address = addr return this } setaudioport( port ) { this.getmedia().port = port return this } /** * * @param { string } type * @returns */ getmedia( type = "audio" ) { let m = this.sdp.media.find( mo => type === mo.type ) if ( !m ) { // @ts-ignore this.sdp.media.push( defaultaudiomedia() ) m = this.sdp.media[ this.sdp.media.length - 1 ] } return m } /** * * @param { "sendrecv"|"inactive"|"sendonly"|"recvonly" } direction * @returns { object } */ setaudiodirection( direction ) { this.getmedia().direction = direction return this } /** * Add a CODEC or CODECs, formats: * "pcma" * "pcma pcmu" * "pcma, pcmu" * [ "pcma", pcmu ] * @param { string | Array< string > } codecs */ addcodecs( codecs ) { let codecarr = codecs if ( !Array.isArray( codecarr ) && "string" === typeof codecs ) { codecarr = codecs.split( /[ ,]+/ ) } else { codecarr = [] } codecarr.forEach( codec => { const codecn = this.#dynamicpts.getpt( codec ) const def = this.#dynamicpts.def.rtp[ codec ] if ( undefined !== def ) { /* suported audio */ const m = this.getmedia( this.#dynamicpts.def.type[ codec ] ) /* Don't allow duplicates */ if( m.payloads.includes( codecn ) ) return m.rtp.push( def ) // @ts-ignore m.payloads.push( def.payload ) if ( undefined !== this.#dynamicpts.def.fmtp[ codec ] ) { m.fmtp.push( this.#dynamicpts.def.fmtp[ codec ] ) } } } ) return this } /** * Add SSRC to each media entry. This ties together multiple streams in one * which will be important when we add video. * @param { string } ssrc string representing the ssrc */ addssrc( ssrc ) { // @ts-ignore this.sdp.msidSemantic = { "semantic": "WMS", "token": crypto.randomBytes( 16 ).toString( "hex" ) } for( const m of this.sdp.media ) { const mmsid = crypto.randomBytes( 16 ).toString( "hex" ) // @ts-ignore m.ssrcs = [ { "id": ssrc, "attribute": "cname", "value": crypto.randomBytes( 16 ).toString( "hex" ) }, // @ts-ignore { "id": ssrc, "attribute": "msid", "value": this.sdp.msidSemantic.token + " " + mmsid }, // @ts-ignore { "id": ssrc, "attribute": "mslabel", "value": this.sdp.msidSemantic.token }, { "id": ssrc, "attribute": "label", "value": mmsid } ] // @ts-ignore m.msid = m.ssrcs[ 1 ].value } return this } /** * Configures the SDP for DTLS (WebRTC). * Limitation is it requires the same fingerprint for each connection * TODO - seperate each media connection for different fingerprints. * @param { string } fingerprint - i.e. "D3:55:21:F4..." * @param { string } actpass - "active|passive|actpass" */ secure( fingerprint, actpass ) { for( const m of this.sdp.media ) { m.protocol = "UDP/TLS/RTP/SAVPF" // @ts-ignore m.fingerprint = { "type": "sha-256", "hash": fingerprint } // @ts-ignore m.setup = actpass } return this } /** * Adds ICE candidate to SDP */ addicecandidates( ip, port, icepwd ) { for( const m of this.sdp.media ) { // @ts-ignore m.candidates = [ { "foundation": 1, /* RFC 5245 4.1.1.3 */ "component": 1, "transport": "udp", "priority": 255, /* RFC 5245 4.1.2 & 4.1.2.1 - we only have 1 candidate */ "ip": ip, "port": port, "type": "host", "generation": 0 } ] // @ts-ignore m.iceUfrag = crypto.randomBytes( 8 ).toString( "hex" ) // @ts-ignore m.icePwd = icepwd } return this } rtcpmux() { for( const m of this.sdp.media ) { // @ts-ignore m.rtcpMux = "rtcp-mux" } return this } clearcodecs() { this.sdp.media.forEach( m => { m.payloads = [] m.rtp = [] m.fmtp = [] } ) return this } /** * Gets a list of codecs (that we support) and return as an array of strings. * @param { string } type * @returns { Array< string > } array of codec names in the format [ "pcma" ] */ #codecs( type = "audio" ) { const audio = this.getmedia( type ) /* work out an array of codecs on this side in the format of [ "pcma", "pcmu" ] */ const ourcodecs = [] for( const pt of audio.payloads ) { if( !this.#dynamicpts.haspt( pt ) ) continue if( this.#dynamicpts.getpt( "ilbc" ) == pt ) { if( -1 == getconfigforpt( audio, pt ).indexOf( "mode=30" ) ) ourcodecs.push( this.#dynamicpts.getcodec( pt ) ) } else { ourcodecs.push( this.#dynamicpts.getcodec( pt ) ) } } return ourcodecs } /* Only allow CODECs supported by both sides. other can be: "pcma pcmu ..." "pcma,pcmu" "0,8" "0 8" [ "pcma", "pcmu" ] [ 0, 8 ] Returns a codec string "pcma pcmu" If first ony, it only returns the first match */ intersection( other, firstonly = false ) { /* ensure other side is on the format [ "pcma", "pcmu" ] */ other = this.alltocodecname( other ) const ourcodecs = this.#codecs() /* intersection */ let retval = other.filter( value => ourcodecs.includes( value ) ) /* If fisrt only - i.e. select codec */ if ( firstonly && 0 < retval.length ) { retval = [ retval[ 0 ] ] this.select( retval[ 0 ] ) } const full = retval.join( " " ) if( !full ) return false return full } /** * See other param in intersection. Confirms that we have * support for at least one of the codecs in codecs * @param { Array< string > | string } codecs */ has( codecs ) { const ourcodecs = this.#codecs() codecs = this.alltocodecname( codecs ) /* intersection */ if( undefined === codecs.find( value => ourcodecs.includes( value ) ) ) return false return true } /** * @returns { object } an object of codec name to payload type * i.e. * { * "ilbc": { payload: 101, codec: "iLBC", rate: 8000 } * } * NB: it only returns a) our supported codecs and b) dynamic codecs - pcma, pcmu, g722 are statically defined */ getdynamicpayloadtypes() { const retval = {} this.sdp.media.forEach( ( v ) => { if( "rtp" in v ) { v.rtp.forEach( r => { const cname = r.codec.toLowerCase() switch( cname ) { case "ilbc": if( 8000 == r.rate ) retval[ cname ] = r break case "telephone-event": if( 8000 == r.rate ) retval[ "rfc2833" ] = r } } ) } } ) return retval } /** * Takes an object as returned by getdynamicpayloadtypes on another object * to set the dynameic payloadtypes on this object * @param { sdp } othersdp */ setdynamepayloadtypes( othersdp ) { if( !othersdp ) return this const o = othersdp.getdynamicpayloadtypes() if( "ilbc" in o && 8000 == o.ilbc.rate ) { this.#dynamicpts.setdynamicpt( "ilbc", "ilbc", o.ilbc.payload ) const m = this.sdp.media.find( mo => "audio" === mo.type ) if( m ) { // @ts-ignore const ilbcindex = m.payloads.indexOf( prtpcodecpts.ilbc ) if( -1 !== ilbcindex ) { // @ts-ignore m.payloads.splice( ilbcindex, 1, o.ilbc.payload ) } } } if( "rfc2833" in o && 8000 == o.rfc2833.rate ) { this.#dynamicpts.setdynamicpt( "2833", "telephone-event", o.rfc2833.payload ) const m = this.sdp.media.find( mo => "audio" === mo.type ) if( m ) { // @ts-ignore const televindex = m.payloads.indexOf( prtpcodecpts[ "2833" ] ) if( -1 !== televindex ) { // @ts-ignore m.payloads.splice( televindex, 1, o.rfc2833.payload ) } } } return this } toString() { const co = Object.assign( this.sdp ) let rfc2833 = "" if( this.#dynamicpts.hascodec( "2833" ) ) { rfc2833 = this.#dynamicpts.getpt( "2833" ) } /** only return selected */ if( undefined !== this.#selected ) { co.media.forEach( ( media ) => { media.rtp = media.rtp.filter( item => [ this.#selected, rfc2833 ].includes( item.payload )) media.fmtp = media.fmtp.filter( item => [ this.#selected, rfc2833 ].includes( item.payload )) media.payloads = [ this.#selected ] if( rfc2833 ) media.payloads.push( rfc2833 ) } ) } /* We need to convert payloads back to string to stop a , being added */ co.media.forEach( ( media, i, a ) => { if( Array.isArray( media.payloads ) ) { a[ i ].payloads = media.payloads.join( " " ) } } ) return sdptransform.write( co ) } } module.exports = sdp