@babblevoice/projectrtp
Version:
A scalable Node addon RTP server
303 lines (246 loc) • 11.2 kB
JavaScript
const expect = require( "chai" ).expect
const dgram = require( "dgram" )
const projectrtp = require( "../../index.js" ).projectrtp
/* Standalone safety net: in the full suite projectrtpserver.js's root before()
already calls run(); run() is idempotent so a second call here is a no-op.
No after()/shutdown here — projectrtpserver.js owns teardown, and mocha's
--exit reaps the process for a standalone run. */
before( () => { projectrtp.run() } )
/* Send one PCMU RTP packet (seq = sn+100, ts = sn*160, ssrc = 25) from `peer`
to the channel's local port, scheduled at sn*20ms — mirrors the helper used
across the other interface suites. */
function sendpk( sn, dstport, peer ) {
return setTimeout( () => {
const payload = Buffer.alloc( 160 ).fill( sn & 0xff )
const header = Buffer.alloc( 12 )
header[ 0 ] = 0x80
header[ 1 ] = 0x00 /* PT 0 = PCMU */
header.writeUInt16BE( ( sn + 100 ) % ( 2 ** 16 ), 2 )
header.writeUInt32BE( sn * 160, 4 )
header.writeUInt32BE( 25, 8 )
peer.send( Buffer.concat( [ header, payload ] ), dstport, "127.0.0.1" )
}, sn * 20 )
}
/* Walk a compound RTCP datagram into { pt, offset } records. length is the
RFC 3550 sub-packet size in 32-bit words minus one. */
function walkrtcp( buf ) {
const items = []
let off = 0
while( off + 4 <= buf.length ) {
const pt = buf[ off + 1 ]
const words = buf.readUInt16BE( off + 2 )
items.push( { pt, off } )
off += ( words + 1 ) * 4
}
return items
}
/* Build a receiver report (PT 201) with a single report block about `aboutssrc`
— the stream the channel sends — so the channel's rtcp_loop folds it into its
RemoteReport. LSR/DLSR left 0 so no RTT is derived (rttms stays null). */
function buildrr( senderssrc, aboutssrc, fraction, cumulative, jitter ) {
const b = Buffer.alloc( 32 )
b[ 0 ] = 0x80 | 0x01 /* V=2, RC=1 */
b[ 1 ] = 201
b.writeUInt16BE( 7, 2 ) /* (32 bytes / 4) - 1 */
b.writeUInt32BE( senderssrc >>> 0, 4 )
b.writeUInt32BE( aboutssrc >>> 0, 8 )
b[ 12 ] = fraction
b[ 13 ] = ( cumulative >> 16 ) & 0xff
b[ 14 ] = ( cumulative >> 8 ) & 0xff
b[ 15 ] = cumulative & 0xff
b.writeUInt32BE( 0, 16 ) /* ext highest seq */
b.writeUInt32BE( jitter, 20 )
b.writeUInt32BE( 0, 24 ) /* LSR */
b.writeUInt32BE( 0, 28 ) /* DLSR */
return b
}
describe( "rtcp", function() {
it( "emits SR + SDES on P+1 and reflects a peer RR in the close stats", async function() {
/* Randomised first report fires ~1–3 s (half a randomised interval); headroom. */
this.timeout( 9000 )
this.slow( 8000 )
const rtp = dgram.createSocket( "udp4" )
const rtcp = dgram.createSocket( "udp4" )
rtp.on( "message", () => {} ) /* drain echoed audio */
/* Bind the RTP peer, then bind the RTCP peer at its port + 1 — the address
the channel derives for symmetric RTCP without mux. */
await new Promise( ( res ) => rtp.bind( res ) )
const peerport = rtp.address().port
await new Promise( ( res, rej ) =>
rtcp.bind( peerport + 1, ( e ) => ( e ? rej( e ) : res() ) ) )
let closestats
let resolveclose
const closed = new Promise( ( res ) => { resolveclose = res } )
const gotsr = new Promise( ( res ) =>
rtcp.once( "message", ( m ) => res( m ) ) )
const channel = await projectrtp.openchannel(
{ "remote": { "address": "127.0.0.1", "port": peerport, "codec": 0 } },
function( d ) {
if( "close" === d.action ) {
closestats = d.stats
resolveclose()
}
} )
/* Echo so out_count > 0 → the periodic report is an SR (not an RR), and
feed 50 packets up front so the channel latches the remote address and
has a reception report to send about the peer. */
expect( channel.echo() ).to.be.true
for( let i = 0; 50 > i; i++ ) sendpk( i, channel.local.port, rtp )
/* First compound the channel sends us. */
const sr = await gotsr
expect( sr.length ).to.be.at.least( 28 )
expect( sr[ 0 ] >> 6 ).to.equal( 2 ) /* RTP version 2 */
const items = walkrtcp( sr )
expect( items[ 0 ].pt ).to.equal( 200 ) /* first sub-packet is an SR */
expect( items.some( ( it ) => 202 === it.pt ) ).to.be.true /* SDES present */
/* SR sender SSRC == the channel's SSRC. */
expect( sr.readUInt32BE( 4 ) ).to.equal( channel.local.ssrc >>> 0 )
/* Send a crafted RR about the channel's stream; the channel should fold it
into RemoteReport and surface it on close. */
rtcp.send(
buildrr( 25, channel.local.ssrc, 25, 12, 40 ),
channel.local.port + 1, "127.0.0.1" )
/* Give the rtcp_loop a moment to process, then close. */
await new Promise( ( r ) => setTimeout( r, 200 ) )
channel.close()
await closed
rtp.close()
rtcp.close()
/* The Close event carries the RTCP summary. */
expect( closestats ).to.have.property( "rtcp" )
const r = closestats.rtcp
/* Our reception of the peer (50 clean packets → ~no loss). */
expect( r.in.valid ).to.equal( true )
expect( r.in.cumulativelost ).to.be.a( "number" )
expect( r.in.jitter ).to.be.a( "number" )
/* The peer's reported reception of us — the crafted RR values. */
expect( r.out.valid ).to.equal( true )
expect( r.out.fractionlost ).to.equal( 25 )
expect( r.out.cumulativelost ).to.equal( 12 )
expect( r.out.jitter ).to.equal( 40 )
/* LSR was 0 in the RR, so no RTT could be derived. */
expect( r.rttms ).to.equal( null )
} )
it( "carries RTCP over the RTP port when rtcp-mux is negotiated (RFC 5761)", async function() {
/* Randomised first report fires ~1-3s; keep the P+1-test headroom. */
this.timeout( 9000 )
this.slow( 8000 )
const rtp = dgram.createSocket( "udp4" )
const rtcp = dgram.createSocket( "udp4" ) /* the P+1 port — must stay silent under mux */
/* Resolve on the first RTCP compound seen *on the RTP port* — demux by the
RTCP packet-type byte (200..=204), ignoring the echoed PCMU audio. */
let resolvesr
const gotsr = new Promise( ( res ) => { resolvesr = res } )
rtp.on( "message", ( m ) => {
if( 2 <= m.length && 200 <= m[ 1 ] && 204 >= m[ 1 ] ) resolvesr( m )
} )
let p1count = 0
rtcp.on( "message", () => { p1count++ } )
await new Promise( ( res ) => rtp.bind( res ) )
const peerport = rtp.address().port
await new Promise( ( res, rej ) =>
rtcp.bind( peerport + 1, ( e ) => ( e ? rej( e ) : res() ) ) )
let closestats
let resolveclose
const closed = new Promise( ( res ) => { resolveclose = res } )
const channel = await projectrtp.openchannel(
{ "remote": { "address": "127.0.0.1", "port": peerport, "codec": 0, "rtcpmux": true } },
function( d ) {
if( "close" === d.action ) {
closestats = d.stats
resolveclose()
}
} )
expect( channel.echo() ).to.be.true
for( let i = 0; 50 > i; i++ ) sendpk( i, channel.local.port, rtp )
/* The channel's first RTCP compound — arriving on the RTP port, not P+1. */
const sr = await gotsr
expect( sr[ 0 ] >> 6 ).to.equal( 2 ) /* RTP version 2 */
const items = walkrtcp( sr )
expect( items[ 0 ].pt ).to.equal( 200 ) /* first sub-packet is an SR */
expect( items.some( ( it ) => 202 === it.pt ) ).to.be.true /* SDES present */
expect( sr.readUInt32BE( 4 ) ).to.equal( channel.local.ssrc >>> 0 )
/* Feed a crafted RR back over the *same* RTP port (mux) about our stream;
the recv_loop must demux it to the RTCP path and fold it. */
rtp.send(
buildrr( 25, channel.local.ssrc, 25, 12, 40 ),
channel.local.port, "127.0.0.1" )
await new Promise( ( r ) => setTimeout( r, 200 ) )
channel.close()
await closed
rtp.close()
rtcp.close()
/* The muxed RR was folded and surfaced in the close stats. */
expect( closestats ).to.have.property( "rtcp" )
expect( closestats.rtcp.out.valid ).to.equal( true )
expect( closestats.rtcp.out.fractionlost ).to.equal( 25 )
expect( closestats.rtcp.out.cumulativelost ).to.equal( 12 )
expect( closestats.rtcp.out.jitter ).to.equal( 40 )
/* Nothing should ever land on the separate P+1 control port under mux. */
expect( p1count ).to.equal( 0 )
} )
it( "sends an RTCP BYE (PT 203) on channel close", async function() {
this.timeout( 4000 )
this.slow( 3000 )
const rtp = dgram.createSocket( "udp4" )
const rtcp = dgram.createSocket( "udp4" )
rtp.on( "message", () => {} ) /* drain echoed audio */
await new Promise( ( res ) => rtp.bind( res ) )
const peerport = rtp.address().port
await new Promise( ( res, rej ) =>
rtcp.bind( peerport + 1, ( e ) => ( e ? rej( e ) : res() ) ) )
/* Resolve on the first compound that carries a BYE sub-packet. Closing
early (before the first periodic report) means the BYE is the only
datagram, but the filter is robust either way. */
let resolvebye
const gotbye = new Promise( ( res ) => { resolvebye = res } )
rtcp.on( "message", ( m ) => {
if( walkrtcp( m ).some( ( it ) => 203 === it.pt ) ) resolvebye( m )
} )
const channel = await projectrtp.openchannel(
{ "remote": { "address": "127.0.0.1", "port": peerport, "codec": 0 } },
function() {} )
/* Feed a few packets so the channel latches the remote address, then close
— the BYE is emitted on the close path. */
expect( channel.echo() ).to.be.true
for( let i = 0; 10 > i; i++ ) sendpk( i, channel.local.port, rtp )
await new Promise( ( r ) => setTimeout( r, 300 ) )
channel.close()
const bye = await gotbye
expect( walkrtcp( bye ).some( ( it ) => 203 === it.pt ) ).to.be.true
rtp.close()
rtcp.close()
} )
it( "populates in.skip and lowers MOS when inbound packets are lost", function( done ) {
const peer = dgram.createSocket( "udp4" )
peer.on( "message", () => {} )
this.timeout( 3000 )
this.slow( 2500 )
peer.bind()
peer.on( "listening", async function() {
const peerport = peer.address().port
const channel = await projectrtp.openchannel(
{ "remote": { "address": "127.0.0.1", "port": peerport, "codec": 0 } },
function( d ) {
if( "close" === d.action ) {
/* 10 of 50 inbound packets are dropped as sequence gaps, so RFC 3550
receiver accounting now records real loss. Before the fix in_skip
was never incremented and MOS was pinned at 4.5. */
expect( d.stats.in.skip ).to.be.above( 0 )
expect( d.stats.in.mos ).to.be.below( 4.4 )
peer.close()
done()
}
} )
expect( channel.echo() ).to.be.true
/* Drop 10 interior packets; keep seq 0 and 49 so the full range 100..149
is present and every gap counts as a loss. */
const drop = { 5: 0, 10: 0, 15: 0, 20: 0, 25: 0, 30: 0, 35: 0, 40: 0, 44: 0, 47: 0 }
for( let i = 0; 50 > i; i++ ) {
if( i in drop ) continue
sendpk( i, channel.local.port, peer )
}
setTimeout( () => channel.close(), 1500 )
} )
} )
} )