├── www ├── images │ ├── bar_audio.png │ └── bar_audio_on.png ├── css │ ├── getmdl-select.min.css │ └── getmdl-select.scss ├── js │ ├── soundmeter.js │ ├── getmdl-select.js │ ├── sfu.js │ └── transaction-manager.js └── index.html ├── package.json ├── lib ├── Logger.js ├── Participant.js └── Room.js ├── LICENSE ├── .gitignore ├── README.md ├── CODE_OF_CONDUCT.md └── index.js /www/images/bar_audio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medooze/sfu/HEAD/www/images/bar_audio.png -------------------------------------------------------------------------------- /www/images/bar_audio_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/medooze/sfu/HEAD/www/images/bar_audio_on.png -------------------------------------------------------------------------------- /www/css/getmdl-select.min.css: -------------------------------------------------------------------------------- 1 | .getmdl-select .mdl-icon-toggle__label{float:right;margin-top:-30px;color:rgba(0,0,0,0.4)} 2 | .getmdl-select.is-focused .mdl-icon-toggle__label{color:#3f51b5} 3 | .getmdl-select .mdl-menu__container{width:100% !important;overflow:hidden} 4 | .getmdl-select .mdl-menu__container .mdl-menu .mdl-menu__item{font-size:16px} 5 | .getmdl-select__fullwidth .mdl-menu{width:100%} 6 | .getmdl-select__fix-height .mdl-menu__container{overflow-y:auto;max-height:300px !important} 7 | -------------------------------------------------------------------------------- /www/css/getmdl-select.scss: -------------------------------------------------------------------------------- 1 | .getmdl-select { 2 | 3 | .mdl-icon-toggle__label { 4 | float: right; 5 | margin-top: -30px; 6 | color: rgba(0, 0, 0, 0.4); 7 | } 8 | &.is-focused { 9 | .mdl-icon-toggle__label { 10 | color: #3f51b5; 11 | } 12 | } 13 | 14 | .mdl-menu__container { 15 | width: 100% !important; 16 | overflow: hidden; 17 | .mdl-menu .mdl-menu__item { 18 | font-size: 16px; 19 | } 20 | } 21 | } 22 | .getmdl-select__fullwidth { 23 | .mdl-menu { 24 | width: 100%; 25 | } 26 | } 27 | 28 | .getmdl-select__fix-height{ 29 | .mdl-menu__container{ 30 | overflow-y: auto; 31 | max-height: 300px !important; 32 | } 33 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sfu", 3 | "version": "0.0.1", 4 | "description": "WebRTC SFU for Node", 5 | "main": "index.js", 6 | "dependencies": { 7 | "medooze-media-server": "^0", 8 | "semantic-sdp": "^3", 9 | "transaction-manager": "^2", 10 | "debug": "^3.1.0", 11 | "websocket": "^1.0.24" 12 | }, 13 | "devDependencies": {}, 14 | "scripts": { 15 | "test": "echo \"Error: no test specified\" && exit 1" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/medooze/sfu-node.git" 20 | }, 21 | "keywords": [ 22 | "webrtc", 23 | "node", 24 | "sfu", 25 | "vp9", 26 | "svc" 27 | ], 28 | "author": "Sergio Garcia Murillo @ Medooze", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/medooze/sfu-node/issues" 32 | }, 33 | "homepage": "https://github.com/medooze/sfu-node#readme" 34 | } 35 | -------------------------------------------------------------------------------- /lib/Logger.js: -------------------------------------------------------------------------------- 1 | process.env["DEBUG"]="*,-websocket:connection"; 2 | const Debug = require("debug"); 3 | 4 | Debug.formatArgs = function (args) { 5 | var name = this.namespace; 6 | var useColors = this.useColors; 7 | 8 | if (useColors) { 9 | var c = this.color; 10 | var colorCode = '\u001b[3' + (c < 8 ? c : '8;5;' + c); 11 | var prefix = colorCode + ';1m' + new Date().toISOString() + name + ' '; 12 | 13 | args[0] = prefix + args[0].split ('\n').join ('\u001b[0m\n' + prefix) + '\u001b[0m'; 14 | } else { 15 | args[0] = new Date().toISOString() + name + ' ' + args[0]; 16 | } 17 | }; 18 | 19 | class Logger 20 | { 21 | constructor(name) 22 | { 23 | this.name = name; 24 | this.info = Debug(" [INFO ] "+name); 25 | this.debug = Debug(" [DEBUG] "+name); 26 | this.warn = Debug(" [WARN ] "+name); 27 | this.error = Debug(" [ERROR] "+name); 28 | } 29 | 30 | child(name) 31 | { 32 | return new Logger(this.name +"::"+name); 33 | } 34 | }; 35 | 36 | module.exports = Logger; 37 | 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | /nbproject/private/ 61 | /server.cert 62 | /server.key 63 | -------------------------------------------------------------------------------- /www/js/soundmeter.js: -------------------------------------------------------------------------------- 1 | 2 | // Meter class that generates a number correlated to audio volume. 3 | // The meter class itself displays nothing, but it makes the 4 | // instantaneous and time-decaying volumes available for inspection. 5 | // It also reports on the fraction of samples that were at or near 6 | // the top of the measurement range. 7 | function SoundMeter (context) { 8 | var AudioContext = window.AudioContext || window.webkitAudioContext; 9 | this.context = new AudioContext(); 10 | this.instant = 0.0; 11 | this.script = this.context.createScriptProcessor (2048, 1, 1); 12 | this.stopped = true; 13 | var self = this; 14 | this.script.onaudioprocess = function (event) { 15 | var input = event.inputBuffer.getChannelData (0); 16 | var i; 17 | var sum = 0.0; 18 | 19 | for (i = 0; i < input.length; ++i) 20 | sum += input[i]*input[i]*10000; 21 | self.instant = Math.sqrt(sum) / input.length; 22 | 23 | }; 24 | } 25 | 26 | SoundMeter.prototype.connectToSource = function (stream) { 27 | var self = this; 28 | 29 | if (this.stopped) 30 | //Stop 31 | this.stop(); 32 | 33 | return new Promise(function(resolve, reject) { 34 | try { 35 | self.mic = self.context.createMediaStreamSource(stream); 36 | self.mic.connect(self.script); 37 | // necessary to make sample run, but should not be. 38 | self.script.connect (self.context.destination); 39 | //Done 40 | resolve(); 41 | } catch (e) { 42 | reject(e); 43 | } 44 | }); 45 | }; 46 | 47 | SoundMeter.prototype.stop = function () { 48 | if(this.stopped) 49 | return; 50 | this.stopped = true; 51 | try{ 52 | if(this.script){ 53 | this.script.onaudioprocess = null; 54 | this.script.disconnect(this.context.destination); 55 | } 56 | this.mic && this.mic.disconnect(); 57 | 58 | } catch (e){ 59 | } 60 | }; 61 | 62 | -------------------------------------------------------------------------------- /www/js/getmdl-select.js: -------------------------------------------------------------------------------- 1 | { 2 | 'use strict'; 3 | 4 | var getmdlSelect = { 5 | defaultValue : { 6 | width: 300 7 | }, 8 | addEventListeners: function (dropdown) { 9 | var input = dropdown.querySelector('input'); 10 | var list = dropdown.querySelectorAll('li'); 11 | var menu = dropdown.querySelector('.mdl-js-menu'); 12 | 13 | //show menu on mouse down or mouse up 14 | input.onkeydown = function (event) { 15 | if (event.keyCode == 38 || event.keyCode == 40) { 16 | menu['MaterialMenu'].show(); 17 | } 18 | }; 19 | 20 | //return focus to input 21 | menu.onkeydown = function (event) { 22 | if (event.keyCode == 13) { 23 | input.focus(); 24 | } 25 | }; 26 | 27 | [].forEach.call(list, function (li) { 28 | li.onclick = function () { 29 | input.value = li.textContent; 30 | dropdown.MaterialTextfield.change(li.textContent); // handles css class changes 31 | setTimeout( function() { 32 | dropdown.MaterialTextfield.updateClasses_(); //update css class 33 | }, 250 ); 34 | 35 | // update input with the "id" value 36 | input.dataset.val = li.dataset.val || ''; 37 | 38 | if ("createEvent" in document) { 39 | var evt = document.createEvent("HTMLEvents"); 40 | evt.initEvent("change", false, true); 41 | menu['MaterialMenu'].hide(); 42 | input.dispatchEvent(evt); 43 | } else { 44 | input.fireEvent("onchange"); 45 | } 46 | }; 47 | }); 48 | }, 49 | init: function (selector, widthDef) { 50 | var dropdowns = document.querySelectorAll(selector); 51 | [].forEach.call(dropdowns, function (i) { 52 | getmdlSelect.addEventListeners(i); 53 | var width = widthDef ? widthDef : (i.querySelector('.mdl-menu').offsetWidth ? i.querySelector('.mdl-menu').offsetWidth : getmdlSelect.defaultValue.width); 54 | i.style.width = width + 'px'; 55 | }); 56 | } 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Medooze SFU 2 | A future proof, experimental WebRTC VP9 SVC SFU. 3 | 4 | # Motivation 5 | There are already several good production ready alternatives for implementing multiconferencing on webrtc, like Jitsi, Janus or SwitchRTC SFUs and even if you need more legacy support you can try our [MCU](http://www.medooze.com/products/mcu.aspx). Our goal is to experiment and provide an early access to the functionalities that will be available in the near future that will improve drastically the performance and quality of multiconferencing services on WebRTC. 6 | 7 | Due to the experimental nature of this functionalities we will only officially support Chrome Canary to be able to access the very latest functionalities available (sometimes even running behind a flag). We don't care about interporeability with other browsers (they will eventually catch up) nor SDP legacy support. 8 | 9 | # Goal 10 | It is our goal to implement only the We intent to implement support the following features: 11 | 12 | - [VP9 SVC](https://tools.ietf.org/html/draft-ietf-payload-vp9-02) 13 | - [RTP transport wide congestion control](https://tools.ietf.org/html/draft-holmer-rmcat-transport-wide-cc-extensions-01) 14 | - Sender side BitRate estimation: algorithm not decided yet candidates are [GCC](https://tools.ietf.org/html/draft-ietf-rmcat-gcc-02), [NADA](https://tools.ietf.org/html/draft-ietf-rmcat-nada-03) or [SCREAM](https://tools.ietf.org/html/draft-ietf-rmcat-scream-cc-07) 15 | - [RTCP reduced size] (https://tools.ietf.org/html/rfc5506) 16 | - Bundle only 17 | - No simulcast 18 | 19 | This is a moving target as new functionalities will be available on Chrome and some others will be removed, we will update our targets appropiatelly. 20 | 21 | To enable VP9 SVC on Chrome Canary you must use the following command line: 22 | 23 | ``` 24 | chrome.exe --force-fieldtrials=WebRTC-SupportVP9SVC/EnabledByFlag2SL3TL/ 25 | ``` 26 | # End to end encrytpion 27 | 28 | A full version of SFrame end to end encryption is under works via insertable streams. Current implementation just uses frame counter as IV which is then inserted in the AES-GCM encrypted frame payload for emoing all required capabilities. 29 | 30 | # Install 31 | 32 | You just need to install all the depencencies and generate the ssl certificates: 33 | 34 | ``` 35 | npm install 36 | openssl req -sha256 -days 3650 -newkey rsa:1024 -nodes -new -x509 -keyout server.key -out server.cert 37 | ``` 38 | 39 | If you get an error like this 40 | ``` 41 | gyp verb build dir attempting to create "build" dir: /usr/local/src/medooze/sfu/node_modules/medooze-media-server/build 42 | gyp ERR! configure error 43 | gyp ERR! stack Error: EACCES: permission denied, mkdir '/usr/local/src/medooze/sfu/node_modules/medooze-media-server/build' 44 | 45 | ``` 46 | 47 | You may try instead with: 48 | ``` 49 | npm install --unsafe-perm 50 | ``` 51 | 52 | # Usage 53 | 54 | In order to run the sfu just: 55 | 56 | ``` 57 | node index.js [ip] 58 | ``` 59 | 60 | where the `ip` is the ICE candidate ip address used for RTP media. To test a simple web client just browse to `https://[ip]:8000/`. 61 | 62 | # License 63 | MIT 64 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at sergio.garcia.murillo@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const TransactionManager = require("transaction-manager"); 2 | const Room = require("./lib/Room"); 3 | //Get Semantic SDP objects 4 | const SemanticSDP = require("semantic-sdp"); 5 | const SDPInfo = SemanticSDP.SDPInfo; 6 | 7 | const PORT = 8084; 8 | 9 | //HTTP&WS stuff 10 | const https = require ('https'); 11 | const url = require ('url'); 12 | const fs = require ('fs'); 13 | const path = require ('path'); 14 | const WebSocketServer = require ('websocket').server; 15 | 16 | //Get the Medooze Media Server interface 17 | const MediaServer = require("medooze-media-server"); 18 | 19 | //Enable debug 20 | MediaServer.enableLog(false); 21 | MediaServer.enableDebug(false); 22 | MediaServer.enableUltraDebug(false); 23 | 24 | //Check 25 | if (process.argv.length!=3) 26 | throw new Error("Missing IP address\nUsage: node index.js "+process.argv.length); 27 | //Get ip 28 | const ip = process.argv[2]; 29 | 30 | //The list of sport castings 31 | const rooms = new Map(); 32 | 33 | const base = 'www'; 34 | 35 | const options = { 36 | key: fs.readFileSync ('server.key'), 37 | cert: fs.readFileSync ('server.cert') 38 | }; 39 | 40 | // maps file extention to MIME typere 41 | const map = { 42 | '.ico': 'image/x-icon', 43 | '.html': 'text/html', 44 | '.js': 'text/javascript', 45 | '.json': 'application/json', 46 | '.css': 'text/css', 47 | '.png': 'image/png', 48 | '.jpg': 'image/jpeg', 49 | '.wav': 'audio/wav', 50 | '.mp3': 'audio/mpeg', 51 | '.svg': 'image/svg+xml', 52 | '.pdf': 'application/pdf', 53 | '.doc': 'application/msword' 54 | }; 55 | 56 | 57 | //Create HTTP server 58 | const server = https.createServer (options, (req, res) => { 59 | // parse URL 60 | const parsedUrl = url.parse (req.url); 61 | // extract URL path 62 | let pathname = base + parsedUrl.pathname; 63 | // based on the URL path, extract the file extention. e.g. .js, .doc, ... 64 | const ext = path.parse (pathname).ext; 65 | 66 | //DO static file handling 67 | fs.exists (pathname, (exist) => { 68 | if (!exist) 69 | { 70 | // if the file is not found, return 404 71 | res.statusCode = 404; 72 | res.end (`File ${pathname} not found!`); 73 | return; 74 | } 75 | 76 | // if is a directory search for index file matching the extention 77 | if (fs.statSync (pathname).isDirectory ()) 78 | pathname += '/index.html'; 79 | 80 | // read file from file system 81 | fs.readFile (pathname, (err, data) => { 82 | if (err) 83 | { 84 | //Error 85 | res.statusCode = 500; 86 | res.end (`Error getting the file: ${err}.`); 87 | } else { 88 | // if the file is found, set Content-type and send data 89 | res.setHeader ('Content-type', map[ext] || 'text/html'); 90 | res.end (data); 91 | } 92 | }); 93 | }); 94 | }).listen (PORT); 95 | 96 | //Create ws server 97 | const ws = new WebSocketServer ({ 98 | httpServer: server, 99 | autoAcceptConnections: false 100 | }); 101 | 102 | //Listen for requests 103 | ws.on ('request', (request) => { 104 | // parse URL 105 | const url = request.resourceURL; 106 | 107 | 108 | //Find the room id 109 | let updateParticipants; 110 | let participant; 111 | let room = rooms.get(url.query.id); 112 | 113 | //if not found 114 | if (!room) 115 | { 116 | //Create new Room 117 | room = new Room(url.query.id,ip); 118 | //Append to room list 119 | rooms.set(room.getId(), room); 120 | } 121 | 122 | //Get protocol 123 | var protocol = request.requestedProtocols[0]; 124 | 125 | //Accept the connection 126 | const connection = request.accept(protocol); 127 | 128 | //Create new transaction manager 129 | const tm = new TransactionManager(connection); 130 | 131 | //Handle incoming commands 132 | tm.on("cmd", async function(cmd) 133 | { 134 | //Get command data 135 | const data = cmd.data; 136 | //check command type 137 | switch(cmd.name) 138 | { 139 | case "join": 140 | try { 141 | //Check if we already have a participant 142 | if (participant) 143 | return cmd.reject("Already joined"); 144 | 145 | //Create it 146 | participant = room.createParticipant(data.name); 147 | 148 | //Check 149 | if (!participant) 150 | return cmd.reject("Error creating participant"); 151 | 152 | //Add listener 153 | room.on("participants",(updateParticipants = (participants) => { 154 | tm.event("participants", participants); 155 | })); 156 | 157 | //Get all streams before adding us 158 | const streams = room.getStreams(); 159 | 160 | //Init participant 161 | const answer = participant.init(data.sdp); 162 | 163 | //Accept cmd 164 | cmd.accept({ 165 | sdp : answer, 166 | room : room.getInfo() 167 | }); 168 | 169 | participant.on("renegotiationneeded",async (sdp) => { 170 | //Send update event 171 | const answer = await tm.cmd('update',{ 172 | sdp : sdp 173 | }); 174 | //Update partitipant 175 | participant.update(answer.sdp); 176 | }); 177 | 178 | //listen for participant events 179 | participant.on("closed",function(){ 180 | //close ws 181 | connection.close(); 182 | //Remove room listeners 183 | room.off("participants",updateParticipants); 184 | }); 185 | 186 | //For each one 187 | for (let stream of streams) 188 | //Add it 189 | participant.addStream(stream); 190 | 191 | } catch (error) { 192 | console.error(error); 193 | //Error 194 | cmd.reject({ 195 | error: error 196 | }); 197 | } 198 | break; 199 | } 200 | }); 201 | 202 | connection.on("close", function(){ 203 | console.log("connection:onclose"); 204 | //Check if we had a participant 205 | if (participant) 206 | //remove it 207 | participant.stop(); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /lib/Participant.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const Logger = require("./Logger"); 3 | //Get Semantic SDP objects 4 | const SemanticSDP = require("semantic-sdp"); 5 | const SDPInfo = SemanticSDP.SDPInfo; 6 | const MediaInfo = SemanticSDP.MediaInfo; 7 | const CandidateInfo = SemanticSDP.CandidateInfo; 8 | const DTLSInfo = SemanticSDP.DTLSInfo; 9 | const ICEInfo = SemanticSDP.ICEInfo; 10 | const StreamInfo = SemanticSDP.StreamInfo; 11 | const TrackInfo = SemanticSDP.TrackInfo; 12 | const Direction = SemanticSDP.Direction; 13 | const CodecInfo = SemanticSDP.CodecInfo; 14 | 15 | 16 | class Participant 17 | { 18 | constructor(id,name,manager,room) 19 | { 20 | //Store props 21 | this.id = id; 22 | this.name = name; 23 | 24 | //Get manager 25 | this.manager = manager; 26 | 27 | //Listent for transport 28 | this.manager.on("transport",(transport)=>{ 29 | //Store transport 30 | this.transport = transport; 31 | //Enable bandwidth probing 32 | this.transport.setBandwidthProbing(true); 33 | this.transport.setMaxProbingBitrate(256000); 34 | 35 | //Dump contents 36 | //this.transport.dump("/tmp/sfu-"+this.uri.join("-")+".pcap"); 37 | // 38 | //Bitrate estimator 39 | this.transport.on("targetbitrate",(targetbitrate)=>{ 40 | //If no other participants 41 | if (this.outgoingStreams.length) 42 | //Done 43 | return; 44 | 45 | //Split bitrate evenly 46 | let bitrate = targetbitrate/this.outgoingStreams.length; 47 | let assigned = 0; 48 | //For each stream 49 | for (const [streamId,stream] of this.outgoingStreams) 50 | { 51 | //Get video track 52 | const videoTrack = stream.getVideoTracks()[0]; 53 | //Get transponder 54 | const transponder = videoTrack.getTransponder(); 55 | //Set it 56 | assigned += transponder.setTargetBitrate(bitrate); 57 | } 58 | 59 | }); 60 | 61 | //Listen for incoming tracks 62 | transport.on("incomingtrack",(track,incomingStream)=>{ 63 | //Log 64 | this.logger.info("incomingtrack"); 65 | 66 | //If stream has already been processed 67 | if (incomingStream.uri) 68 | //ignore 69 | return; 70 | 71 | //Add origin 72 | incomingStream.uri = this.uri.concat(["incomingStreams",incomingStream.getId()]); 73 | 74 | //Listen for stop event 75 | incomingStream.once("stopped",()=>{ 76 | //If not ended 77 | this.incomingStreams.delete(incomingStream.id); 78 | }); 79 | 80 | //Append 81 | this.incomingStreams.set(incomingStream.id,incomingStream); 82 | 83 | //Publish stream 84 | this.logger.info("onstream"); 85 | this.emitter.emit("stream",incomingStream); 86 | 87 | }); 88 | }); 89 | 90 | //Listent for renegotiation events 91 | this.manager.on("renegotiationneeded", async ()=>{ 92 | //Check not closed 93 | if (!this.manager) 94 | return; 95 | //Emit event 96 | this.logger.info("onrenegotiationneeded"); 97 | this.emitter.emit("renegotiationneeded", this.manager.createLocalDescription()); 98 | }); 99 | 100 | //Create event emitter 101 | this.emitter = new EventEmitter(); 102 | 103 | //Streams 104 | this.incomingStreams = new Map(); 105 | this.outgoingStreams = new Map(); 106 | 107 | //Create uri 108 | this.uri = room.uri.concat(["participants",id]); 109 | 110 | //Get child logger 111 | this.logger = room.logger.child("participants["+id+"]"); 112 | } 113 | 114 | getId() 115 | { 116 | return this.id; 117 | } 118 | 119 | init(sdp) { 120 | //Log 121 | this.logger.info("init"); 122 | 123 | //Process it 124 | this.manager.processRemoteDescription(sdp); 125 | 126 | //Return local sdp 127 | return this.manager.createLocalDescription(); 128 | } 129 | 130 | update(sdp) { 131 | //Check not closed 132 | if (!this.manager) 133 | return; 134 | //Log 135 | this.logger.info("update"); 136 | 137 | //Process it 138 | this.manager.processRemoteDescription(sdp); 139 | } 140 | 141 | addStream(stream) { 142 | 143 | this.logger.info("addStream() "+stream.uri.join("/")); 144 | 145 | //Create sfu local stream 146 | const outgoingStream = this.transport.createOutgoingStream({ 147 | audio: true, 148 | video: true 149 | }); 150 | 151 | //Add uri 152 | outgoingStream.uri = this.uri.concat(["outgoingStreams",outgoingStream.getId()]); 153 | 154 | //Append 155 | this.outgoingStreams.set(outgoingStream.getId(),outgoingStream); 156 | 157 | //Attach 158 | outgoingStream.attachTo(stream); 159 | 160 | //Listen when this stream is removed & stopped 161 | stream.on("stopped",()=>{ 162 | //If we are already stopped 163 | if (!this.outgoingStreams) 164 | //Do nothing 165 | return; 166 | this.logger.info("removeStream() "+stream.uri.join("/")); 167 | //Remove stream from outgoing streams 168 | this.outgoingStreams.delete(outgoingStream.getId()); 169 | //Remove stream 170 | outgoingStream.stop(); 171 | }); 172 | } 173 | 174 | 175 | getInfo() { 176 | //Create info 177 | const info = { 178 | id : this.id, 179 | name : this.name, 180 | streams : [ 181 | this.incomingStream ? this.incomingStream.getId() : undefined 182 | ] 183 | }; 184 | 185 | //Return it 186 | return info; 187 | } 188 | 189 | getIncomingStreams() { 190 | return this.incomingStreams.values(); 191 | } 192 | 193 | /** 194 | * Add event listener 195 | * @param {String} event - Event name 196 | * @param {function} listeener - Event listener 197 | * @returns {Transport} 198 | */ 199 | on() 200 | { 201 | //Delegate event listeners to event emitter 202 | this.emitter.on.apply(this.emitter, arguments); 203 | //Return object so it can be chained 204 | return this; 205 | } 206 | 207 | /** 208 | * Remove event listener 209 | * @param {String} event - Event name 210 | * @param {function} listener - Event listener 211 | * @returns {Transport} 212 | */ 213 | off() 214 | { 215 | //Delegate event listeners to event emitter 216 | this.emitter.removeListener.apply(this.emitter, arguments); 217 | //Return object so it can be chained 218 | return this; 219 | } 220 | 221 | stop() 222 | { 223 | this.logger.info("stop"); 224 | 225 | //remove all published streams 226 | for (let stream of this.incomingStreams.values()) 227 | //Stop it 228 | stream.stop(); 229 | 230 | 231 | //Remove all emitting streams 232 | for (let stream of this.outgoingStreams.values()) 233 | //Stop it 234 | stream.stop(); 235 | 236 | //IF we hve a transport 237 | if (this.transport) 238 | //Stop transport 239 | this.transport.stop(); 240 | 241 | //Stop manager 242 | this.manager.stop(); 243 | 244 | //Clean them 245 | this.room = null; 246 | this.incomingStreams = null; 247 | this.outgoingStreams = null; 248 | this.transport = null; 249 | this.manager = null; 250 | this.localSDP = null; 251 | this.remoteSDP = null; 252 | 253 | //Done 254 | this.logger.info("onstopped"); 255 | this.emitter.emit("stopped"); 256 | } 257 | }; 258 | 259 | module.exports = Participant; 260 | -------------------------------------------------------------------------------- /lib/Room.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const Logger = require("./Logger"); 3 | //Get Semantic SDP objects 4 | const SemanticSDP = require("semantic-sdp"); 5 | const SDPInfo = SemanticSDP.SDPInfo; 6 | const MediaInfo = SemanticSDP.MediaInfo; 7 | const CandidateInfo = SemanticSDP.CandidateInfo; 8 | const DTLSInfo = SemanticSDP.DTLSInfo; 9 | const ICEInfo = SemanticSDP.ICEInfo; 10 | const StreamInfo = SemanticSDP.StreamInfo; 11 | const TrackInfo = SemanticSDP.TrackInfo; 12 | const Direction = SemanticSDP.Direction; 13 | const CodecInfo = SemanticSDP.CodecInfo; 14 | 15 | //Get the Medooze Media Server interface 16 | const MediaServer = require("medooze-media-server"); 17 | 18 | const Participant = require("./Participant"); 19 | 20 | class Room 21 | { 22 | constructor(id,ip) 23 | { 24 | //Store id 25 | this.id = id; 26 | 27 | //Create UDP server endpoint 28 | this.endpoint = MediaServer.createEndpoint(ip); 29 | 30 | //The comentarist set 31 | this.participants = new Map(); 32 | 33 | //Create the room media capabilities 34 | this.capabilities = { 35 | audio : { 36 | codecs : ["opus"], 37 | extensions : ["urn:ietf:params:rtp-hdrext:ssrc-audio-level"] 38 | }, 39 | video : { 40 | codecs : ["vp9"], 41 | rtx : true, 42 | rtcpfbs : [ 43 | { "id": "transport-cc"}, 44 | { "id": "ccm", "params": ["fir"]}, 45 | { "id": "nack"}, 46 | { "id": "nack", "params": ["pli"]}, 47 | ], 48 | extensions : [ "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01", "urn:3gpp:video-orientation"] 49 | } 50 | }; 51 | 52 | //No participants 53 | this.max = 0; 54 | 55 | //Create event emitter 56 | this.emitter = new EventEmitter(); 57 | 58 | //Active speaker detection 59 | this.activeSpeakerDetector = MediaServer.createActiveSpeakerDetector(); 60 | 61 | //When new speaker detected 62 | this.activeSpeakerDetector.on("activespeakerchanged",(track)=>{ 63 | //Get active speaker id 64 | const speakerId = track.participant.getId(); 65 | //Check if it is the same as current one 66 | if (this.speakerId===speakerId) 67 | //Do nothing 68 | return; 69 | //Update speaker 70 | this.speakerId = speakerId; 71 | 72 | //Log 73 | this.logger.debug("activespeakerchanged speakerId=%d",speakerId); 74 | 75 | //Relaunch event 76 | this.emitter.emit("activespeakerchanged",track.participant,track); 77 | }); 78 | //Create uri 79 | this.uri = ["rooms",id]; 80 | 81 | //Create logger 82 | this.logger = new Logger("rooms["+this.id+"]"); 83 | 84 | //Log 85 | this.logger.info("created()"); 86 | } 87 | 88 | getId() 89 | { 90 | return this.id; 91 | } 92 | 93 | getSpeakerId() 94 | { 95 | return this.speakerId; 96 | } 97 | 98 | getEndpoint() 99 | { 100 | return this.endpoint; 101 | } 102 | 103 | getCapabilities() { 104 | return this.capabilities; 105 | } 106 | 107 | createParticipant(name) 108 | { 109 | this.logger.info("createParticipant() "+ name); 110 | 111 | //Create participant 112 | const participant = new Participant( 113 | this.max++, 114 | name, 115 | this.endpoint.createSDPManager("unified-plan",this.capabilities), 116 | this 117 | ); 118 | 119 | //Listener for any new publisher stream 120 | participant.on('stream',(stream)=>{ 121 | //Send it to the other participants 122 | for (let other of this.participants.values()) 123 | //Check it is not the event source 124 | if (participant.getId()!=other.getId()) 125 | //Add stream to participant 126 | other.addStream(stream); 127 | //Get audio tracks 128 | const audioTracks = stream.getAudioTracks(); 129 | //If there are any 130 | if (audioTracks.length) 131 | { 132 | //Get participant id 133 | const participantId = participant.getId(); 134 | //And firsst audio track 135 | const audioTrack = audioTracks[0]; 136 | //Only log for now 137 | this.logger.debug("addSpeaker() [participant:%s]",participantId); 138 | //Store participant 139 | audioTrack.participant = participant; 140 | audioTrack.stream = stream; 141 | //Add to detecotr 142 | this.activeSpeakerDetector.addSpeaker(audioTrack); 143 | } 144 | 145 | //Get video tracks 146 | const videoTracks = stream.getVideoTracks(); 147 | //For each video track 148 | for (let i=0; i { 155 | //Delete comentarist 156 | this.participants.delete(participant.id); 157 | //emir participant change 158 | this.emitter.emit("participants",this.participants.values()); 159 | }); 160 | 161 | //Add to the participant to list 162 | this.participants.set(participant.id,participant); 163 | 164 | //emit participant change 165 | this.emitter.emit("participants",this.getInfo().participants); 166 | 167 | //Done 168 | return participant; 169 | } 170 | 171 | getStreams() { 172 | const streams = []; 173 | 174 | //For each participant 175 | for (let participant of this.participants.values()) 176 | //For each stream 177 | for (let stream of participant.getIncomingStreams()) 178 | //Add participant streams 179 | streams.push(stream); 180 | //return them 181 | return streams; 182 | } 183 | 184 | getInfo() 185 | { 186 | //Create info 187 | const info = { 188 | id : this.id, 189 | participants : [] 190 | }; 191 | 192 | //For each participant 193 | for (let participant of this.participants.values()) 194 | //Append it 195 | info.participants.push(participant.getInfo()); 196 | 197 | //Return it 198 | return info; 199 | } 200 | 201 | /** 202 | * Add event listener 203 | * @param {String} event - Event name 204 | * @param {function} listeener - Event listener 205 | * @returns {Transport} 206 | */ 207 | on() 208 | { 209 | //Delegate event listeners to event emitter 210 | this.emitter.on.apply(this.emitter, arguments); 211 | //Return object so it can be chained 212 | return this; 213 | } 214 | 215 | /** 216 | * Remove event listener 217 | * @param {String} event - Event name 218 | * @param {function} listener - Event listener 219 | * @returns {Transport} 220 | */ 221 | off() 222 | { 223 | //Delegate event listeners to event emitter 224 | this.emitter.removeListener.apply(this.emitter, arguments); 225 | //Return object so it can be chained 226 | return this; 227 | } 228 | 229 | stop() 230 | { 231 | if (!this.endpoint) 232 | return; 233 | 234 | //Log 235 | this.logger.info("stop()"); 236 | 237 | //Stop vad 238 | this.activeSpeakerDetector.stop(); 239 | 240 | //For each participant 241 | for (const [id,participant] of this.participants) 242 | //Stop it with reason 243 | participant.stop("destroyed"); 244 | 245 | //Stop endpoint 246 | this.endpoint.stop(); 247 | 248 | //Emit event 249 | this.logger.info("stopped"); 250 | this.emitter.emit("stopped"); 251 | 252 | //Null things 253 | this.activeSpeakerDetector = null; 254 | this.participants = null; 255 | this.endpoint = null; 256 | this.emitter = null; 257 | } 258 | 259 | }; 260 | 261 | module.exports = Room; 262 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 282 | 283 | 284 |
285 |
286 |
Room:
287 |
288 |
289 | 290 |

Medooze SFU

291 |
292 |

293 | Please enter the room ID for the conference you want to create, your name and select the audio microphone you wish to use 294 |

295 |
296 |
297 | 298 | 299 |
300 | 301 |
302 | 303 | 304 |
305 |
306 | 307 | 308 |
309 |
310 |
311 | 312 | 315 | 316 |
    317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 | 329 |
330 |
331 |
332 | 333 |
334 | 335 | 336 | 337 | -------------------------------------------------------------------------------- /www/js/sfu.js: -------------------------------------------------------------------------------- 1 | let participants; 2 | let audioDeviceId; 3 | let videoResolution = true; 4 | 5 | //Get our url 6 | const href = new URL(window.location.href); 7 | //Get id 8 | const roomId = href.searchParams.get("roomId"); 9 | //Get name 10 | const name = href.searchParams.get("name"); 11 | //Get key 12 | const key = href.searchParams.get("key"); 13 | //Get video 14 | const nopublish = href.searchParams.has("nopublish"); 15 | //Get ws url from navigaro url 16 | const url = "wss://"+href.host; 17 | //Check support for insertabe media streams 18 | // In Chrome v98 is "RTCRtpSender.prototype.createEncodedStreams" 19 | const supportsInsertableStreams = !!RTCRtpSender.prototype.createEncodedVideoStreams || !!RTCRtpSender.prototype.createEncodedStreams; 20 | 21 | if (href.searchParams.has ("video")) 22 | switch (href.searchParams.get ("video").toLowerCase ()) 23 | { 24 | case "1080p": 25 | videoResolution = { 26 | width: {min: 1920, max: 1920}, 27 | height: {min: 1080, max: 1080}, 28 | }; 29 | break; 30 | case "720p": 31 | videoResolution = { 32 | width: {min: 1280, max: 1280}, 33 | height: {min: 720, max: 720}, 34 | }; 35 | break; 36 | case "576p": 37 | videoResolution = { 38 | width: {min: 720, max: 720}, 39 | height: {min: 576, max: 576}, 40 | }; 41 | break; 42 | case "480p": 43 | videoResolution = { 44 | width: {min: 640, max: 640}, 45 | height: {min: 480, max: 480}, 46 | }; 47 | break; 48 | case "320p": 49 | videoResolution = { 50 | width: {min: 320, max: 320}, 51 | height: {min: 240, max: 240}, 52 | }; 53 | break; 54 | case "no": 55 | videoResolution = false; 56 | break; 57 | } 58 | 59 | 60 | function addRemoteTrack(event) 61 | { 62 | console.log(event); 63 | 64 | const track = event.track; 65 | const stream = event.streams[0]; 66 | 67 | if (!stream) 68 | return console.log("addRemoteTrack() no stream") 69 | 70 | //Check if video is already present 71 | let video = container.querySelector("video[id='"+stream.id+"']"); 72 | 73 | //Check if already present 74 | if (video) 75 | //Ignore 76 | return console.log("addRemoteTrack() video already present for "+stream.id); 77 | 78 | //Listen for end event 79 | track.onended=(event)=>{ 80 | console.log(event); 81 | 82 | //Check if video is already present 83 | let video = container.querySelector("video[id='"+stream.id+"']"); 84 | 85 | //Check if already present 86 | if (!video) 87 | //Ignore 88 | return console.log("removeRemoteTrack() video not present for "+stream.id); 89 | 90 | container.removeChild(video); 91 | } 92 | 93 | //Create new video element 94 | video = document.createElement("video"); 95 | //Set same id 96 | video.id = stream.id; 97 | //Set src stream 98 | video.srcObject = stream; 99 | //Set other properties 100 | video.autoplay = true; 101 | video.play(); 102 | //Append it 103 | container.appendChild(video); 104 | } 105 | 106 | function addLocalVideoForStream(stream,muted) 107 | { 108 | //Create new video element 109 | const video = document.createElement("video"); 110 | //Set same id 111 | video.id = stream.id; 112 | //Set src stream 113 | video.srcObject = stream; 114 | //Set other properties 115 | video.autoplay = true; 116 | video.muted = muted; 117 | video.play(); 118 | //Append it 119 | container.appendChild(video); 120 | } 121 | 122 | /* 123 | Get some key material to use as input to the deriveKey method. 124 | The key material is a secret key supplied by the user. 125 | */ 126 | async function getRoomKey(roomId,secret) 127 | { 128 | const enc = new TextEncoder(); 129 | const keyMaterial = await window.crypto.subtle.importKey( 130 | "raw", 131 | enc.encode(secret), 132 | {name: "PBKDF2"}, 133 | false, 134 | ["deriveBits", "deriveKey"] 135 | ); 136 | return window.crypto.subtle.deriveKey( 137 | { 138 | name: "PBKDF2", 139 | salt: enc.encode(roomId), 140 | iterations: 100000, 141 | hash: "SHA-256" 142 | }, 143 | keyMaterial, 144 | {"name": "AES-GCM", "length": 256}, 145 | true, 146 | ["encrypt", "decrypt"] 147 | ); 148 | } 149 | 150 | /* 151 | * 152 | */ 153 | async function connect(url,roomId,name,secret) 154 | { 155 | let counter = 0; 156 | const roomKey = await getRoomKey(roomId,secret); 157 | async function encrypt(chunk, controller) { 158 | try { 159 | //Get iv 160 | const iv = new ArrayBuffer(4); 161 | //Create view, inc counter and set it 162 | new DataView(iv).setUint32(0,counter <65535 ? counter++ : counter=0); 163 | //Encrypt 164 | const ciphertext = await window.crypto.subtle.encrypt( 165 | { 166 | name: "AES-GCM", 167 | iv: iv 168 | }, 169 | roomKey, 170 | chunk.data 171 | ); 172 | //Set chunk data 173 | chunk.data = new ArrayBuffer(ciphertext.byteLength + 4); 174 | //Crate new encoded data and allocate size for iv 175 | const data = new Uint8Array(chunk.data); 176 | //Copy iv 177 | data.set(new Uint8Array(iv),0); 178 | //Copy cipher 179 | data.set(new Uint8Array(ciphertext),4); 180 | //Write 181 | controller.enqueue(chunk); 182 | } catch(e) { 183 | } 184 | } 185 | 186 | async function decrypt(chunk, controller) { 187 | try { 188 | //decrypt 189 | chunk.data = await window.crypto.subtle.decrypt( 190 | { 191 | name: "AES-GCM", 192 | iv: new Uint8Array(chunk.data,0,4) 193 | }, 194 | roomKey, 195 | new Uint8Array(chunk.data,4,chunk.data.byteLength - 4) 196 | ); 197 | //Write 198 | controller.enqueue(chunk); 199 | } catch(e) { 200 | } 201 | } 202 | 203 | const isCryptoEnabled = !!secret && supportsInsertableStreams; 204 | 205 | var pc = new RTCPeerConnection({ 206 | bundlePolicy : "max-bundle", 207 | rtcpMuxPolicy : "require", 208 | forceEncodedVideoInsertableStreams : isCryptoEnabled 209 | }); 210 | 211 | //Create room url 212 | const roomUrl = url +"?id="+roomId; 213 | 214 | var ws = new WebSocket(roomUrl); 215 | var tm = new TransactionManager(ws); 216 | 217 | pc.ontrack = (event) => { 218 | //If encrypting/decrypting 219 | if (isCryptoEnabled) 220 | { 221 | //Create transfor strem fro decrypting 222 | const transform = new TransformStream({ 223 | start() {}, 224 | flush() {}, 225 | transform: decrypt 226 | }); 227 | //Get the receiver streams for track 228 | let receiverStreams = event.receiver.createEncodedVideoStreams(); 229 | //Decrytp 230 | receiverStreams.readableStream 231 | .pipeThrough(transform) 232 | .pipeTo(receiverStreams.writableStream); 233 | } 234 | addRemoteTrack(event); 235 | }; 236 | 237 | ws.onopen = async function() 238 | { 239 | console.log("ws:opened"); 240 | 241 | try 242 | { 243 | if (!nopublish) 244 | { 245 | const stream = await navigator.mediaDevices.getUserMedia({ 246 | audio: { 247 | deviceId: audioDeviceId 248 | }, 249 | video: videoResolution 250 | }); 251 | 252 | console.debug("md::getUserMedia sucess",stream); 253 | 254 | //Play it 255 | addLocalVideoForStream(stream,true); 256 | //Add stream to peer connection 257 | for (const track of stream.getTracks()) 258 | { 259 | //Add track 260 | const sender = pc.addTrack(track,stream); 261 | //If encrypting/decrypting 262 | if (isCryptoEnabled) 263 | { 264 | //Get insertable streams 265 | const senderStreams = sender.createEncodedVideoStreams(); 266 | //Create transform stream for encryption 267 | let senderTransformStream = new TransformStream({ 268 | start() {}, 269 | flush() {}, 270 | transform: encrypt 271 | }); 272 | //Encrypt 273 | senderStreams.readableStream 274 | .pipeThrough(senderTransformStream) 275 | .pipeTo(senderStreams.writableStream); 276 | } 277 | } 278 | } 279 | 280 | //Create new offer 281 | const offer = await pc.createOffer({ 282 | offerToReceiveAudio: true, 283 | offerToReceiveVideo: true 284 | }); 285 | 286 | console.debug("pc::createOffer sucess",offer); 287 | 288 | //Set it 289 | pc.setLocalDescription(offer); 290 | 291 | console.log("pc::setLocalDescription succes",offer.sdp); 292 | 293 | //Join room 294 | const joined = await tm.cmd("join",{ 295 | name : name, 296 | sdp : offer.sdp 297 | }); 298 | 299 | console.log("cmd::join success",joined); 300 | 301 | //Create answer 302 | const answer = new RTCSessionDescription({ 303 | type :'answer', 304 | sdp : joined.sdp 305 | }); 306 | 307 | //Set it 308 | await pc.setRemoteDescription(answer); 309 | 310 | console.log("pc::setRemoteDescription succes",answer.sdp); 311 | 312 | console.log("JOINED"); 313 | } catch (error) { 314 | console.error("Error",error); 315 | ws.close(); 316 | } 317 | }; 318 | 319 | tm.on("cmd",async function(cmd) { 320 | console.log("ts::cmd",cmd); 321 | 322 | switch (cmd.name) 323 | { 324 | case "update" : 325 | try 326 | { 327 | console.log(cmd.data.sdp); 328 | 329 | //Create new offer 330 | const offer = new RTCSessionDescription({ 331 | type : 'offer', 332 | sdp : cmd.data.sdp 333 | }); 334 | 335 | //Set offer 336 | await pc.setRemoteDescription(offer); 337 | 338 | console.log("pc::setRemoteDescription succes",offer.sdp); 339 | 340 | //Create answer 341 | const answer = await pc.createAnswer(); 342 | 343 | console.log("pc::createAnswer succes",answer.sdp); 344 | 345 | //Only set it locally 346 | await pc.setLocalDescription(answer); 347 | 348 | console.log("pc::setLocalDescription succes",answer.sdp); 349 | 350 | //accept 351 | cmd.accept({sdp:answer.sdp}); 352 | 353 | } catch (error) { 354 | console.error("Error",error); 355 | ws.close(); 356 | } 357 | break; 358 | } 359 | }); 360 | 361 | tm.on("event",async function(event) { 362 | console.log("ts::event",event); 363 | 364 | switch (event.name) 365 | { 366 | case "participants" : 367 | //update participant list 368 | participants = event.participants; 369 | break; 370 | } 371 | }); 372 | } 373 | 374 | navigator.mediaDevices.getUserMedia({ 375 | audio: true, 376 | video: false 377 | }) 378 | .then(function(stream){ 379 | 380 | //Set the input value 381 | audio_devices.value = stream.getAudioTracks()[0].label; 382 | 383 | //Get the select 384 | var menu = document.getElementById("audio_devices_menu"); 385 | 386 | //Populate the device lists 387 | navigator.mediaDevices.enumerateDevices() 388 | .then(function(devices) { 389 | //For each one 390 | devices.forEach(function(device) 391 | { 392 | //It is a mic? 393 | if (device.kind==="audioinput") 394 | { 395 | //Create menu item 396 | var li = document.createElement("li"); 397 | //Populate 398 | li.dataset["val"] = device.deviceId; 399 | li.innerText = device.label; 400 | li.className = "mdl-menu__item"; 401 | 402 | //Add listener 403 | li.addEventListener('click', function() { 404 | console.log(device.deviceId); 405 | //Close previous 406 | stream.getAudioTracks()[0].stop(); 407 | //Store device id 408 | audioDeviceId = device.deviceId 409 | //Get stream for the device 410 | navigator.mediaDevices.getUserMedia({ 411 | audio: { 412 | deviceId: device.deviceId 413 | }, 414 | video: false 415 | }) 416 | .then(function(stream){ 417 | //Store it 418 | soundMeter.connectToSource(stream).then(draw); 419 | }); 420 | 421 | }); 422 | //Append 423 | menu.appendChild (li); 424 | } 425 | }); 426 | //Upgrade 427 | getmdlSelect.init('.getmdl-select'); 428 | componentHandler.upgradeDom(); 429 | }) 430 | .catch(function(error){ 431 | console.log(error); 432 | }); 433 | 434 | var fps = 20; 435 | var now; 436 | var then = Date.now(); 437 | var interval = 1000/fps; 438 | var delta; 439 | var drawTimer; 440 | var soundMeter = new SoundMeter(window); 441 | //Stop 442 | cancelAnimationFrame(drawTimer); 443 | 444 | function draw() { 445 | drawTimer = requestAnimationFrame(draw); 446 | 447 | now = Date.now(); 448 | delta = now - then; 449 | 450 | if (delta > interval) { 451 | then = now ; 452 | var tot = Math.min(100,(soundMeter.instant*200)); 453 | //Get all 454 | const voometers = document.querySelectorAll (".voometer"); 455 | //Set new size 456 | for (let i=0;i 0 && this._events[type].length > m) { 138 | this._events[type].warned = true; 139 | console.error('(node) warning: possible EventEmitter memory ' + 140 | 'leak detected. %d listeners added. ' + 141 | 'Use emitter.setMaxListeners() to increase limit.', 142 | this._events[type].length); 143 | if (typeof console.trace === 'function') { 144 | // not supported in IE 10 145 | console.trace(); 146 | } 147 | } 148 | } 149 | 150 | return this; 151 | }; 152 | 153 | EventEmitter.prototype.on = EventEmitter.prototype.addListener; 154 | 155 | EventEmitter.prototype.once = function(type, listener) { 156 | if (!isFunction(listener)) 157 | throw TypeError('listener must be a function'); 158 | 159 | var fired = false; 160 | 161 | function g() { 162 | this.removeListener(type, g); 163 | 164 | if (!fired) { 165 | fired = true; 166 | listener.apply(this, arguments); 167 | } 168 | } 169 | 170 | g.listener = listener; 171 | this.on(type, g); 172 | 173 | return this; 174 | }; 175 | 176 | // emits a 'removeListener' event iff the listener was removed 177 | EventEmitter.prototype.removeListener = function(type, listener) { 178 | var list, position, length, i; 179 | 180 | if (!isFunction(listener)) 181 | throw TypeError('listener must be a function'); 182 | 183 | if (!this._events || !this._events[type]) 184 | return this; 185 | 186 | list = this._events[type]; 187 | length = list.length; 188 | position = -1; 189 | 190 | if (list === listener || 191 | (isFunction(list.listener) && list.listener === listener)) { 192 | delete this._events[type]; 193 | if (this._events.removeListener) 194 | this.emit('removeListener', type, listener); 195 | 196 | } else if (isObject(list)) { 197 | for (i = length; i-- > 0;) { 198 | if (list[i] === listener || 199 | (list[i].listener && list[i].listener === listener)) { 200 | position = i; 201 | break; 202 | } 203 | } 204 | 205 | if (position < 0) 206 | return this; 207 | 208 | if (list.length === 1) { 209 | list.length = 0; 210 | delete this._events[type]; 211 | } else { 212 | list.splice(position, 1); 213 | } 214 | 215 | if (this._events.removeListener) 216 | this.emit('removeListener', type, listener); 217 | } 218 | 219 | return this; 220 | }; 221 | 222 | EventEmitter.prototype.removeAllListeners = function(type) { 223 | var key, listeners; 224 | 225 | if (!this._events) 226 | return this; 227 | 228 | // not listening for removeListener, no need to emit 229 | if (!this._events.removeListener) { 230 | if (arguments.length === 0) 231 | this._events = {}; 232 | else if (this._events[type]) 233 | delete this._events[type]; 234 | return this; 235 | } 236 | 237 | // emit removeListener for all listeners on all events 238 | if (arguments.length === 0) { 239 | for (key in this._events) { 240 | if (key === 'removeListener') continue; 241 | this.removeAllListeners(key); 242 | } 243 | this.removeAllListeners('removeListener'); 244 | this._events = {}; 245 | return this; 246 | } 247 | 248 | listeners = this._events[type]; 249 | 250 | if (isFunction(listeners)) { 251 | this.removeListener(type, listeners); 252 | } else if (listeners) { 253 | // LIFO order 254 | while (listeners.length) 255 | this.removeListener(type, listeners[listeners.length - 1]); 256 | } 257 | delete this._events[type]; 258 | 259 | return this; 260 | }; 261 | 262 | EventEmitter.prototype.listeners = function(type) { 263 | var ret; 264 | if (!this._events || !this._events[type]) 265 | ret = []; 266 | else if (isFunction(this._events[type])) 267 | ret = [this._events[type]]; 268 | else 269 | ret = this._events[type].slice(); 270 | return ret; 271 | }; 272 | 273 | EventEmitter.prototype.listenerCount = function(type) { 274 | if (this._events) { 275 | var evlistener = this._events[type]; 276 | 277 | if (isFunction(evlistener)) 278 | return 1; 279 | else if (evlistener) 280 | return evlistener.length; 281 | } 282 | return 0; 283 | }; 284 | 285 | EventEmitter.listenerCount = function(emitter, type) { 286 | return emitter.listenerCount(type); 287 | }; 288 | 289 | function isFunction(arg) { 290 | return typeof arg === 'function'; 291 | } 292 | 293 | function isNumber(arg) { 294 | return typeof arg === 'number'; 295 | } 296 | 297 | function isObject(arg) { 298 | return typeof arg === 'object' && arg !== null; 299 | } 300 | 301 | function isUndefined(arg) { 302 | return arg === void 0; 303 | } 304 | 305 | },{}],2:[function(require,module,exports){ 306 | "use strict"; 307 | const EventEmitter = require('events'); 308 | 309 | class Namespace extends EventEmitter 310 | { 311 | constructor(namespace,tm) 312 | { 313 | super(); 314 | this.namespace = namespace; 315 | this.tm = tm; 316 | } 317 | 318 | cmd(name,data) 319 | { 320 | return this.tm.cmd(name,data,this.namespace); 321 | } 322 | 323 | event(name,data) 324 | { 325 | return this.tm.event(name,data,this.namespace); 326 | } 327 | 328 | close() 329 | { 330 | return this.tm.namespaces.delete(this.namespace); 331 | } 332 | }; 333 | 334 | class TransactionManager extends EventEmitter 335 | { 336 | constructor(transport) 337 | { 338 | super(); 339 | this.maxId = 0; 340 | this.namespaces = new Map(); 341 | this.transactions = new Map(); 342 | this.transport = transport; 343 | 344 | //Message event listener 345 | this.listener = (msg) => { 346 | //Process message 347 | var message = JSON.parse(msg.utf8Data || msg.data); 348 | 349 | //Check type 350 | switch(message.type) 351 | { 352 | case "cmd" : 353 | //Create command 354 | const cmd = { 355 | name : message.name, 356 | data : message.data, 357 | namespace : message.namespace, 358 | accept : (data) => { 359 | //Send response back 360 | transport.send(JSON.stringify ({ 361 | type : "response", 362 | transId : message.transId, 363 | data : data 364 | })); 365 | }, 366 | reject : (data) => { 367 | //Send response back 368 | transport.send(JSON.stringify ({ 369 | type : "error", 370 | transId : message.transId, 371 | data : data 372 | })); 373 | } 374 | }; 375 | 376 | //If it has a namespace 377 | if (cmd.namespace) 378 | { 379 | //Get namespace 380 | const namespace = this.namespaces.get(cmd.namespace); 381 | //If we have it 382 | if (namespace) 383 | //trigger event only on namespace 384 | namespace.emit("cmd",cmd); 385 | else 386 | //Launch event on main event handler 387 | this.emit("cmd",cmd); 388 | } else { 389 | //Launch event on main event handler 390 | this.emit("cmd",cmd); 391 | } 392 | break; 393 | case "response": 394 | { 395 | //Get transaction 396 | const transaction = this.transactions.get(message.transId); 397 | if (!transaction) 398 | return; 399 | //delete transacetion 400 | this.transactions.delete(message.transId); 401 | //Accept 402 | transaction.resolve(message.data); 403 | break; 404 | } 405 | case "error": 406 | { 407 | //Get transaction 408 | const transaction = this.transactions.get(message.transId); 409 | if (!transaction) 410 | return; 411 | //delete transacetion 412 | this.transactions.delete(message.transId); 413 | //Reject 414 | transaction.reject(message.data); 415 | break; 416 | } 417 | case "event": 418 | //Create event 419 | const event = { 420 | name : message.name, 421 | data : message.data, 422 | namespace : message.namespace, 423 | }; 424 | //If it has a namespace 425 | if (event.namespace) 426 | { 427 | //Get namespace 428 | var namespace = this.namespaces.get(event.namespace); 429 | //If we have it 430 | if (namespace) 431 | //trigger event 432 | namespace.emit("event",event); 433 | else 434 | //Launch event on main event handler 435 | this.emit("event",event); 436 | } else { 437 | //Launch event on main event handler 438 | this.emit("event",event); 439 | } 440 | break; 441 | } 442 | }; 443 | 444 | //Add it 445 | this.transport.addListener ? this.transport.addListener("message",this.listener) : this.transport.addEventListener("message",this.listener); 446 | } 447 | 448 | cmd(name,data,namespace) 449 | { 450 | return new Promise((resolve,reject) => { 451 | //Check name is correct 452 | if (!name || name.length===0) 453 | throw new Error("Bad command name"); 454 | 455 | //Create command 456 | const cmd = { 457 | type : "cmd", 458 | transId : this.maxId++, 459 | name : name, 460 | data : data 461 | }; 462 | //Check namespace 463 | if (namespace) 464 | //Add it 465 | cmd.namespace = namespace; 466 | //Serialize 467 | const json = JSON.stringify(cmd); 468 | //Add callbacks 469 | cmd.resolve = resolve; 470 | cmd.reject = reject; 471 | //Add to map 472 | this.transactions.set(cmd.transId,cmd); 473 | 474 | try { 475 | //Send json 476 | this.transport.send(json); 477 | } catch (e) { 478 | //delete transacetion 479 | this.transactions.delete(cmd.transId); 480 | //rethrow 481 | throw e; 482 | } 483 | }); 484 | } 485 | 486 | event(name,data,namespace) 487 | { 488 | //Check name is correct 489 | if (!name || name.length===0) 490 | throw new Error("Bad event name"); 491 | 492 | //Create command 493 | const event = { 494 | type : "event", 495 | name : name, 496 | data : data 497 | }; 498 | //Check namespace 499 | if (namespace) 500 | //Add it 501 | event.namespace = namespace; 502 | //Serialize 503 | const json = JSON.stringify(event); 504 | //Send json 505 | return this.transport.send(json); 506 | } 507 | 508 | namespace(ns) 509 | { 510 | //Check if we already have them 511 | let namespace = this.namespaces.get(ns); 512 | //If already have it 513 | if (namespace) return namespace; 514 | //Create one instead 515 | namespace = new Namespace(ns,this); 516 | //Store it 517 | this.namespaces.set(ns, namespace); 518 | //ok 519 | return namespace; 520 | 521 | } 522 | 523 | close() 524 | { 525 | //Erase namespaces 526 | for (const ns of this.namespace.values()) 527 | //terminate it 528 | ns.close(); 529 | //remove lisnters 530 | this.transport.removeListener ? this.transport.removeListener("message",this.listener) : this.transport.removeEventListener("message",this.listener); 531 | } 532 | }; 533 | 534 | module.exports = TransactionManager; 535 | },{"events":1}]},{},[2])(2) 536 | }); --------------------------------------------------------------------------------