@babblevoice/projectrtp
Version:
A scalable Node addon RTP server
879 lines (740 loc) • 37.6 kB
JavaScript
/*
* End-to-end codec chain tests with **real G.722 on the wire**.
*
* Topology (matches a real 2-leg Poly ↔ Mobile call):
*
* JS sine gen ──► /tmp/tone400.wav
* │
* │ (player loop, projectrtp reads WAV, encodes)
* ▼
* ┌────────────────────────────────┐
* │ PseudoPoly (codec=G.722) │ ← LOCAL mode, not in any mix.
* │ remote = Ch_A.local │ "Plays the role of the Poly
* └────────────────────────────────┘ phone: its player output is
* │ G.722 on the wire."
* │ UDP G.722 (pt=9)
* ▼
* ┌────────────────────────────────┐
* │ Ch_A (codec=G.722, mixed w/ B) │ ── decodes G.722 from PseudoPoly
* │ remote = sink (we don't care) │ via phase-2, frame → mix
* └────────────────────────────────┘
* │ (mix: Ch_A's 8 kHz linear → Ch_B.codec)
* ▼
* ┌────────────────────────────────┐
* │ Ch_B (codec=PCMA, mixed w/ A) │ ── encodes to PCMA via mix, sends
* │ remote = JS measurement socket │ outbound to JS.
* └────────────────────────────────┘
* │ UDP PCMA (pt=8)
* ▼
* JS endpoint collects PCMA
* → decode (JS a-law table)
* → FFT, assert 400 Hz dominant
* → write received audio to /tmp/<test>.wav
*
* This exercises the full G.722 → 8 kHz → PCMA chain that the real
* server hits on a Poly → Mobile call. The WAV output on disk lets you
* listen / load into a DAW when something goes wrong.
*
* To cover more shapes (different codecs, different mix positions),
* `runchain()` takes the 3 codecs as parameters. The "capture" leg
* (Ch_B) has to be PCMA or PCMU because JS can't decode G.722 / iLBC.
*/
const expect = require( "chai" ).expect
const dgram = require( "dgram" )
const fs = require( "fs" )
const fft = require( "fft-js" ).fft
const projectrtp = require( "../../index" ).projectrtp
// ---- WAV writer -----------------------------------------------------------
//
// Minimal PCM-16 mono RIFF/WAVE. `samples` is linear 16-bit at `sampleRate`.
// We don't use the projectrtp recorder because that would require a channel;
// here we just want a file on disk to inspect when a test fails.
/**
* @param { string } path
* @param { number[] | Int16Array } samples
* @param { number } sampleRate
*/
function writewav( path, samples, sampleRate ) {
const pcmBytes = samples.length * 2
const header = Buffer.alloc( 44 )
header.write( "RIFF", 0 )
header.writeUInt32LE( 36 + pcmBytes, 4 )
header.write( "WAVE", 8 )
header.write( "fmt ", 12 )
header.writeUInt32LE( 16, 16 ) // fmt chunk size
header.writeUInt16LE( 1, 20 ) // PCM
header.writeUInt16LE( 1, 22 ) // mono
header.writeUInt32LE( sampleRate, 24 )
header.writeUInt32LE( sampleRate * 2, 28 ) // byte rate
header.writeUInt16LE( 2, 32 ) // block align
header.writeUInt16LE( 16, 34 ) // bits per sample
header.write( "data", 36 )
header.writeUInt32LE( pcmBytes, 40 )
const pcm = Buffer.alloc( pcmBytes )
for( let i = 0; i < samples.length; i++ ) pcm.writeInt16LE( samples[ i ], i * 2 )
fs.writeFileSync( path, Buffer.concat( [ header, pcm ] ) )
}
// ---- FFT helpers ----------------------------------------------------------
function truncpow2( arr ) {
const n = Math.pow( 2, Math.floor( Math.log2( arr.length ) ) )
return arr.slice( 0, n )
}
/**
* Compute the single-sided magnitude spectrum — first N/2 bins only.
* The DFT of a real signal is conjugate-symmetric, so bin (N-k)
* mirrors bin k with the same magnitude. Before discarding the second
* half, `energyat/total` ratios were artificially halved (the tone's
* main lobe at bin k and its mirror at bin N-k both landed in
* `etotal` but only the first did in `e400`).
*
* @param { number[] | Int16Array } samples → bin magnitudes (length = N/2)
*/
function ampfft( samples ) {
const c = fft( truncpow2( Array.from( samples ) ) )
const amps = c.map( ( [ r, i ] ) => Math.sqrt( r * r + i * i ) )
return amps.slice( 0, amps.length / 2 )
}
/**
* Sum of magnitudes in the `±window` bins centred on `hz`. Expects a
* single-sided spectrum from `ampfft` (length = N/2, indexed 0 → N/2-1
* across 0 → Nyquist Hz).
* @param { number[] } amps
* @param { number } hz
* @param { number } sampleRate
* @param { number } window
*/
function energyat( amps, hz, sampleRate, window = 4 ) {
const nyquist = sampleRate / 2
const bin = Math.round( ( hz / nyquist ) * amps.length )
let s = 0
for( let i = Math.max( 0, bin - window ); i <= Math.min( amps.length - 1, bin + window ); i++ ) {
s += amps[ i ]
}
return s
}
// ---- G.711 decoders (JS can do µ-law / A-law; cannot do G.722) ------------
/** @param { Buffer } bytes */
function pcmatolinear( bytes ) {
const out = new Int16Array( bytes.length )
for( let i = 0; i < bytes.length; i++ ) out[ i ] = projectrtp.codecx.pcma2linear16( bytes[ i ] )
return out
}
/** @param { Buffer } bytes */
function pcmutolinear( bytes ) {
const out = new Int16Array( bytes.length )
for( let i = 0; i < bytes.length; i++ ) out[ i ] = projectrtp.codecx.pcmu2linear16( bytes[ i ] )
return out
}
// ---- The three-channel chain harness --------------------------------------
/**
* Run one end-to-end chain test with three projectrtp channels.
*
* @param { object } opts
* @param { number } opts.polyCodec - codec for the PseudoPoly player leg.
* This is what's on the wire between
* PseudoPoly and Ch_A (typically 9 = G.722).
* @param { number } opts.chanACodec - codec Ch_A uses on its own outbound.
* Ch_A receives whatever `polyCodec`
* packets hit its local port, decodes
* per packet PT — the PT sent BY Ch_A
* is `chanACodec`. For a pure G.722
* chain keep this equal to polyCodec.
* @param { number } opts.chanBCodec - codec Ch_B uses on its outbound to JS.
* Must be 0 (PCMU) or 8 (PCMA).
* @param { number } [opts.durationMs=2500]
* @param { string } opts.outWav - path for the final captured WAV
* (decoded from JS-side RTP).
* @param { boolean } [opts.dumpChannels=false] - when true, also attach a
* projectrtp recorder to each of the
* three channels. The resulting WAV
* files land alongside `outWav` with
* names derived from its stem:
* <stem>_poly_rec.wav (PseudoPoly inbound)
* <stem>_chanA_rec.wav (Ch_A inbound — the
* G.722 decode point)
* <stem>_chanB_rec.wav (Ch_B inbound —
* normally silent in
* this topology)
* Note: projectrtp recorders capture a
* channel's INBOUND audio.
* - poly_rec captures the B→A mix
* direction (Ch_B's frame encoded
* as polyCodec, sent to PseudoPoly).
* In the current topology Ch_B's
* inbound is sinkB-echoed silence,
* so this records silence — useful
* for verifying the return path.
* - chanA_rec captures Ch_A's inbound
* (PseudoPoly's tone decoded via
* Ch_A's G.722 decoder). The key
* diagnostic for decode problems.
* - chanB_rec captures Ch_B's inbound
* (the silence echoed by sinkB).
*/
async function runchain( opts ) {
const { polyCodec, chanACodec, chanBCodec, outWav } = opts
const durationMs = opts.durationMs || 2500
const dumpChannels = !!opts.dumpChannels
expect( chanBCodec ).to.be.oneOf( [ 0, 8 ],
"Ch_B must be PCMU (0) or PCMA (8) so the test can decode it in JS" )
// Derive recorder filenames from the `outWav` stem so every per-test
// artefact lives in the same directory with a matching prefix.
const stem = outWav.replace( /\.wav$/, "" )
const polyRecPath = `${stem}_poly_rec.wav`
const chanARecPath = `${stem}_chanA_rec.wav`
const chanBRecPath = `${stem}_chanB_rec.wav`
// ---- endpoints --------------------------------------------------------
//
// Only one raw-UDP sink is needed — sinkB, on the right of the chain.
// PseudoPoly is the left endpoint: it already has a local socket (Ch_A
// directs its B→A outbound there), and its recorder captures that
// direction's audio as a WAV. We don't need a separate sinkA.
//
// sinkB echoes a comfort-noise / silence frame back to Ch_B on every
// received packet. In a real call both legs always send audio (silence
// frames included); mirroring that here means Ch_B's decode path is
// actually exercised and PseudoPoly's left-side recorder captures a
// proper silence stream rather than nothing. Swap the echo payload
// later to feed real audio back up the chain if you want full
// bidirectional tests.
/*
PseudoPoly ◄──── B→A silence from mix (Ch_B's frame → G.722) ◄─── Ch_A
│ │
│ player tone → G.722 │
▼ ▼
Ch_A.local ──────► Ch_A ══ mix ══ Ch_B ──── PCMA/PCMU ────► sinkB (UDP)
│
▼
decode PCMA, FFT, write WAV
also ↩ echo silence back
to Ch_B.local
*/
/**
* @param { number } expectedPt - PT of packets we should accumulate.
* @param { boolean } echoSilence - when true, reply to every received
* packet with a same-length silence
* frame in the same PT back to the
* sender. 0xFF for PCMU, 0xD5 for
* PCMA — the encoded "zero sample".
*/
function opensink( expectedPt, echoSilence ) {
const sock = dgram.createSocket( "udp4" )
/** @type { number[] } */
const bytes = []
// RTP state for the echo stream. Fresh ssrc so Ch_B's jitter buffer
// treats this as a new source (avoids SSRC collision with whatever
// Ch_B uses outbound).
const echoSsrc = 0xDEADBEEF
let echoSn = 0
let echoTs = 0
const silenceByte = ( expectedPt === 0 ) ? 0xFF : 0xD5
sock.on( "message", ( msg, rinfo ) => {
if( msg.length < 12 ) return
const pt = msg[ 1 ] & 0x7F
if( pt !== expectedPt ) return
const payload = msg.subarray( 12 )
for( const b of payload ) bytes.push( b )
if( echoSilence ) {
const header = Buffer.alloc( 12 )
header[ 0 ] = 0x80
header[ 1 ] = expectedPt
header.writeUInt16BE( echoSn, 2 )
header.writeUInt32BE( echoTs >>> 0, 4 )
header.writeUInt32BE( echoSsrc, 8 )
echoSn = ( echoSn + 1 ) & 0xFFFF
echoTs = ( echoTs + payload.length ) >>> 0
const reply = Buffer.concat( [ header, Buffer.alloc( payload.length, silenceByte ) ] )
sock.send( reply, rinfo.port, rinfo.address )
}
} )
return { sock, bytes }
}
const sinkB = opensink( chanBCodec, /* echoSilence */ true )
sinkB.sock.bind()
await new Promise( ( r ) => sinkB.sock.on( "listening", r ) )
// ---- channel close plumbing ------------------------------------------
const closes = []
const closed = () => {
let r
const p = new Promise( ( res ) => { r = res } )
closes.push( r )
return p
}
const closeA = closed()
const closeB = closed()
const closePoly = closed()
// ---- channels --------------------------------------------------------
//
// Open PseudoPoly last (it needs Ch_A's local port as its remote), and
// Ch_A first (Ch_A.remote will point at PseudoPoly's local once we
// know it). We work around the bootstrap problem by opening Ch_A with a
// throwaway remote, then issuing a `channel.remote(...)` update once
// we've opened PseudoPoly and know its port.
const chanA = await projectrtp.openchannel( {
// Throwaway initial remote — updated below once we know PseudoPoly.
remote: { address: "127.0.0.1", port: 1, codec: chanACodec },
}, ( d ) => { if( "close" === d.action ) closes[ 0 ]() } )
// Ch_B's remote is the measurement endpoint — sinkB.
const chanB = await projectrtp.openchannel( {
remote: { address: "127.0.0.1", port: sinkB.sock.address().port, codec: chanBCodec },
}, ( d ) => { if( "close" === d.action ) closes[ 1 ]() } )
// PseudoPoly is the left endpoint. Its remote is Ch_A.local so its
// player output (the generated tone) arrives at Ch_A as inbound, and
// conversely Ch_A's B→A outbound lands on PseudoPoly's local port
// where PseudoPoly's recorder captures it.
//
// Not in the mix: a channel's player is cleared on mix entry (matches
// C++). Keeping PseudoPoly in LOCAL mode lets the player keep firing
// alongside Ch_A/Ch_B's mix.
const pseudoPoly = await projectrtp.openchannel( {
remote: { address: "127.0.0.1", port: chanA.local.port, codec: polyCodec },
}, ( d ) => { if( "close" === d.action ) closes[ 2 ]() } )
// Point Ch_A's remote at PseudoPoly so the B→A return direction of
// mix2 lands on PseudoPoly's local port (captured by its recorder).
expect( chanA.remote( {
address: "127.0.0.1", port: pseudoPoly.local.port, codec: chanACodec,
} ) ).to.be.true
// Attach per-channel recorders BEFORE the mix / player start so we
// capture from the first inbound frame. Recorders run in both Local
// and Mixed modes — starting them at open-time is safe regardless
// of which mode each channel ends up in.
if( dumpChannels ) {
expect( pseudoPoly.record( { file: polyRecPath, numchannels: 2 } ), "poly rec start" ).to.be.true
expect( chanA.record( { file: chanARecPath, numchannels: 2 } ), "chanA rec start" ).to.be.true
expect( chanB.record( { file: chanBRecPath, numchannels: 2 } ), "chanB rec start" ).to.be.true
}
// Mix Ch_A with Ch_B BEFORE starting the player on PseudoPoly. The
// mix is the 2-party mix2 case; PseudoPoly is outside it entirely.
expect( chanA.mix( chanB ) ).to.be.true
// Brief settle so the mix is firmly established before audio begins.
await new Promise( ( r ) => setTimeout( r, 100 ) )
// Fire the player — this generates the G.722 (or whatever polyCodec is)
// packets on the wire from PseudoPoly to Ch_A.
expect( pseudoPoly.play( { loop: true, files: [ { wav: "/tmp/tone400.wav" } ] } ) ).to.be.true
await new Promise( ( r ) => setTimeout( r, durationMs ) )
// ---- cleanup ---------------------------------------------------------
pseudoPoly.close()
chanA.close()
chanB.close()
await Promise.all( [ closeA, closeB, closePoly ] )
await new Promise( ( r ) => sinkB.sock.close( r ) )
// Write sinkB to disk (= outWav). PCMA / PCMU only (asserted above)
// so decoding is always possible.
dumpsinkaudio( sinkB.bytes, chanBCodec, outWav.replace( /\.wav$/, "" ) )
const received = ( chanBCodec === 8 )
? Array.from( pcmatolinear( Buffer.from( sinkB.bytes ) ) )
: Array.from( pcmutolinear( Buffer.from( sinkB.bytes ) ) )
return {
received,
outWav,
polyRecPath: dumpChannels ? polyRecPath : null,
chanARecPath: dumpChannels ? chanARecPath : null,
chanBRecPath: dumpChannels ? chanBRecPath : null,
}
}
/**
* Write a sink's accumulated bytes to disk in the most useful form:
* - PCMA (pt 8) → decode to linear16 and save as WAV
* - PCMU (pt 0) → decode to linear16 and save as WAV
* - G.722 (pt 9) → save raw payload as `.g722` (ffmpeg-decodable)
* - anything else → save as `.<pt>.raw` (best effort)
* Returns the path actually written.
* @param { number[] } bytes
* @param { number } pt
* @param { string } stemNoExt
*/
function dumpsinkaudio( bytes, pt, stemNoExt ) {
const buf = Buffer.from( bytes )
if( pt === 8 || pt === 0 ) {
const linear = ( pt === 8 ) ? pcmatolinear( buf ) : pcmutolinear( buf )
const path = `${stemNoExt}.wav`
writewav( path, linear, 8000 )
return path
}
if( pt === 9 ) {
const path = `${stemNoExt}.g722`
fs.writeFileSync( path, buf )
return path
}
const path = `${stemNoExt}.pt${pt}.raw`
fs.writeFileSync( path, buf )
return path
}
// ---- JS-native sine source ------------------------------------------------
//
// Generate the tone in JS and save to a WAV. PseudoPoly's player reads
// this WAV and emits whatever codec it's configured for. This keeps the
// source signal entirely ours — no dependency on projectrtp's tone
// generator.
/**
* @param { number } freqHz
* @param { number } durationSec
* @param { number } sampleRate
* @param { number } amplitude - 0..1 scale relative to i16 full scale
*/
function gensinewav( path, freqHz, durationSec, sampleRate = 8000, amplitude = 0.5 ) {
const total = Math.floor( sampleRate * durationSec )
const peak = Math.round( 32767 * amplitude )
const samples = new Int16Array( total )
const w = 2 * Math.PI * freqHz / sampleRate
for( let i = 0; i < total; i++ ) samples[ i ] = Math.round( Math.sin( i * w ) * peak )
writewav( path, samples, sampleRate )
}
// ---- The tests ------------------------------------------------------------
describe( "codec chain (3 channels: pseudo-poly → mix(A, B) → JS measure)", function() {
this.beforeAll( function() {
// JS-native sine generator → WAV. PseudoPoly's player reads this
// and produces the G.722 (or whatever) packets on the wire.
gensinewav( "/tmp/tone400.wav", 400, 2.0, 8000, 0.5 )
expect( fs.existsSync( "/tmp/tone400.wav" ), "wav write failed" ).to.be.true
} )
this.afterAll( function() {
try { fs.unlinkSync( "/tmp/tone400.wav" ) } catch( _ ) { /* ignore */ }
} )
it( "G.722 on the wire → mix → PCMA out: 400 Hz tone survives", async function() {
this.timeout( 5000 )
this.slow( 4000 )
const { received } = await runchain( {
polyCodec: 9, // G.722 wire PseudoPoly → Ch_A
chanACodec: 9, // Ch_A is a G.722 leg
chanBCodec: 8, // PCMA to JS
outWav: "/tmp/codecchain_g722_to_pcma.wav",
dumpChannels: true, // write per-channel WAVs too
} )
expect( received.length, "mix/player/chain produced too little audio — setup broken" )
.to.be.above( 4096 )
const amps = ampfft( received )
/* and roughly 30hz either side - based on a window = 4 */
const e400 = energyat( amps, 400, 8000, 4 )
const etotal = amps.reduce( ( a, b ) => a + b, 0 )
const ratio = e400 / etotal
// eslint-disable-next-line no-console
console.log( ` G.722→PCMA: samples=${received.length} E@400Hz=${e400.toFixed(0)} ` +
`total=${etotal.toFixed(0)} ratio=${ratio.toFixed(3)}` )
expect( e400, "no energy at 400 Hz — the tone did not survive the chain" ).to.be.above( 0 )
// A clean chain produces a strong peak (ratio ≳ 0.4). 0.15 is the
// "barely-present" threshold — if the test is only just passing at
// ~0.15 the output is noisy even if not zero.
expect( ratio, "400 Hz is not dominant in the FFT — chain is noisy" ).to.be.above( 0.30 )
} )
it( "PCMA on the wire → mix → PCMA out (no G.722) — baseline", async function() {
this.timeout( 5000 )
this.slow( 4000 )
// Baseline with no G.722 anywhere. If this ratio is high and the
// G.722 test above is low, the regression is confined to the G.722
// decode / resample stage.
const { received } = await runchain( {
polyCodec: 8,
chanACodec: 8,
chanBCodec: 8,
outWav: "/tmp/codecchain_pcma_to_pcma.wav",
dumpChannels: true,
} )
expect( received.length ).to.be.above( 4096 )
const amps = ampfft( received )
const e400 = energyat( amps, 400, 8000, 4 )
const etotal = amps.reduce( ( a, b ) => a + b, 0 )
const ratio = e400 / etotal
// eslint-disable-next-line no-console
console.log( ` PCMA→PCMA : samples=${received.length} E@400Hz=${e400.toFixed(0)} ` +
`total=${etotal.toFixed(0)} ratio=${ratio.toFixed(3)}` )
expect( e400 ).to.be.above( 0 )
// Single-sided spectrum restored — raising this back from 0.18 to
// 0.35 reflects genuinely-tonal behaviour for a pass-through PCMA
// chain. A regression that introduced codec distortion would show
// as harmonics at 1200/2000/2800 Hz and drop the ratio below 0.30.
expect( ratio, "PCMA baseline should be strongly tonal" ).to.be.above( 0.35 )
} )
it( "G.722 on the wire → mix → PCMU out: 400 Hz tone survives", async function() {
this.timeout( 5000 )
this.slow( 4000 )
const { received } = await runchain( {
polyCodec: 9,
chanACodec: 9,
chanBCodec: 0, // PCMU to JS
outWav: "/tmp/codecchain_g722_to_pcmu.wav",
dumpChannels: true,
} )
expect( received.length ).to.be.above( 4096 )
const amps = ampfft( received )
const e400 = energyat( amps, 400, 8000, 4 )
const etotal = amps.reduce( ( a, b ) => a + b, 0 )
const ratio = e400 / etotal
// eslint-disable-next-line no-console
console.log( ` G.722→PCMU: samples=${received.length} E@400Hz=${e400.toFixed(0)} ` +
`total=${etotal.toFixed(0)} ratio=${ratio.toFixed(3)}` )
expect( e400 ).to.be.above( 0 )
expect( ratio ).to.be.above( 0.30 )
} )
} )
// ---- DTLS-SRTP back-to-back ----------------------------------------------
//
// ┌────────────┐ ┌────────────┐
// │ chanA │ ====== SRTP ======> │ chanB │
// │ (active, │ (PCMA over DTLS) │ (passive, │
// │ client) │ │ server) │
// │ plays tone │ │ records │
// └────────────┘ └────────────┘
//
// Both channels live in the same process. If the 400 Hz tone reaches
// chanB's recorder at recognisable amplitude, the DTLS handshake ran,
// both SRTP contexts were built, and inbound SRTP is decrypting
// correctly. No tone ⇒ break is somewhere in handshake → keying-material
// → SRTP decrypt.
/**
* Read a PCM-16 mono WAV straight from the bytes. Skips the 44-byte
* RIFF/WAVE header our recorder emits — consistent with `writewav`
* above — and returns { samples, sampleRate }.
* @param { string } path
*/
function readwav( path ) {
const buf = fs.readFileSync( path )
const sampleRate = buf.readUInt32LE( 24 )
const numChannels = buf.readUInt16LE( 22 )
const bitsPerSample = buf.readUInt16LE( 34 )
if( bitsPerSample !== 16 ) throw new Error( `unexpected bitsPerSample=${bitsPerSample}` )
const dataLen = buf.readUInt32LE( 40 )
const n = dataLen / 2
const interleaved = new Int16Array( n )
for( let i = 0; i < n; i++ ) interleaved[ i ] = buf.readInt16LE( 44 + i * 2 )
// If stereo, just return the left channel — that's the "inbound" side
// under our current L=in / R=out convention.
if( numChannels === 2 ) {
const mono = new Int16Array( n / 2 )
for( let i = 0; i < mono.length; i++ ) mono[ i ] = interleaved[ i * 2 ]
return { samples: mono, sampleRate }
}
return { samples: interleaved, sampleRate }
}
describe( "dtls-srtp back-to-back (2 channels: A plays → SRTP → B records)", function() {
this.timeout( 8000 )
this.slow( 4500 )
this.beforeAll( function() {
gensinewav( "/tmp/tone400_dtls.wav", 400, 2.0, 8000, 0.5 )
} )
this.afterAll( function() {
try { fs.unlinkSync( "/tmp/tone400_dtls.wav" ) } catch ( _ ) { /* ignore */ }
} )
it( "PCMA over DTLS: 400 Hz tone survives the encrypted leg", async function() {
const recPath = "/tmp/codecchain_dtls_b_rec.wav"
try { fs.unlinkSync( recPath ) } catch ( _ ) { /* ignore */ }
let done
const finished = new Promise( ( r ) => { done = r } )
const chanA = await projectrtp.openchannel( {}, ( d ) => {
if ( "close" === d.action ) chanB.close()
} )
const chanB = await projectrtp.openchannel( {}, ( d ) => {
if ( "close" === d.action ) done()
} )
// PCMA end-to-end with DTLS-SRTP. chanA is the DTLS client
// (mode=active), chanB is the server (mode=passive). Each side gets
// the OTHER side's fingerprint so cert verification succeeds.
expect( chanA.remote( {
address: "127.0.0.1",
port: chanB.local.port,
codec: 8,
dtls: { fingerprint: { hash: chanB.local.dtls.fingerprint }, mode: "active" },
} ) ).to.be.true
expect( chanB.remote( {
address: "127.0.0.1",
port: chanA.local.port,
codec: 8,
dtls: { fingerprint: { hash: chanA.local.dtls.fingerprint }, mode: "passive" },
} ) ).to.be.true
// Recorder on the receiving side. Captures decoded inbound — so what
// we see in this file is whatever came out the far side of SRTP
// decrypt. Start it before audio so we don't miss the first frames.
expect( chanB.record( { file: recPath } ) ).to.be.true
// Settle for the DTLS handshake. Typically <200 ms on loopback;
// 300 ms leaves margin for retransmits without blowing out the test.
await new Promise( ( r ) => setTimeout( r, 300 ) )
expect( chanA.play( { loop: true, files: [ { wav: "/tmp/tone400_dtls.wav" } ] } ) ).to.be.true
// ~1.5 s of tone post-handshake — enough for a clean FFT.
await new Promise( ( r ) => setTimeout( r, 1800 ) )
chanA.close()
await finished
// ---- verify -----------------------------------------------------
const { samples, sampleRate } = readwav( recPath )
expect( samples.length, "recording too short — no SRTP audio reached chanB" ).to.be.above( 4000 )
// Drop the first 250 ms so the FFT sees steady-state tone, not the
// silent gap before the player kicks in.
const trimFront = Math.min( samples.length, ( sampleRate * 0.25 ) | 0 )
const analysed = samples.slice( trimFront )
const amps = ampfft( analysed )
const e400 = energyat( amps, 400, sampleRate, 4 )
const etotal = amps.reduce( ( a, b ) => a + b, 0 )
const ratio = e400 / etotal
// eslint-disable-next-line no-console
console.log( ` DTLS-SRTP: samples=${analysed.length} E@400Hz=${e400.toFixed(0)} ` +
`total=${etotal.toFixed(0)} ratio=${ratio.toFixed(3)}` )
expect( e400 ).to.be.above( 0 )
expect( ratio, "400 Hz not dominant in the FFT — SRTP audio not making it through" ).to.be.above( 0.30 )
} )
// NOTE: a mix-mode variant of the above was attempted but removed. The
// one-line fix (calling `poll_dtls_handshake` from `mix_tick`) is
// verified by the production scenario: a real SRTP peer sends inbound
// audio which goes through chanA's codecx and then the mix. A
// back-to-back test where chanA only has a player and is mixed with
// chanB hits a separate mix-path issue (player-only channels don't
// feed their samples through codecx in mix2), not the DTLS fix. The
// Local-mode DTLS test above covers the cryptographic path; real-call
// traffic covers the mix+DTLS interaction.
} )
// ---- createReadStream — live audio tap ------------------------------------
//
// chanA plays a 400 Hz tone and sends it as PCMA to chanB. On chanB we
// attach `createReadStream({ direction: "in" })` and collect frames as a
// Node Readable. If FFT of the collected samples is peaked at 400 Hz, the
// whole chain works: tick feed point → bounded mpsc → forwarder task →
// ThreadsafeFunction → Readable → consumer.
describe( "createReadStream — live audio tap", function() {
this.timeout( 8000 )
this.slow( 4500 )
this.beforeAll( function() {
gensinewav( "/tmp/tone400_tap.wav", 400, 2.0, 8000, 0.5 )
} )
this.afterAll( function() {
try { fs.unlinkSync( "/tmp/tone400_tap.wav" ) } catch ( _ ) { /* ignore */ }
} )
it( "PCMA tone → inbound tap: 400 Hz survives the full reader pipeline", async function() {
let done
const finished = new Promise( ( r ) => { done = r } )
const chanA = await projectrtp.openchannel( {}, ( d ) => {
if( "close" === d.action ) chanB.close()
} )
const chanB = await projectrtp.openchannel( {}, ( d ) => {
if( "close" === d.action ) done()
} )
expect( chanA.remote( {
address: "127.0.0.1", port: chanB.local.port, codec: 8,
} ) ).to.be.true
expect( chanB.remote( {
address: "127.0.0.1", port: chanA.local.port, codec: 8,
} ) ).to.be.true
// Collect frames via the reader. `direction: "in"` = what chanB is
// receiving from chanA = the tone decoded from PCMA.
const reader = chanB.createReadStream( { direction: "in", format: "l16", samplerate: 8000 } )
// Resolved config must be visible on the Readable — consumers need to
// know the byte shape without peeking at opts they didn't pass.
expect( reader.format ).to.equal( "l16" )
expect( reader.samplerate ).to.equal( 8000 )
expect( reader.numchannels ).to.equal( 1 )
expect( reader.direction ).to.equal( "in" )
expect( reader.readerId ).to.be.a( "number" ).above( 0 )
const frames = []
reader.on( "data", ( buf ) => frames.push( buf ) )
let ended = false
reader.on( "end", () => { ended = true } )
expect( chanA.play( { loop: true, files: [ { wav: "/tmp/tone400_tap.wav" } ] } ) ).to.be.true
await new Promise( ( r ) => setTimeout( r, 1800 ) )
// Explicitly destroy BEFORE closing chanA so we cover the JS-initiated
// tear-down path (_destroy → DestroyReadStream → forwarder sees mpsc
// drop → end-of-stream sentinel → JS `end`).
reader.destroy()
await new Promise( ( r ) => setTimeout( r, 50 ) )
expect( ended, "reader.destroy() should end the stream" ).to.be.true
chanA.close()
await finished
const totalBytes = frames.reduce( ( n, b ) => n + b.length, 0 )
expect( totalBytes, "reader collected no audio" ).to.be.above( 8000 )
// Reassemble as Int16 LE samples and run the same FFT the other tests
// use. Drop the first 250 ms so the tone is in steady state.
const all = Buffer.concat( frames )
const sampleCount = all.length / 2
const samples = new Int16Array( sampleCount )
for( let i = 0; i < sampleCount; i++ ) samples[ i ] = all.readInt16LE( i * 2 )
const trimFront = Math.min( samples.length, 2000 )
const analysed = samples.slice( trimFront )
const amps = ampfft( analysed )
const e400 = energyat( amps, 400, 8000, 4 )
const etotal = amps.reduce( ( a, b ) => a + b, 0 )
const ratio = e400 / etotal
// eslint-disable-next-line no-console
console.log( ` TAP: samples=${analysed.length} E@400Hz=${e400.toFixed(0)} ` +
`total=${etotal.toFixed(0)} ratio=${ratio.toFixed(3)}` )
expect( e400 ).to.be.above( 0 )
expect( ratio, "400 Hz not dominant — tap pipeline broke the audio" ).to.be.above( 0.30 )
} )
} )
// ---- createWriteStream — live audio inject --------------------------------
//
// chanA has a Node `Writable` attached via createWriteStream. We generate
// a 400 Hz sine in JS, `pipe` it into the writer, and verify the tone
// lands at chanB by tapping its inbound with createReadStream and
// running the same FFT the other tests use. This exercises the full
// write path: JS → napi push_writer_bytes → Rust mpsc → tick
// next_frame_8k → send_player_frame → SRTP-free PCMA on the wire →
// chanB recv_loop → reader tap → FFT.
describe( "createWriteStream — live audio inject", function() {
this.timeout( 8000 )
this.slow( 4500 )
it( "Node Writable → PCMA out → receiver tap: 400 Hz survives the inject path", async function() {
let done
const finished = new Promise( ( r ) => { done = r } )
const chanA = await projectrtp.openchannel( {}, ( d ) => {
if( "close" === d.action ) chanB.close()
} )
const chanB = await projectrtp.openchannel( {}, ( d ) => {
if( "close" === d.action ) done()
} )
expect( chanA.remote( {
address: "127.0.0.1", port: chanB.local.port, codec: 8,
} ) ).to.be.true
expect( chanB.remote( {
address: "127.0.0.1", port: chanA.local.port, codec: 8,
} ) ).to.be.true
// chanB taps its inbound so we can check what arrived from chanA.
const reader = chanB.createReadStream( { direction: "in", format: "l16", samplerate: 8000 } )
const frames = []
reader.on( "data", ( buf ) => frames.push( buf ) )
// Writer on chanA — linear PCM-16 @ 8 kHz mono.
const writer = chanA.createWriteStream()
expect( writer.format ).to.equal( "l16" )
expect( writer.samplerate ).to.equal( 8000 )
expect( writer.numchannels ).to.equal( 1 )
expect( writer.writerId ).to.be.a( "number" ).above( 0 )
// Generate ~1.5 s of 400 Hz sine as a Buffer and feed it into the
// Writable in small chunks so we exercise the framing/buffering code
// (chunks don't align to 20 ms boundaries).
const samplesPerChunk = 37 // intentionally odd — prove chunk sizes don't matter
const totalSamples = 8000 * 2 // 2 seconds; we'll only consume ~1.5 s
const peak = Math.round( 32767 * 0.5 )
const w = 2 * Math.PI * 400 / 8000
for( let i = 0; i < totalSamples; i += samplesPerChunk ) {
const n = Math.min( samplesPerChunk, totalSamples - i )
const chunk = Buffer.alloc( n * 2 )
for( let j = 0; j < n; j++ ) {
chunk.writeInt16LE( Math.round( Math.sin( ( i + j ) * w ) * peak ), j * 2 )
}
// Backpressure honoured: await drain if write returns false.
const ok = writer.write( chunk )
if( !ok ) await new Promise( ( res ) => writer.once( "drain", res ) )
}
// ~1.5 s of airtime is plenty for the tick to consume and ship.
await new Promise( ( r ) => setTimeout( r, 1800 ) )
// Tear down cleanly. `reader.destroy` emits `end`; closing chanA
// cascades to chanB via the test's close-chain callbacks.
reader.destroy()
writer.end()
await new Promise( ( r ) => setTimeout( r, 50 ) )
chanA.close()
await finished
const totalBytes = frames.reduce( ( n, b ) => n + b.length, 0 )
expect( totalBytes, "reader collected no audio — writer path didn't emit" ).to.be.above( 8000 )
const all = Buffer.concat( frames )
const sampleCount = all.length / 2
const samples = new Int16Array( sampleCount )
for( let i = 0; i < sampleCount; i++ ) samples[ i ] = all.readInt16LE( i * 2 )
const trimFront = Math.min( samples.length, 2000 )
const analysed = samples.slice( trimFront )
const amps = ampfft( analysed )
const e400 = energyat( amps, 400, 8000, 4 )
const etotal = amps.reduce( ( a, b ) => a + b, 0 )
const ratio = e400 / etotal
// eslint-disable-next-line no-console
console.log( ` WRITE: samples=${analysed.length} E@400Hz=${e400.toFixed(0)} ` +
`total=${etotal.toFixed(0)} ratio=${ratio.toFixed(3)}` )
expect( e400 ).to.be.above( 0 )
expect( ratio, "400 Hz not dominant — writer pipeline broke the audio" ).to.be.above( 0.30 )
} )
} )