├── .gitignore ├── package.json ├── statistics.js ├── README.md ├── html ├── pcm-player.js ├── vumeter.css └── index.html ├── rtp-worker.js └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aes67-web-monitor", 3 | "version": "1.0.0", 4 | "description": "Web service to remotely monitor AES67 streams", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/nicolassturmel/aes67-web-monitor.git" 13 | }, 14 | "keywords": [ 15 | "aes67", 16 | "node", 17 | "webAudio", 18 | "pcm" 19 | ], 20 | "author": "Nicolas Sturmel", 21 | "license": "ISC", 22 | "bugs": { 23 | "url": "https://github.com/nicolassturmel/aes67-web-monitor/issues" 24 | }, 25 | "homepage": "https://github.com/nicolassturmel/aes67-web-monitor#readme", 26 | "dependencies": { 27 | "express": "^4.17.1", 28 | "sdp-transform": "^2.14.0", 29 | "url": "^0.11.0", 30 | "vhost": "^3.0.2", 31 | "ws": "^7.3.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /statistics.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class statistics { 3 | constructor() { 4 | this.max = 0 5 | this.min = Number.POSITIVE_INFINITY 6 | this.global_max = 0 7 | this.global_min = Number.POSITIVE_INFINITY 8 | this.count = 0 9 | this.acc = 0 10 | this.first_round_ok = 5; 11 | } 12 | add(c) { 13 | this.acc += c 14 | this.count++ 15 | if(c < this.min) this.min = c 16 | if(c > this.max) this.max = c 17 | } 18 | get(keep) { 19 | if(this.first_round_ok == 0) { 20 | if(this.min < this.global_min) this.global_min = this.min 21 | if(this.max > this.global_max) this.global_max = this.max 22 | } else { 23 | this.first_round_ok--; 24 | } 25 | let r = { 26 | mean: (this.count > 0? this.acc/this.count : 0) || 0, 27 | min: this.min, 28 | max: this.max, 29 | min_global: this.global_min, 30 | max_global: this.global_max 31 | } 32 | if(!keep) { 33 | this.max = 0 34 | this.min = Number.POSITIVE_INFINITY 35 | this.acc = 0 36 | this.count = 0 37 | } 38 | return r 39 | } 40 | clear() { 41 | this.first_round_ok = 5; 42 | this.global_max = 0 43 | this.global_min = Number.POSITIVE_INFINITY 44 | } 45 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aes67-web-monitor 2 | 3 | AES67-web-monitor is a micro service that allows you to monitor your LAN AES67 streams on your browser. This means ... on your phone too ! 4 | 5 | # How To 6 | 7 | First get code 8 | ``` 9 | git clone https://github.com/nicolassturmel/aes67-web-monitor 10 | cd aes67-web-monitor 11 | ``` 12 | 13 | then the dependencies 14 | ``` 15 | npm i 16 | ``` 17 | 18 | Then just run 19 | ``` 20 | sudo node --experimental-worker server.js 21 | ``` 22 | 23 | Once on the webpage, choose an interface to discover from and wait for SAP to do its magic 24 | 25 | ### Why root ? 26 | The PTP ports are bellow 1024 and require root privileges to be openned 27 | 28 | ### What port is the webserver ? 29 | The webserver is on port 8067 30 | 31 | ### What about the RTP indicator ? 32 | This indicator has 3 parts, left, center and right. 33 | 34 | - Left shows the delay, narrow means a tight delay (packet arrives when it should), wide means a high delay. Mean in black, local max in green absolute max in red. If black is narrow and green is wide, this means a high delay variance. 35 | - Right give the same information on inter packet time (time between packet processing) 36 | - Center is just a color indicator. Black: no stream, green: ok, orange: uncertain, red: outside AES67 specs 37 | 38 | So: 39 | - symmetrical narrow streams are typically local, fpga generated, streams 40 | - asymmetrical, wide on the left, are streams with a clock offset 41 | etc... 42 | 43 | 44 | # Help needed 45 | 46 | - Improve stability 47 | - Accomodate to more than two channels 48 | - Have different metering (LUFS...) 49 | 50 | # Credits 51 | 52 | The web pcm player is code originaly from https://github.com/samirkumardas/pcm-player then tweaked for realtime 53 | -------------------------------------------------------------------------------- /html/pcm-player.js: -------------------------------------------------------------------------------- 1 | function PCMPlayer(option) { 2 | this.init(option); 3 | } 4 | 5 | PCMPlayer.prototype.init = function(option) { 6 | var defaults = { 7 | encoding: '16bitInt', 8 | channels: 1, 9 | sampleRate: 8000, 10 | flushingTime: 1000 11 | }; 12 | this.option = Object.assign({}, defaults, option); 13 | this.samples = new Float32Array(); 14 | this.flush = this.flush.bind(this); 15 | this.interval = setInterval(this.flush, this.option.flushingTime); 16 | this.maxValue = this.getMaxValue(); 17 | this.typedArray = this.getTypedArray(); 18 | this.createContext(); 19 | }; 20 | 21 | PCMPlayer.prototype.getMaxValue = function () { 22 | var encodings = { 23 | '8bitInt': 128, 24 | '16bitInt': 32768, 25 | '32bitInt': 2147483648, 26 | '32bitFloat': 1 27 | } 28 | 29 | return encodings[this.option.encoding] ? encodings[this.option.encoding] : encodings['16bitInt']; 30 | }; 31 | 32 | PCMPlayer.prototype.getTypedArray = function () { 33 | var typedArrays = { 34 | '8bitInt': Int8Array, 35 | '16bitInt': Int16Array, 36 | '32bitInt': Int32Array, 37 | '32bitFloat': Float32Array 38 | } 39 | 40 | return typedArrays[this.option.encoding] ? typedArrays[this.option.encoding] : typedArrays['16bitInt']; 41 | }; 42 | 43 | PCMPlayer.prototype.createContext = function() { 44 | this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 45 | this.gainNode = this.audioCtx.createGain(); 46 | this.gainNode.gain.value = 1; 47 | this.gainNode.connect(this.audioCtx.destination); 48 | this.startTime = this.audioCtx.currentTime; 49 | }; 50 | 51 | PCMPlayer.prototype.isTypedArray = function(data) { 52 | return (data.byteLength && data.buffer && data.buffer.constructor == ArrayBuffer); 53 | }; 54 | 55 | PCMPlayer.prototype.feed = function(data) { 56 | //console.log("Feeding size ",data.length) 57 | if (!this.isTypedArray(data)) return; 58 | data = this.getFormatedValue(data); 59 | var tmp = new Float32Array(this.samples.length + data.length); 60 | tmp.set(this.samples, 0); 61 | tmp.set(data, this.samples.length); 62 | this.samples = tmp; 63 | }; 64 | 65 | PCMPlayer.prototype.getFormatedValue = function(data) { 66 | var data = new this.typedArray(data.buffer), 67 | float32 = new Float32Array(data.length), 68 | i; 69 | 70 | for (i = 0; i < data.length; i++) { 71 | float32[i] = data[i] / this.maxValue; 72 | } 73 | return float32; 74 | }; 75 | 76 | PCMPlayer.prototype.volume = function(volume) { 77 | this.gainNode.gain.value = volume; 78 | }; 79 | 80 | PCMPlayer.prototype.destroy = function() { 81 | if (this.interval) { 82 | clearInterval(this.interval); 83 | } 84 | this.samples = null; 85 | this.audioCtx.close(); 86 | this.audioCtx = null; 87 | }; 88 | 89 | PCMPlayer.prototype.flush = function() { 90 | if (!this.samples.length) return; 91 | var bufferSource = this.audioCtx.createBufferSource(), 92 | length = this.samples.length / this.option.channels, 93 | audioBuffer = this.audioCtx.createBuffer(this.option.channels, length, this.option.sampleRate), 94 | audioData, 95 | channel, 96 | offset, 97 | i, 98 | decrement; 99 | //console.log(this.option.channels) 100 | for (channel = 0; channel < this.option.channels; channel++) { 101 | audioData = audioBuffer.getChannelData(channel); 102 | offset = channel; 103 | decrement = 50; 104 | for (i = 0; i < length; i++) { 105 | audioData[i] = this.samples[offset]; 106 | // /* fadein */ 107 | // if (i < 50) { 108 | // audioData[i] = (audioData[i] * i) / 50; 109 | // } 110 | // /* fadeout*/ 111 | // if (i >= (length - 51)) { 112 | // audioData[i] = (audioData[i] * decrement--) / 50; 113 | // } 114 | offset += this.option.channels; 115 | } 116 | } 117 | 118 | if (this.startTime < this.audioCtx.currentTime) { 119 | this.startTime = this.audioCtx.currentTime; 120 | } 121 | // console.log('start vs current '+this.startTime+' vs '+this.audioCtx.currentTime+' duration: '+audioBuffer.duration); 122 | bufferSource.buffer = audioBuffer; 123 | bufferSource.connect(this.gainNode); 124 | bufferSource.start(this.startTime); 125 | this.startTime += audioBuffer.duration; 126 | this.samples = new Float32Array(); 127 | }; 128 | -------------------------------------------------------------------------------- /html/vumeter.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 100vw; 3 | height: 100vh; 4 | overflow-y: scroll; 5 | } 6 | 7 | #container { 8 | display: grid; 9 | grid-template-rows: 50px 1fr auto; 10 | grid-template-columns: 30px 60px 1fr 30px ; 11 | grid-template-areas: 12 | ". vumeter button ." 13 | ". vumeter collection ." 14 | ". . collection ."; 15 | } 16 | 17 | #optbar { 18 | display: inline; 19 | grid-area: button; 20 | margin: 10px auto; 21 | padding-left: 10px; 22 | } 23 | 24 | #button { 25 | text-align: center; 26 | line-height: 30px; 27 | height: 30px; 28 | margin: 10px auto; 29 | padding: 0 0.2em; 30 | border: 2px green solid; 31 | } 32 | 33 | #button.on { 34 | background-color: #0eec90; 35 | } 36 | 37 | #vumeter { 38 | grid-area: vumeter; 39 | position: relative; 40 | height: 400px; 41 | width: 60px; 42 | } 43 | 44 | #collection { 45 | grid-area: collection; 46 | width: calc(100% - 70px); 47 | margin-left: 30px; 48 | } 49 | 50 | #collection-in { 51 | width: 100%; 52 | } 53 | 54 | #circle { 55 | width: 30px; 56 | height: 30px; 57 | border-radius: 20%; 58 | background-color: red; 59 | padding: 0; 60 | } 61 | #circle.on { 62 | background-color: green; 63 | } 64 | 65 | #nostream { 66 | position: absolute; 67 | z-index: 10; 68 | width: 100%; 69 | height: 100%; 70 | } 71 | 72 | #nostream:before,#nostream:after{ 73 | z-index: 10; 74 | content:''; 75 | position:absolute; 76 | width:6px; 77 | height:45%; 78 | background-color:grey; 79 | border-radius:2px; 80 | top:20%; 81 | } 82 | 83 | #nostream:before{ 84 | -webkit-transform:rotate(25deg); 85 | -moz-transform:rotate(25deg); 86 | transform:rotate(25deg); 87 | left:45%; 88 | } 89 | #nostream:after{ 90 | -webkit-transform:rotate(-25deg); 91 | -moz-transform:rotate(-25deg); 92 | transform:rotate(-25deg); 93 | right:45%; 94 | } 95 | 96 | .hspace { 97 | margin: 0 10px; 98 | } 99 | 100 | .session { 101 | display: inline-block; 102 | padding: 0 1em; 103 | margin: 2px; 104 | min-width: 160px; 105 | overflow: hidden; 106 | height: 100%; 107 | border: #aa00aa 3px solid; 108 | } 109 | 110 | .selected { 111 | background-color: gray; 112 | } 113 | 114 | .hidden { 115 | display: none; 116 | } 117 | 118 | .bar { 119 | position: absolute; 120 | bottom: 20%; 121 | height: 75%; 122 | width: 20px; 123 | } 124 | 125 | .left { 126 | left: calc(50% - 30px ); 127 | background: grey; 128 | } 129 | .right { 130 | right: calc(50% - 30px ); 131 | background: grey; 132 | } 133 | .audio { 134 | transition: height 50ms ease-in-out; 135 | background-color: #aa00aa; 136 | } 137 | .max { 138 | transition: bottom 50ms ease-in-out; 139 | background-color: orange; 140 | height: 2px; 141 | } 142 | .maxg { 143 | transition: bottom 50ms ease-in-out; 144 | background-color: red; 145 | height: 2px; 146 | } 147 | 148 | .graduation { 149 | left: calc(50% - 10px ); 150 | background: white; 151 | } 152 | 153 | .gradElem { 154 | position: absolute; 155 | text-align: center; 156 | width: 100%; 157 | font-size: small; 158 | } 159 | 160 | .white { 161 | background-color: #EEEEEE; 162 | } 163 | 164 | .stats { 165 | display: flex; 166 | justify-content: center; 167 | align-items: center; 168 | position: absolute; 169 | height: 20%; 170 | width: 60px; 171 | bottom: 0; 172 | left: calc(50% - 30px) 173 | } 174 | 175 | .good { 176 | z-index: 3; 177 | position: flex; 178 | width: 20px; 179 | height: 20px; 180 | border-radius: 50%; 181 | background-color: black; 182 | } 183 | 184 | .delay { 185 | position: absolute; 186 | right: 50%; 187 | height: 20%; 188 | width: 50%; 189 | clip-path: polygon(0 0%, 0% 100%, 90% 50%); 190 | } 191 | .interpacket { 192 | position: absolute; 193 | left: 50%; 194 | height: 20%; 195 | width: 50%; 196 | clip-path: polygon(100% 100%, 100% 0%, 10% 50%); 197 | } 198 | 199 | .mean-stat { 200 | background-color: black; 201 | } 202 | 203 | .max-stat { 204 | box-sizing: border-box; 205 | background-color: transparent; 206 | border: green 8px solid; 207 | } 208 | .maxg-stat { 209 | box-sizing: border-box; 210 | background-color: transparent; 211 | border: red 3px solid; 212 | } 213 | 214 | .txt-stats { 215 | position: absolute; 216 | width: 100%; 217 | top: 10%; 218 | right: 0; 219 | text-align: center; 220 | font-size: small; 221 | } -------------------------------------------------------------------------------- /rtp-worker.js: -------------------------------------------------------------------------------- 1 | 2 | var stats = require('./statistics') 3 | var dgram = require('dgram'); 4 | 5 | var inter_packet_stats = new stats() 6 | var delay_stats = new stats() 7 | var rms = [new stats(), new stats()] 8 | const { parentPort , workerData } = require('worker_threads') 9 | 10 | console.log(workerData) 11 | 12 | let timeOffset = 0n 13 | 14 | let interval = 0.05, 15 | sampleRate = 48000, 16 | bytePerSample = 4, 17 | bytePerSampleStream = 3, 18 | channels = 2 19 | 20 | let buffer = [ 21 | new Buffer.alloc(interval*sampleRate* bytePerSample * channels), 22 | new Buffer.alloc(interval*sampleRate* bytePerSample * channels) 23 | ] 24 | 25 | //console.log(buffer) 26 | 27 | let currentBuffer = 0 28 | let currentPos = 0 29 | 30 | var client = null 31 | 32 | var getRtp = (params) => { 33 | console.log(params) 34 | let madd = params.maddress 35 | let port = params.port 36 | let host = params.host 37 | channels = params.channels 38 | 39 | let mix = [] 40 | 41 | for(let g = 0 ; g < channels ; g++) 42 | mix[g] = (g%2 == 0)? [1,0] : [0,1] 43 | //buffer[g] = new Buffer.alloc(interval*sampleRate* bytePerSample * channels) 44 | 45 | for(let c = 0 ; c < channels ; c++) 46 | rms[c] = new stats() 47 | 48 | // check multi 49 | let b1 = parseInt(madd.split(".")[0]) 50 | if(b1 < 224 || b1 > 240) 51 | return 52 | 53 | let offset = params.offset || 0 54 | client = dgram.createSocket({ type: "udp4", reuseAddr: true }); 55 | 56 | client.on('listening', function () { 57 | console.log('UDP Client listening on ' + madd + ":" + port); 58 | client.setBroadcast(true) 59 | client.setMulticastTTL(128); 60 | client.addMembership(madd,host); 61 | }); 62 | 63 | let lastSeq = 0 64 | let lastTime = BigInt(0) 65 | 66 | client.on('message', function (message, remote) { 67 | //console.log(".") 68 | let v = message.readInt8(0) 69 | let pt = message.readInt8(1) 70 | let seq = message.readUInt16BE(2) 71 | let ts = (message.readUInt32BE(4) - offset)%Math.pow(2,32) 72 | let ssrc = message.readUInt32BE(8) 73 | 74 | // inter packet time 75 | let time = process.hrtime.bigint() 76 | let diff = Number(time - lastTime)/1000000; 77 | lastTime = time 78 | 79 | // computing ts 80 | let realTime = timeOffset + time 81 | let realTS = Number(realTime*48000n / 1000000000n)%Math.pow(2,32) 82 | let tsdiff = (realTS - ts + Math.pow(2,32))%Math.pow(2,32) 83 | if(tsdiff > Math.pow(2,31)) tsdiff = tsdiff - Math.pow(2,32) 84 | inter_packet_stats.add(diff) 85 | delay_stats.add(tsdiff) 86 | 87 | 88 | if(seq != lastSeq+1) 89 | console.log("Err Seq: ",seq,lastSeq) 90 | lastSeq = seq 91 | if(lastSeq == 65535) lastSeq = -1 92 | 93 | 94 | for(let sampleIndex = 0; sampleIndex < (message.length - 12)/(channels * bytePerSampleStream); sampleIndex++) 95 | { 96 | if(currentPos == interval*sampleRate) 97 | { 98 | //console.log("sending " + currentPos + " at " + Date.now()) 99 | currentPos = 0 100 | let rmsT = [], 101 | peakT = [], 102 | peakgT = [] 103 | 104 | for(let c = 0 ; c < channels ; c++) { 105 | rmsT[c] = 10*Math.log10(rms[c].get(true).mean) 106 | peakT[c] = 10*Math.log10(rms[c].get(true).max) 107 | peakgT[c] = 10*Math.log10(rms[c].get().max_global) 108 | } 109 | parentPort.postMessage({ 110 | type: "data", 111 | data: { 112 | buffer: buffer[currentBuffer], 113 | delay: delay_stats.get(), 114 | inter_packets: inter_packet_stats.get(), 115 | rms: rmsT, 116 | peak: peakT, 117 | peakg: peakgT, 118 | rtp : { 119 | payload_type: pt, 120 | ssrc: ssrc 121 | }, 122 | sender : { 123 | ip: remote.address, 124 | port: remote.port 125 | } 126 | } 127 | }) 128 | currentBuffer = (currentBuffer+1)%2 129 | } 130 | 131 | let s, sL = 0, sR = 0 132 | for(let c = 0 ; c < channels ; c++) { 133 | s = (message.readInt32BE(sampleIndex*bytePerSampleStream*channels+12 + bytePerSampleStream*c - 1) & 0x00FFFFFF) << 8 134 | rms[c].add((s / Math.pow(2,(8*bytePerSample-1)))*(s / Math.pow(2,(8*bytePerSample-1)))) 135 | sL += mix[c][0]* s 136 | sR += mix[c][1]* s 137 | } 138 | if(sL > 2147483647) sL = 2147483647 139 | if(sR > 2147483647) sR = 2147483647 140 | if(sL < -2147483648) sL = -2147483648 141 | if(sR < -2147483648) sR = -2147483648 142 | buffer[currentBuffer].writeInt32LE(sL,bytePerSample * 2*currentPos) 143 | buffer[currentBuffer].writeInt32LE(sR,bytePerSample * 2*currentPos + bytePerSample) 144 | currentPos += 1 145 | } 146 | }); 147 | 148 | client.bind(port); 149 | } 150 | 151 | 152 | 153 | parentPort.on("message",(t) => { 154 | switch(t.type) { 155 | case "timeOffset": 156 | timeOffset = t.data 157 | break 158 | case "start": 159 | getRtp(t.data) 160 | break 161 | case "restart": 162 | if(client) client.close() 163 | //console.log(t) 164 | getRtp(t.data) 165 | case "clear": 166 | inter_packet_stats.clear() 167 | delay_stats.clear() 168 | rms.forEach(e => e.clear()) 169 | break 170 | default: 171 | break; 172 | } 173 | }) -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | var http = require('http') 3 | var exp = require('express') 4 | const fs = require('fs'); 5 | var dgram = require('dgram'); 6 | var stats = require('./statistics') 7 | const { Worker } = require('worker_threads') 8 | const sdpTransform = require('sdp-transform'); 9 | const os = require('os'); 10 | const url = require('url'); 11 | 12 | var RtpReceivers = {} 13 | let selectedDevice = null 14 | 15 | // Web server 16 | // ---------- 17 | 18 | const user_app = exp(); 19 | 20 | const server = http.createServer(user_app); 21 | 22 | user_app.use('/', exp.static(__dirname + '/html')); 23 | 24 | server.listen(8067, () => { 25 | console.log(`Server started on port 8067 :)`); 26 | }); 27 | 28 | // Mechanics 29 | // --------- 30 | 31 | var getInterfaces = () => { 32 | var netInt = os.networkInterfaces() 33 | let addresses = [] 34 | Object.keys(netInt).forEach(i => { 35 | let ip4 = netInt[i].filter(k => k.family == "IPv4") 36 | if(ip4.length > 0) 37 | ip4.forEach(p => { 38 | addresses.push({ 39 | name: i, 40 | ip: p.address, 41 | mask: p.netmask 42 | }) 43 | }) 44 | }) 45 | return addresses 46 | } 47 | 48 | console.log(getInterfaces()) 49 | 50 | var launchRtpReceiver = (sdp,host,id) => 51 | { 52 | var worker = new Worker("./rtp-worker.js") 53 | worker.on('online', () => { 54 | // worker.postMessage({ 55 | // type: "start", 56 | // data: { 57 | // maddress: sdp.connection.ip.split("/")[0], 58 | // host: host, 59 | // port: sdp.media[0].port, 60 | // codec: "L24", 61 | // channels: 2, 62 | // buuferLength: 0.05, 63 | // offset: (sdp.media && sdp.media.length>0 && sdp.media[0].mediaClk && sdp.media[0].mediaClk.mediaClockName == "direct")? sdp.media[0].mediaClk.mediaClockValue : 0 64 | // } 65 | // }) 66 | console.log('One more worker') 67 | }) 68 | worker.on('message',(k) => { 69 | switch(k.type) { 70 | case "data": 71 | sendData(k.data) 72 | break 73 | default: 74 | break 75 | } 76 | }) 77 | RtpReceivers[id] = worker 78 | } 79 | 80 | let wss, 81 | wss2; 82 | 83 | server.on('upgrade', function upgrade(request, socket, head) { 84 | const pathname = url.parse(request.url).pathname; 85 | 86 | if (pathname === '/pcm') { 87 | wss.handleUpgrade(request, socket, head, function done(ws) { 88 | wss.emit('connection', ws, request); 89 | }); 90 | } else if (pathname === '/stats') { 91 | wss2.handleUpgrade(request, socket, head, function done(ws) { 92 | wss2.emit('connection', ws, request); 93 | }); 94 | } else { 95 | socket.destroy(); 96 | } 97 | }); 98 | openSocket(); 99 | 100 | let timeOffset = 0n 101 | 102 | var clientPTP = null 103 | 104 | function getPTP(host) { 105 | if(clientPTP) clientPTP.close() 106 | let madd = '224.0.1.129' 107 | let port = 319 108 | clientPTP = dgram.createSocket({ type: "udp4", reuseAddr: true }); 109 | 110 | clientPTP.on('listening', function () { 111 | console.log('UDP Client listening on ' + madd + ":" + port); 112 | clientPTP.setBroadcast(true) 113 | clientPTP.setMulticastTTL(128); 114 | clientPTP.addMembership(madd,host); 115 | }); 116 | 117 | 118 | 119 | clientPTP.on('message', function (message, remote) { 120 | let time = process.hrtime.bigint() 121 | if(message.readUInt8(0) == 0 && message.readUInt8(1) == 0x2) { 122 | let ts1 = message.readUInt8(34) 123 | let ts2 = message.readUInt8(35) 124 | let ts3 = message.readUInt8(36) 125 | let ts4 = message.readUInt8(37) 126 | let ts5 = message.readUInt8(38) 127 | let ts6 = message.readUInt8(39) 128 | let ns1 = message.readUInt8(40) 129 | let ns2 = message.readUInt8(41) 130 | let ns3 = message.readUInt8(42) 131 | let ns4 = message.readUInt8(43) 132 | let s = BigInt(ts1*Math.pow(2,48) + ts2*Math.pow(2,32) + ts3*Math.pow(2,24) + ts4*Math.pow(2,16) + ts5*Math.pow(2,8) + ts6)*1000000000n + BigInt(ns1*Math.pow(2,24) + ns2*Math.pow(2,16) + ns3*Math.pow(2,8) + ns4) 133 | //console.log(" - " + s) 134 | timeOffset = s - time 135 | Object.keys(RtpReceivers).forEach(k => RtpReceivers[k].postMessage({type: "timeOffset", data: timeOffset})) 136 | 137 | } 138 | }) 139 | 140 | 141 | clientPTP.bind(port); 142 | } 143 | 144 | let sdpCollections = [] 145 | var clientSAP = null 146 | function getSAP(host) { 147 | if(clientSAP) clientSAP.close() 148 | 149 | let madd = '239.255.255.255' 150 | let port = 9875 151 | clientSAP = dgram.createSocket({ type: "udp4", reuseAddr: true }); 152 | 153 | clientSAP.on('listening', function () { 154 | console.log('UDP Client listening on ' + madd + ":" + port); 155 | clientSAP.setBroadcast(true) 156 | clientSAP.setMulticastTTL(128); 157 | clientSAP.addMembership(madd,host); 158 | }); 159 | 160 | var removeSdp = (name) => { 161 | let id = sdpCollections.findIndex((k) => {k.name == name;}) 162 | if(id >= 0) { 163 | sendSDP(sdpCollections[id].sdp,"remove") 164 | sdpCollections.splice(id,1) 165 | } 166 | } 167 | 168 | clientSAP.on('message', function (message, remote) { 169 | let sdp = sdpTransform.parse(message.toString().split("application/sdp")[1]) 170 | let timer = setTimeout( () => { 171 | removeSdp(sdp.name) 172 | } , 45000) 173 | if(!sdpCollections.some(k => k.name == sdp.name)) { 174 | sdpCollections.push({ 175 | sdp: sdp, 176 | timer: timer, 177 | name: sdp.name 178 | }) 179 | sendSDP(sdp,"update") 180 | } 181 | else { 182 | let item = sdpCollections.filter(k => k.name == sdp.name)[0] 183 | item.timer.refresh() 184 | item.sdp = sdp 185 | sendSDP(sdp,"update") 186 | } 187 | console.log(sdp.name,sdp.media[0].rtp) 188 | 189 | }) 190 | 191 | 192 | clientSAP.bind(port); 193 | } 194 | 195 | var sendSDP = (SDP,action) => { 196 | wss2.clients.forEach(function each(client) { 197 | if (client.readyState === WebSocket.OPEN) { 198 | client.send(JSON.stringify({ 199 | type: "streams", 200 | action: action, 201 | data: SDP 202 | })); 203 | } 204 | }) 205 | } 206 | 207 | let params = null 208 | 209 | function openSocket() { 210 | wss = new WebSocket.Server({ 211 | noServer: true , 212 | perMessageDeflate: { 213 | zlibDeflateOptions: { 214 | // See zlib defaults. 215 | chunkSize: 1024, 216 | memLevel: 7, 217 | level: 3 218 | }, 219 | zlibInflateOptions: { 220 | chunkSize: 10 * 1024 221 | }, 222 | // Other options settable: 223 | clientNoContextTakeover: true, // Defaults to negotiated value. 224 | serverNoContextTakeover: true, // Defaults to negotiated value. 225 | serverMaxWindowBits: 10, // Defaults to negotiated value. 226 | // Below options specified as default values. 227 | concurrencyLimit: 10, // Limits zlib concurrency for perf. 228 | threshold: 1024 // Size (in bytes) below which messages 229 | // should not be compressed. 230 | }}); 231 | console.log('Server ready...'); 232 | wss.on('connection', function connection(ws) { 233 | console.log('Socket connected. sending data...'); 234 | ws.on("error",() => console.log("You got halted due to an error")) 235 | // interval = setInterval(function() { 236 | // sendData(); 237 | // }, 50); 238 | }); 239 | wss2 = new WebSocket.Server({ noServer: true }); 240 | console.log('Server ready...'); 241 | wss2.on('connection', function connection(ws) { 242 | console.log('Socket connected. sending data...'); 243 | ws.send(JSON.stringify({ 244 | type: "params", 245 | data: params 246 | })); 247 | ws.on('message',(m) => { 248 | let msg = JSON.parse(m) 249 | console.log(m,msg) 250 | switch(msg.type) { 251 | case "clear": 252 | Object.keys(RtpReceivers).forEach(k => RtpReceivers[k].postMessage({type: "clear"})) 253 | break 254 | case "session": 255 | let sdpElem = sdpCollections.filter(e => e.name == msg.data)[0] 256 | if(sdpElem) { 257 | console.log(sdpElem) 258 | params = { 259 | maddress: (sdpElem.sdp.connection ? sdpElem.sdp.connection.ip.split("/")[0] : sdpElem.sdp.media[0].connection.ip.split("/")[0]), 260 | host: selectedDevice, 261 | port: sdpElem.sdp.media[0].port, 262 | codec: "L24", 263 | channels: sdpElem.sdp.media[0].rtp[0].encoding, 264 | buuferLength: 0.05, 265 | offset: (sdpElem.sdp.media && sdpElem.sdp.media.length>0 && sdpElem.sdp.media[0].mediaClk && sdpElem.sdp.media[0].mediaClk.mediaClockName == "direct")? sdpElem.sdp.media[0].mediaClk.mediaClockValue : 0 266 | } 267 | RtpReceivers["thgssdfw"].postMessage({ 268 | type: "restart", 269 | data: params 270 | }) 271 | wss2.clients.forEach(function each(client) { 272 | if (client.readyState === WebSocket.OPEN) { 273 | client.send(JSON.stringify({ 274 | type: "params", 275 | data: params 276 | })); 277 | } 278 | }); 279 | } 280 | break 281 | case "selectInterface": 282 | chooseInterface(msg.data) 283 | break 284 | default: 285 | console.log("Unprocessed " + msg.type) 286 | break 287 | } 288 | }) 289 | ws.on("error",() => console.log("You got halted due to an error")) 290 | ws.send(JSON.stringify( 291 | { 292 | type: "interfaces", 293 | data: getInterfaces() 294 | } 295 | )) 296 | }); 297 | } 298 | 299 | function sendData(struct) { 300 | wss.clients.forEach(function each(client) { 301 | if (client.readyState === WebSocket.OPEN) { 302 | client.send(struct.buffer); 303 | } 304 | }); 305 | struct.buffer = null 306 | wss2.clients.forEach(function each(client) { 307 | if (client.readyState === WebSocket.OPEN) { 308 | client.send(JSON.stringify({ 309 | type: "stats", 310 | data: struct 311 | })); 312 | } 313 | }); 314 | } 315 | 316 | var chooseInterface = (add) => { 317 | sdpCollections.forEach((id) => { 318 | sendSDP(id.sdp,"remove") 319 | }) 320 | sdpCollections = [] 321 | getPTP(add) 322 | getSAP(add) 323 | selectedDevice = add 324 | } 325 | 326 | launchRtpReceiver(null,null,"thgssdfw") 327 | 328 | 329 | 330 | 331 | 332 | 333 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My AES67 web test 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | 19 |
20 |
21 |
22 |
23 |
RTP
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | 36 |
37 |
38 |
Play S
39 |
40 |
show / hide debug data
41 | 42 | 266 | 267 | 268 | 269 | --------------------------------------------------------------------------------