├── .gitignore ├── .travis.yml ├── CNAME ├── README.md ├── components ├── call.js ├── files.js ├── index.js ├── peer.js ├── record.js ├── visuals.js ├── volume.js └── waudio.js ├── dist.js ├── faq.html ├── favicon.png ├── index.html ├── index.js ├── lib └── getRTCConfig.js ├── package.json ├── scripts └── deploy.sh └── worker.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | notifications: 3 | email: false 4 | before_script: 5 | - npm prune 6 | branches: 7 | except: 8 | - /^v\d+\.\d+\.\d+$/ 9 | node_js: 10 | - '8' 11 | cache: 12 | directories: 13 | - node_modules 14 | script: 15 | - npm run test 16 | - npm run build 17 | after_success: 18 | - npm run semantic-release 19 | - ./scripts/deploy.sh -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | rollcall.audio 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roll Call 2 | 3 | Roll Call is a completely free🎉 voice chat service with podcast 4 | quality recording. 5 | 6 |

7 | 8 | 9 | 10 |

11 | 12 | Go ahead and use it: [rollcall.audio](https://rollcall.audio) 13 | 14 | ![demo gif](https://file-vpbygpmpka.now.sh) 15 | 16 | Features Include: 17 | 18 | * Multi-party realtime audio calls. 19 | * Drag & Drop File Sharing. 20 | * Podcast quality recording. 21 | * We record each participant locally and send you the audio via the 22 | data channel instead of recording the compressed and often low quality 23 | realtime audio. 24 | 25 | For more information on how to use Roll Call check out the 26 | [FAQ](https://rollcall.audio/faq.html). 27 | 28 | Roll Call is entirely Open Source and can be embedded into your own web pages 29 | and web applications. 30 | 31 | ## Chrome/Brave Only 32 | 33 | **Roll Call only works in last few releases of 34 | Chrome & Brave**. This is not due to lack of testing or development work but 35 | because of bugs in Safari and Firefox. Roll Call sits at the intersection of 36 | browser audio and WebRTC support, it's a minefield for finding bugs burried 37 | deep in browser implementations. Even supporting Chrome takes [some hacks](https://github.com/mikeal/waudio/blob/master/index.js#L9). 38 | 39 | 40 | ## Embedding 41 | 42 | Roll Call can easily be embedded on your own website. The easiest way is 43 | with a script include. 44 | 45 | ```html 46 | 47 | 48 | 49 | 50 | ``` 51 | 52 | Roll Call uses WebComponents. This means that you can use it like 53 | any other HTML element and manipulate its state with JavaScript. 54 | 55 | Or, if you want to build it into the JavaScript bundle for your own app 56 | you can do so easily, but you'll need to handle loading a WebComponents 57 | polyfill for most browsers on your own. 58 | 59 | ```javascript 60 | const { Call } = require('roll-call') 61 | 62 | let elem = new Call() 63 | elem.call = 'myUniqueCallIdentifier' 64 | document.body.appendChild(elem) 65 | ``` 66 | 67 | Once you require the script the elements are registered so you could also 68 | do something like this. 69 | 70 | ```javascript 71 | require('roll-call') 72 | 73 | document.body.innerHTML += `` 74 | ``` 75 | 76 | ## To Develop 77 | 78 | Download the code and run `npm install`. 79 | 80 | If you want to do development run: 81 | 82 | ```bash 83 | npm install 84 | npm start 85 | ``` 86 | 87 | ## Try It Out 88 | 89 | Roll Call is built and [deployed automatically](https://github.com/mikeal/roll-call/blob/master/scripts/deploy.sh): 90 | 91 | * [rollcall.audio](https://rollcall.audio) runs the [stable](https://github.com/mikeal/roll-call/tree/stable) branch. 92 | * [rollcall.audio/staging](https://rollcall.audio/staging/) runs the latest code on [master](https://github.com/mikeal/roll-call/tree/master). 93 | 94 | ## Wu-Tang Roll Call 95 | 96 | ``` 97 | The Rza, 98 | the Gza, 99 | Inspectah Deck, 100 | Raekwon, 101 | U-God, 102 | Masta Killa, 103 | Method Man, 104 | Ghostface Killah, 105 | and the late great Ol Dirty Bastard. 106 | ``` 107 | -------------------------------------------------------------------------------- /components/call.js: -------------------------------------------------------------------------------- 1 | const bel = require('bel') 2 | const dragDrop = require('drag-drop') 3 | const getUserMedia = require('get-user-media-promise') 4 | const waudio = require('./waudio') 5 | const createSwarm = require('killa-beez') 6 | const ZComponent = require('zcomponent') 7 | const Peer = require('./peer') 8 | const RecordButton = require('./record') 9 | const getRTCConfig = require('../lib/getRTCConfig') 10 | 11 | const getConfig = () => { 12 | return new Promise((resolve, reject) => { 13 | getRTCConfig((err, config) => { 14 | if (err) resolve(null) 15 | else resolve(config) 16 | }) 17 | }) 18 | } 19 | 20 | const each = (arr, fn) => { 21 | return Array.from(arr).forEach(fn) 22 | } 23 | 24 | class Call extends ZComponent { 25 | constructor () { 26 | super() 27 | this.roomHost = 'https://roomexchange.now.sh' 28 | this.serving = [] 29 | dragDrop(this, files => { 30 | this.serveFiles(files) 31 | }) 32 | } 33 | set call (val) { 34 | this.start(val) 35 | } 36 | async start (callid) { 37 | let device = await this.device 38 | 39 | let audioopts = { 40 | echoCancellation: true, 41 | volume: 0.9, 42 | deviceId: device ? {exact: device} : undefined 43 | } 44 | let mediaopts = { 45 | audio: audioopts, 46 | video: false 47 | } 48 | let [media, config] = await Promise.all([ 49 | getUserMedia(mediaopts), 50 | getConfig() 51 | ]) 52 | 53 | let output = waudio(media) 54 | let swarm = createSwarm({stream: output.stream, config}) 55 | swarm.on('peer', peer => this.onPeer(peer)) 56 | 57 | let record = new RecordButton() 58 | this.appendChild(record) 59 | record.swarm = swarm 60 | 61 | this.speakers = waudio(true) 62 | this.swarm = swarm 63 | this.output = output 64 | 65 | let me = new Peer() 66 | me.id = 'peer:me' 67 | me.audio = output 68 | me.appendChild(bel`me`) 69 | this.appendChild(me) 70 | this.me = me 71 | 72 | swarm.joinRoom(this.roomHost, callid) 73 | } 74 | get device () { 75 | return this._device 76 | } 77 | onPeer (peer) { 78 | let elem = new Peer() 79 | elem.attach(peer, this.speakers) 80 | // TODO: serve files. 81 | this.appendChild(elem) 82 | } 83 | serveFiles (files) { 84 | this.serving = this.serving.concat(Array.from(files)) 85 | each(this.querySelectorAll('roll-call-peer'), peer => { 86 | peer.serveFiles(files) 87 | }) 88 | } 89 | get shadow () { 90 | return ` 91 | 99 | 100 | ` 101 | } 102 | } 103 | 104 | window.customElements.define('roll-call', Call) 105 | 106 | module.exports = Call 107 | -------------------------------------------------------------------------------- /components/files.js: -------------------------------------------------------------------------------- 1 | /* globals URL, Blob */ 2 | const ZComponent = require('zcomponent') 3 | const bel = require('bel') 4 | 5 | const iconMap = { 6 | 'audio': `🎧`, 8 | 'image': `🖼️`, 10 | 'video': `📹`, 12 | 'text': `📃` 14 | } 15 | 16 | class FileShare extends ZComponent { 17 | set contentType (contentType) { 18 | // TODO: set icon for slot 19 | for (let key in iconMap) { 20 | if (contentType.startsWith(key)) { 21 | this.appendChild(bel([iconMap[key]])) 22 | } 23 | } 24 | } 25 | set action (value) { 26 | let action = this.shadowRoot.querySelector('div.action') 27 | if (typeof value === 'string') { 28 | action.innerHTML = value 29 | } else { 30 | action.innerHTML = '' 31 | action.appendChild(value) 32 | } 33 | } 34 | set size (value) { 35 | this._size = value 36 | let size = this._size 37 | if (size > (1000000)) { 38 | size = parseInt(size / 1000000) 39 | size += 'm' 40 | } if (size > 1000) { 41 | size = parseInt(size / 1000) 42 | size += 'k' 43 | } else { 44 | size += 'b' 45 | } 46 | this.shadowRoot.querySelector('div.size').textContent = size 47 | } 48 | get size () { 49 | return this._size || 0 50 | } 51 | progress (chunk) { 52 | if (chunk === null) { 53 | this.complete = true 54 | return 55 | } 56 | this.size += chunk.length 57 | let elem = this.shadowRoot.querySelector('progress') 58 | if (elem) { 59 | elem.setAttribute('value', this.size) 60 | } 61 | } 62 | get shadow () { 63 | return ` 64 | 103 | 104 | 💽 106 | 107 |
108 |
109 |
110 |
111 | ` 112 | } 113 | } 114 | 115 | class Uploader extends FileShare { 116 | constructor () { 117 | super() 118 | let sizeElement = this.shadowRoot.querySelector('div.size') 119 | sizeElement.setAttribute('title', 'uploaded') 120 | } 121 | set uploading (value) { 122 | if (!this._uploading) { 123 | this._uploading = true 124 | let progress = bel([` 125 | 126 | `]) 127 | this.size = 0 128 | this.action = progress 129 | } 130 | } 131 | set complete (value) { 132 | this.action = 'Sent' 133 | } 134 | } 135 | 136 | class Downloader extends FileShare { 137 | constructor () { 138 | super() 139 | let sizeElement = this.shadowRoot.querySelector('div.size') 140 | sizeElement.setAttribute('title', 'file size') 141 | this.chunks = [] 142 | } 143 | set rpc (rpc) { 144 | let f = this.filename 145 | let start = bel` 146 | ${f} 147 | ` 148 | start.onclick = async () => { 149 | start.onclick = null 150 | let progress = bel([` 151 | 152 | `]) 153 | this.action = progress 154 | this.size = 0 155 | let chunk = true 156 | let i = 0 157 | while (chunk) { 158 | chunk = await rpc.read(this.filename, i) 159 | if (chunk && chunk.length) { 160 | this.write(chunk) 161 | i += chunk.length 162 | this.progress(chunk) 163 | } 164 | } 165 | this.complete = true 166 | } 167 | this.action = start 168 | } 169 | write (chunk) { 170 | this.chunks.push(chunk) 171 | } 172 | set complete (value) { 173 | // TODO: wire up save button. 174 | let f = this.filename 175 | let save = bel`Save` 176 | save.onclick = async () => { 177 | let blob = new Blob(this.chunks, {type: this.contentType}) 178 | let url = URL.createObjectURL(blob) 179 | let a = document.createElement('a') 180 | a.setAttribute('href', url) 181 | a.setAttribute('download', this.filename) 182 | a.click() 183 | } 184 | this.action = save 185 | this.blockRemoval = true 186 | } 187 | } 188 | 189 | window.customElements.define('roll-call-uploader', Uploader) 190 | window.customElements.define('roll-call-downloader', Downloader) 191 | exports.Downloader = Downloader 192 | exports.Uploader = Uploader 193 | -------------------------------------------------------------------------------- /components/index.js: -------------------------------------------------------------------------------- 1 | exports.Visuals = require('./visuals') 2 | exports.Volume = require('./volume') 3 | exports.Peer = require('./peer') 4 | exports.Call = require('./call') 5 | -------------------------------------------------------------------------------- /components/peer.js: -------------------------------------------------------------------------------- 1 | /* globals FileReader */ 2 | const waudio = require('./waudio') 3 | const ZComponent = require('zcomponent') 4 | const Visuals = require('./visuals') 5 | const Volume = require('./volume') 6 | const znode = require('znode') 7 | const once = require('once') 8 | const dragDrop = require('drag-drop') 9 | const toBuffer = require('typedarray-to-buffer') 10 | const { Uploader, Downloader } = require('./files') 11 | 12 | const each = (arr, fn) => { 13 | return Array.from(arr).forEach(fn) 14 | } 15 | 16 | let totalPeers = 0 17 | 18 | const spliceBlob = blob => { 19 | let promises = [] 20 | let i = 0 21 | let csize = 50 * 1000 // chunk size 22 | while (i < blob.size) { 23 | ;((_blob) => { 24 | promises.push(new Promise((resolve, reject) => { 25 | let reader = new FileReader() 26 | reader.onload = () => resolve(toBuffer(reader.result)) 27 | reader.readAsArrayBuffer(_blob) 28 | })) 29 | })(blob.slice(i, i + csize)) 30 | i += csize 31 | } 32 | promises.push(null) 33 | return promises 34 | } 35 | 36 | class Peer extends ZComponent { 37 | constructor () { 38 | super() 39 | this.files = {} 40 | this.recordStreams = {} 41 | } 42 | async attach (peer, speakers) { 43 | this.id = `peer:${peer.publicKey}` 44 | peer.once('stream', async stream => { 45 | let audio = waudio(stream) 46 | audio.connect(speakers) 47 | 48 | this.audio = audio 49 | 50 | peer.meth.on('stream:znode', async stream => { 51 | this.rpc = await znode(stream, this.api) 52 | }) 53 | this.rpc = await znode(peer.meth.stream('znode'), this.api) 54 | }) 55 | 56 | let cleanup = once(() => { 57 | let cv = this.querySelector('canvas.roll-call-visuals') 58 | let ctx = cv.canvasCtx 59 | cv.disconnected = true 60 | ctx.fillStyle = 'red' 61 | ctx.font = '20px Courier' 62 | ctx.fillText('Disconnected.', 70, 30) 63 | this.disconnected = true 64 | 65 | let blockRemoval 66 | each(this.childNodes, node => { 67 | if (node.blockRemoval) blockRemoval = true 68 | }) 69 | 70 | if (this.recording) { 71 | // TODO: Add disconnected info. 72 | this.querySelector('roll-call-recorder-file').complete = true 73 | } else if (!blockRemoval) { 74 | this.parentNode.removeChild(this) 75 | } 76 | }) 77 | peer.on('error', cleanup) 78 | peer.on('close', cleanup) 79 | 80 | this.peer = peer 81 | 82 | let display = this.shadowRoot.querySelector('slot[name=peername]') 83 | totalPeers += 1 84 | display.textContent = display.textContent + ' ' + totalPeers 85 | } 86 | set audio (audio) { 87 | let visuals = new Visuals() 88 | visuals.audio = audio 89 | visuals.setAttribute('slot', 'visuals') 90 | this.appendChild(visuals) 91 | 92 | let volume = new Volume() 93 | volume.audio = audio 94 | volume.setAttribute('slot', 'volume') 95 | this.appendChild(volume) 96 | this._audio = audio 97 | } 98 | get audio () { 99 | return this._audio 100 | } 101 | set rpc (val) { 102 | // Fastest connection wins. 103 | if (this._rpc) return 104 | this._rpc = val 105 | this.onRPC(val) 106 | 107 | dragDrop(this, files => { 108 | this.serveFiles(files) 109 | }) 110 | } 111 | get rpc () { 112 | return this._rpc 113 | } 114 | get api () { 115 | return { 116 | setName: name => this.setName(name), 117 | record: recid => this.remoteRecord(recid), 118 | stop: recid => this.remoteStop(recid), 119 | read: filename => this.remoteRead(filename), 120 | offerFile: obj => this.onFileOffer(obj) 121 | } 122 | } 123 | serveFiles (files) { 124 | if (!this.rpc) return // this is the me element 125 | files.forEach(async f => { 126 | let filename = f.name 127 | let buffers = await spliceBlob(f) 128 | this.files[filename] = {buffers, closed: false} 129 | this.rpc.offerFile({filename, size: f.size, type: f.type}) 130 | 131 | let uploader = new Uploader() 132 | uploader.setAttribute('filename', filename) 133 | uploader.setAttribute('slot', 'recording') 134 | uploader.size = f.size 135 | uploader.fileSize = f.size 136 | uploader.contentType = f.type 137 | uploader.action = 'File Offered' 138 | this.appendChild(uploader) 139 | }) 140 | } 141 | onFileOffer (obj) { 142 | let downloader = new Downloader() 143 | downloader.filename = obj.filename 144 | downloader.setAttribute('filename', obj.filename) 145 | downloader.setAttribute('slot', 'recording') 146 | 147 | downloader.contentType = obj.type 148 | downloader.size = obj.size 149 | downloader.rpc = this.rpc 150 | 151 | this.appendChild(downloader) 152 | } 153 | async remoteRead (filename) { 154 | let sel = `roll-call-uploader[filename="${filename}"]` 155 | let chunk = await this.read(filename) 156 | let uploader = this.querySelector(sel) 157 | uploader.uploading = true 158 | uploader.progress(chunk) 159 | return chunk 160 | } 161 | remoteRecord (recid) { 162 | let uploader = new Uploader() 163 | uploader.setAttribute('filename', recid) 164 | uploader.setAttribute('slot', 'recording') 165 | 166 | uploader.contentType = 'audio/webm' 167 | uploader.action = 'Recording' 168 | this.appendChild(uploader) 169 | return this.record(recid) 170 | } 171 | remoteStop (recid) { 172 | return this.stop(recid) 173 | } 174 | async read (filename) { 175 | if (!this.files[filename]) throw new Error('No such file.') 176 | 177 | let f = this.files[filename] 178 | if (f.buffers.length) { 179 | return f.buffers.shift() 180 | } 181 | if (f.closed) { 182 | return null 183 | } 184 | return new Promise((resolve, reject) => { 185 | f.stream.once('data', () => { 186 | resolve(this.read(filename)) 187 | }) 188 | }) 189 | } 190 | async record (recid) { 191 | let input = this.parentNode.output 192 | let stream = input.record({video: false, audio: true}) 193 | 194 | this.recordStreams[recid] = stream 195 | let filename = recid 196 | this.files[filename] = {stream, buffers: [], closed: false} 197 | stream.on('data', chunk => this.files[filename].buffers.push(chunk)) 198 | let cleanup = once(() => { 199 | this.files[filename].closed = true 200 | stream.emit('data', null) 201 | }) 202 | stream.on('end', cleanup) 203 | stream.on('close', cleanup) 204 | stream.on('error', cleanup) 205 | return filename 206 | } 207 | async stop (recid) { 208 | return this.recordStreams[recid].stop() 209 | } 210 | get shadow () { 211 | return ` 212 | 231 |
232 | 233 |
234 |
235 | 236 |
237 |
238 | Peer 239 |
240 |
241 | 242 |
243 | ` 244 | } 245 | } 246 | 247 | window.customElements.define('roll-call-peer', Peer) 248 | 249 | module.exports = Peer 250 | -------------------------------------------------------------------------------- /components/record.js: -------------------------------------------------------------------------------- 1 | /* globals Blob, URL, JSZip */ 2 | const ZComponent = require('zcomponent') 3 | const once = require('once') 4 | const waudio = require('./waudio') 5 | const loadjs = require('load-js') 6 | 7 | const jszip = `https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.4/jszip.min.js` 8 | 9 | const zip = async (id) => { 10 | await loadjs([{async: true, url: jszip}]) 11 | var zip = new JSZip() 12 | 13 | // Generate a directory within the Zip file structure 14 | var folder = zip.folder(`rollcall-${id}`) 15 | 16 | // Add a file to the directory, in this case an image with data URI as contents 17 | let sel = 'roll-call-recorder-file' 18 | for (let n of document.querySelectorAll(sel)) { 19 | let arrayBuffers = await n.getArrayBuffers() 20 | let blob = new Blob(arrayBuffers, {type: n.contentType}) 21 | folder.file(n.filename, blob) 22 | } 23 | let opts = {type: 'audio/webm'} 24 | let blob = new Blob(files[`${id}-monitor.webm`], opts) 25 | folder.file(`${id}-monitor.webm`, blob) 26 | return zip.generateAsync({type: 'blob'}) 27 | } 28 | 29 | const values = obj => Object.keys(obj).map(k => obj[k]) 30 | const random = () => Math.random().toString(36).substring(7) 31 | 32 | const files = {} 33 | const write = async (filename, chunk) => { 34 | if (!files[filename]) files[filename] = [] 35 | files[filename].push(chunk) 36 | return true 37 | } 38 | 39 | const formatTime = ms => { 40 | if (ms > 1000 * 60) { 41 | ms = parseInt(ms / (1000 * 60)) 42 | ms += 'm' 43 | } else if (ms > 1000) { 44 | ms = parseInt(ms / 1000) 45 | ms += 's' 46 | } else { 47 | ms += 'ms' 48 | } 49 | return ms 50 | } 51 | 52 | class FileDownload extends ZComponent { 53 | async getArrayBuffers () { 54 | return files[this.filename] 55 | } 56 | set delay (ms) { 57 | let node = this.shadowRoot.querySelector('div.delay') 58 | node.textContent = formatTime(ms) 59 | } 60 | set recordTime (ms) { 61 | if (ms < 1000) return 62 | let node = this.shadowRoot.querySelector('div.total-time') 63 | node.textContent = formatTime(ms) 64 | } 65 | 66 | set bytesDownloaded (size) { 67 | let sel = 'div.bytesDownloaded' 68 | let bytesDownloaded = this.shadowRoot.querySelector(sel) 69 | if (!bytesDownloaded) return 70 | if (size > (1000000)) { 71 | size = (size / 1000000).toFixed(2) 72 | size += 'm' 73 | } if (size > 1000) { 74 | size = parseInt(size / 1000) 75 | size += 'k' 76 | } else { 77 | size += 'b' 78 | } 79 | bytesDownloaded.textContent = size 80 | } 81 | set complete (_bool) { 82 | if (!_bool || _bool === 'false') return 83 | this._complete = true 84 | this.style.color = 'blue' 85 | this.style.cursor = 'pointer' 86 | this.onclick = async () => { 87 | let arrayBuffers = await this.getArrayBuffers() 88 | let blob = new Blob(arrayBuffers, {type: this.contentType}) 89 | let url = URL.createObjectURL(blob) 90 | let a = document.createElement('a') 91 | a.setAttribute('href', url) 92 | a.setAttribute('download', this.filename) 93 | a.click() 94 | } 95 | } 96 | get complete () { 97 | return this._complete 98 | } 99 | get shadow () { 100 | return ` 101 | 124 |
125 |
126 | 💽 128 |
129 |
130 |
0s
131 |
0b
132 |
133 | ` 134 | } 135 | } 136 | 137 | // class Recording extends ZComponent { 138 | 139 | // } 140 | 141 | class Recorder extends ZComponent { 142 | constructor () { 143 | super() 144 | this.peers = {} 145 | } 146 | set swarm (swarm) { 147 | let call = this.parentNode 148 | call.onAddedNode = child => { 149 | if (child.tagName !== 'ROLL-CALL-PEER') return 150 | this.onPeer(child) 151 | } 152 | let button = this.shadowRoot.querySelector('div.record-button') 153 | button.onclick = () => { 154 | this.start() 155 | button.textContent = 'Stop Recording' 156 | button.onclick = () => { 157 | this.stop() 158 | button.textContent = 'Downloading...' 159 | } 160 | } 161 | } 162 | onPeer (node) { 163 | if (node.peer) { 164 | let key = node.peer.publicKey 165 | node.onRPC = rpc => { 166 | this.peers[key] = node 167 | if (this.recording) { 168 | this.recordPeer(rpc, node) 169 | } 170 | } 171 | let cleanup = once(() => { 172 | // if (this.recording) return 173 | delete this.peers[key] 174 | }) 175 | node.peer.on('end', cleanup) 176 | node.peer.on('close', cleanup) 177 | node.peer.on('error', cleanup) 178 | } 179 | } 180 | async recordPeer (rpc, peerNode) { 181 | let filename = await rpc.record(this.recording) 182 | rpc._recfile = filename 183 | // TODO: ping/pong to remove half of RTT from delay. 184 | let delay = Date.now() - this.recordStart 185 | let _filename = `${delay}-${filename}.webm` 186 | this.files.push(_filename) 187 | 188 | let fileElement = new FileDownload() 189 | fileElement.starttime = Date.now() 190 | fileElement.filename = _filename 191 | fileElement.delay = delay 192 | fileElement.setAttribute('slot', 'recording') 193 | peerNode.appendChild(fileElement) 194 | peerNode.recording = this.recording 195 | 196 | let chunk = true 197 | let length = 0 198 | while (chunk) { 199 | chunk = await rpc.read(filename) 200 | fileElement.recordTime = Date.now() - fileElement.starttime 201 | await write(_filename, chunk) 202 | fileElement.bytesDownloaded = length 203 | if (chunk) length += chunk.length 204 | } 205 | fileElement.complete = true 206 | } 207 | start () { 208 | this.recording = random() 209 | this.files = [] 210 | this.recordStart = Date.now() 211 | 212 | values(this.peers).forEach(peer => { 213 | // TODO: create element for recording download and pass to record peer 214 | this.recordPeer(peer.rpc, peer) 215 | }) 216 | 217 | let me = this.parentNode.me 218 | let rpc = {read: f => me.read(f), record: recid => me.record(recid)} 219 | this.recordPeer(rpc, document.getElementById('peer:me')) 220 | 221 | let f = this.recording + '-monitor.webm' 222 | this.files.push(f) 223 | let audio = waudio() 224 | files[f] = [] 225 | me.parentNode.speakers.connect(audio) 226 | me.audio.connect(audio) 227 | this.monitor = audio.record({video: false, audio: true}) 228 | this.monitor.on('data', chunk => files[f].push(chunk)) 229 | } 230 | async stop () { 231 | this.monitor.stop() 232 | clearInterval(this.interval) 233 | let recid = this.recording 234 | // let starttime = this.recordStart 235 | // let files = this.files 236 | delete this.recording 237 | delete this.recordStart 238 | delete this.files 239 | let promises = values(this.peers) 240 | .filter(p => p.rpc._recfile) 241 | .map(p => p.rpc.stop(recid)) 242 | await Promise.all(promises) 243 | this.parentNode.me.stop(recid) 244 | this.onEnd(recid) 245 | } 246 | onEnd (recid) { 247 | let button = this.shadowRoot.querySelector('div.record-button') 248 | button.textContent = 'Download Zip' 249 | button.onclick = async () => { 250 | let blob = await zip(recid) 251 | let url = URL.createObjectURL(blob) 252 | let a = document.createElement('a') 253 | a.setAttribute('href', url) 254 | a.setAttribute('download', `rollcall-${recid}.zip`) 255 | a.click() 256 | } 257 | } 258 | get shadow () { 259 | return ` 260 | 276 |
277 |
278 | Start Recording 279 |
280 | 281 |
282 | ` 283 | } 284 | } 285 | 286 | window.customElements.define('roll-call-recorder', Recorder) 287 | window.customElements.define('roll-call-recorder-file', FileDownload) 288 | 289 | module.exports = Recorder 290 | -------------------------------------------------------------------------------- /components/visuals.js: -------------------------------------------------------------------------------- 1 | /* global requestAnimationFrame */ 2 | const ZComponent = require('zcomponent') 3 | 4 | const each = (arr, fn) => { 5 | return Array.from(arr).forEach(fn) 6 | } 7 | 8 | let looping 9 | 10 | const startLoop = () => { 11 | if (looping) return 12 | 13 | let lastTime = Date.now() 14 | let selector = 'canvas.roll-call-visuals' 15 | 16 | function draw () { 17 | requestAnimationFrame(draw) 18 | let now = Date.now() 19 | if (now - lastTime < 50) return 20 | 21 | each(document.querySelectorAll(selector), drawPerson) 22 | 23 | function drawPerson (canvas) { 24 | if (canvas.disconnected) return 25 | let WIDTH = canvas.width 26 | let HEIGHT = canvas.height 27 | let canvasCtx = canvas.canvasCtx 28 | let analyser = canvas.analyser 29 | let bufferLength = analyser._bufferLength 30 | 31 | let dataArray = new Uint8Array(bufferLength) 32 | 33 | analyser.getByteFrequencyData(dataArray) 34 | 35 | canvasCtx.clearRect(0, 0, WIDTH, HEIGHT) 36 | let barWidth = (WIDTH / bufferLength) * 5 37 | let barHeight 38 | let x = 0 39 | let total = 0 40 | for (var i = 0; i < bufferLength; i++) { 41 | barHeight = dataArray[i] / 3 42 | if (barHeight > 10) { 43 | canvasCtx.fillStyle = 'rgb(66,133,244)' 44 | canvasCtx.fillRect(x, HEIGHT - barHeight / 2, barWidth, barHeight) 45 | } 46 | x += barWidth + 1 47 | total += barHeight 48 | } 49 | lastTime = now 50 | window.lastTotal = total 51 | } 52 | } 53 | draw() 54 | looping = true 55 | } 56 | 57 | class Visuals extends ZComponent { 58 | set audio (audio) { 59 | let canvas = document.createElement('canvas') 60 | canvas.height = 49 61 | canvas.width = 290 62 | let analyser = audio.context.createAnalyser() 63 | 64 | audio.connect(analyser) 65 | 66 | canvas.canvasCtx = canvas.getContext('2d') 67 | analyser.fftSize = 256 68 | analyser._bufferLength = analyser.frequencyBinCount 69 | canvas.canvasCtx.clearRect(0, 0, canvas.width, canvas.height) 70 | canvas.analyser = analyser 71 | canvas.classList.add('roll-call-visuals') 72 | this.appendChild(canvas) 73 | startLoop() 74 | } 75 | get shadow () { 76 | return ` 77 | 88 | 89 | ` 90 | } 91 | } 92 | 93 | window.customElements.define('roll-call-visuals', Visuals) 94 | 95 | module.exports = Visuals 96 | -------------------------------------------------------------------------------- /components/volume.js: -------------------------------------------------------------------------------- 1 | const ZComponent = require('zcomponent') 2 | const bel = require('bel') 3 | 4 | const micMoji = () => bel([ 5 | `🎙️` 6 | ]) 7 | 8 | const muteMoji = () => bel([ 9 | `🔇` 10 | ]) 11 | 12 | class Volume extends ZComponent { 13 | set audio (audio) { 14 | let elem = this.shadowRoot 15 | 16 | /* Wire up Mute Button */ 17 | const muteButton = elem.querySelector('div.container div.mute-button') 18 | const mute = () => { 19 | audio.mute() 20 | muteButton.innerHTML = '' 21 | muteButton.appendChild(muteMoji()) 22 | muteButton.onclick = unmute 23 | elem.querySelector('input[type=range]').disabled = true 24 | } 25 | const unmute = () => { 26 | audio.unmute() 27 | muteButton.innerHTML = '' 28 | muteButton.appendChild(micMoji()) 29 | muteButton.onclick = mute 30 | elem.querySelector('input[type=range]').disabled = false 31 | } 32 | muteButton.onclick = mute 33 | muteButton.appendChild(micMoji()) 34 | 35 | /* Wire up Volume Slider */ 36 | const slider = elem.querySelector(`input[type="range"]`) 37 | slider.oninput = () => { 38 | let volume = parseFloat(slider.value) 39 | audio.volume(volume) 40 | } 41 | } 42 | get shadow () { 43 | return ` 44 | 126 |
127 |
128 |
129 |
130 | 131 |
132 |
133 | ` 134 | } 135 | } 136 | 137 | window.customElements.define('roll-call-volume', Volume) 138 | 139 | module.exports = Volume 140 | -------------------------------------------------------------------------------- /components/waudio.js: -------------------------------------------------------------------------------- 1 | /* globals AudioContext */ 2 | const context = new AudioContext() 3 | module.exports = require('waudio')(context) 4 | -------------------------------------------------------------------------------- /dist.js: -------------------------------------------------------------------------------- 1 | /* globals CustomEvent */ 2 | const loadjs = require('load-js') 3 | 4 | window.addEventListener('WebComponentsReady', () => { 5 | let event = new CustomEvent('RollCallReady', require('./components')) 6 | window.dispatchEvent(event) 7 | }) 8 | const polyfill = 'https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.12/webcomponents-loader.js' 9 | loadjs([{async: true, url: polyfill}]) 10 | -------------------------------------------------------------------------------- /faq.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Roll Call 7 | 8 | 14 | 15 | 16 | 17 | 18 | 38 | 39 | 40 |

41 | Roll Call is a completely free🎉 42 | voice chat service with podcast quality recording. 43 |

44 |

FAQ (Frequently Asked Questions)

45 |

46 | This page answers common questions and explains how 47 | to use all the "hidden" features in Roll Call. 48 |

49 | 53 |

Basic calling.

54 |

You can click the start link on the front page, or just click 55 | this to start a new call. 56 |

57 | 63 | 64 |

65 | You can invite other people to the call simply by sharing 66 | the URL with anyone you'd like to have join. You can also modify the URL's room paramater to create something more permanent 67 | that you and your friend can continue to return to. 68 |

69 | 70 | 71 |

What does "podcast quality recording" mean?

72 |

73 | You're probably used to hearing short delays 74 | and clipping as the audio adjusts to bandwidth conditions 75 | when using online calling tools. 76 | When you record a call on Roll Call it does not record 77 | the audio you're hearing, which is optimized for latency. 78 |

79 |

80 | Roll Call records every participant locally and 81 | sends that audio to the person who intiated the recording. 82 | You'll notice when you initiate a recording that each the 83 | element for each participant shows the status of their 84 | recording. That includes the data transfered in real time. 85 |

86 |

87 | Browsers are limited in the formats and bitrates they can record. 88 | But the largest loss in quality during recording comes from the 89 | tradeoffs of maintaining a realtime call, which we avoid. 90 |

91 |

How is this free?

92 |

93 | Roll Call uses WebRTC, a peer-to-peer realtime standard for 94 | browsers. This means that the infrastructure for the calls is 95 | minimal and only used for signaling. The bulk of what would 96 | normally cost money, like hosting fees, are unnecessary since 97 | it's peer-to-peer, so this project remains free and entirely Open Source. 98 |

99 |

100 | However, you can support the development of this project on 101 | Patreon. 102 | This helps 103 | sustain the project and prioritize it over other open source 104 | work. 105 |

106 |
107 |
108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeal/roll-call/b9aab1446cfa2ef617656d2a759609f91d8a4604/favicon.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Roll Call 7 | 8 | 24 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* globals URL */ 2 | 3 | const container = document.body 4 | const dragDrop = require('drag-drop') 5 | 6 | if (!window.AudioContext && window.webkitAudioContext) { 7 | window.AudioContext = window.webkitAudioContext 8 | } 9 | 10 | const getChromeVersion = (force) => { 11 | if (force) return true 12 | var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./) 13 | return raw ? parseInt(raw[2], 10) > 59 : false 14 | } 15 | 16 | const each = (arr, fn) => Array.from(arr).forEach(fn) 17 | 18 | const random = () => Math.random().toString(36).substring(7) 19 | 20 | const welcome = 21 | ` 22 | 41 | 42 |

43 | Roll Call is a completely free🎉 44 | voice chat service with podcast quality recording. 45 |
46 |
47 | 48 | Start a new call to try it out. 49 | 50 |
51 |
52 | Support this project on Patreon. 53 |

54 |
55 | ` 56 | 57 | const onlyChrome = ` 58 | Roll Call only works in latest Chrome Browser :( 59 | ` 60 | 61 | const help = ` 62 | help -> 63 | ` 64 | window.addEventListener('WebComponentsReady', () => { 65 | let url = new URL(window.location) 66 | let room = url.searchParams.get('room') 67 | let force = url.searchParams.get('force') 68 | if (window.location.search && getChromeVersion(force) && room) { 69 | require('./components') 70 | container.innerHTML = `${help}` 71 | dragDrop('body', files => { 72 | document.querySelector('roll-call').serveFiles(files) 73 | }) 74 | } else { 75 | container.innerHTML = welcome 76 | if (!getChromeVersion(force)) { 77 | document.querySelector('span.start-text').innerHTML = onlyChrome 78 | } 79 | each(document.querySelectorAll('welcome-message span'), elem => { 80 | elem.onclick = () => { 81 | let room 82 | if (elem.id === 'start-party') room = 'party' 83 | else room = random() 84 | 85 | window.location = window.location.pathname + '?room=' + room 86 | } 87 | }) 88 | } 89 | }) 90 | -------------------------------------------------------------------------------- /lib/getRTCConfig.js: -------------------------------------------------------------------------------- 1 | const xhr = require('xhr') 2 | 3 | function getRtcConfig (cb) { 4 | xhr({ 5 | url: 'https://instant.io/_rtcConfig', 6 | timeout: 10000 7 | }, (err, res) => { 8 | if (err || res.statusCode !== 200) { 9 | cb(new Error('Could not get WebRTC config from server. Using default (without TURN).')) 10 | } else { 11 | var rtcConfig 12 | try { 13 | rtcConfig = JSON.parse(res.body) 14 | } catch (err) { 15 | return cb(new Error('Got invalid WebRTC config from server: ' + res.body)) 16 | } 17 | cb(null, rtcConfig) 18 | } 19 | }) 20 | } 21 | 22 | module.exports = getRtcConfig 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roll-call", 3 | "version": "0.0.0-development", 4 | "description": "", 5 | "main": "components/index.js", 6 | "scripts": { 7 | "start": "budo index.js:bundle.js", 8 | "build": "browserify index.js > bundle.js", 9 | "commit": "git-cz", 10 | "test": "standard", 11 | "prepublish": "mkdir -p dist && browserify dist.js > dist/rollcall.js && cat dist/rollcall.js | minify > dist/rollcall.min.js", 12 | "precommit": "npm test", 13 | "prepush": "npm test", 14 | "commitmsg": "validate-commit-msg", 15 | "semantic-release": "semantic-release pre && npm publish && semantic-release post" 16 | }, 17 | "standard": { 18 | "ignore": [ 19 | "dist" 20 | ] 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/mikeal/roll-call.git" 25 | }, 26 | "keywords": [], 27 | "author": "Mikeal Rogers (http://www.mikealrogers.com)", 28 | "license": "Apache-2.0", 29 | "bugs": { 30 | "url": "https://github.com/mikeal/roll-call/issues" 31 | }, 32 | "homepage": "https://github.com/mikeal/roll-call#readme", 33 | "devDependencies": { 34 | "babel-minify": "^0.2.0", 35 | "browserify": "^14.4.0", 36 | "budo": "^10.0.4", 37 | "husky": "^0.14.3", 38 | "standard": "^10.0.3", 39 | "semantic-release": "^8.0.3" 40 | }, 41 | "dependencies": { 42 | "bel": "^5.1.2", 43 | "drag-drop": "^2.13.2", 44 | "get-user-media-promise": "^1.1.1", 45 | "getusermedia": "^2.0.1", 46 | "killa-beez": "^3.3.0", 47 | "load-js": "^2.0.0", 48 | "once": "^1.4.0", 49 | "typedarray-to-buffer": "^3.1.2", 50 | "waudio": "^2.7.0", 51 | "xhr": "^2.4.0", 52 | "zcomponent": "^1.0.4", 53 | "znode": "^1.1.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # based on: 4 | # - https://gist.github.com/domenic/ec8b0fc8ab45f39403dd 5 | # - http://www.steveklabnik.com/automatically_update_github_pages_with_travis_example/ 6 | 7 | set -o errexit -o nounset 8 | 9 | if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then 10 | echo "Skipping deploy: pull request." 11 | exit 1 12 | fi 13 | 14 | if [ "$TRAVIS_BRANCH" != "master" -a "$TRAVIS_BRANCH" != "stable" ]; then 15 | echo "Skipping deploy: branch not master or stable." 16 | exit 1 17 | fi 18 | 19 | copy_assets () { cp -r favicon.png index.html bundle.js worker.js faq.html $1; } 20 | 21 | 22 | mkdir ../build 23 | mkdir ../build/staging 24 | 25 | git fetch origin master:remotes/origin/master stable:remotes/origin/stable 26 | 27 | git checkout -f origin/master 28 | MASTER_REV=$(git rev-parse --short HEAD) 29 | rm -rf node_modules package-lock.json 30 | npm install 31 | npm run build 32 | copy_assets ../build/staging 33 | 34 | git checkout -f origin/stable 35 | STABLE_REV=$(git rev-parse --short HEAD) 36 | rm -rf node_modules package-lock.json 37 | npm install 38 | npm run build 39 | copy_assets ../build 40 | 41 | cd ../build 42 | echo "rollcall.audio" > CNAME 43 | 44 | git init 45 | git config user.name "CI" 46 | git config user.email "ci@rollcall.audio" 47 | 48 | git add -A . 49 | git commit -m "Auto-build of ${MASTER_REV} (master), ${STABLE_REV} (stable)" 50 | git push -f "https://${GH_TOKEN}@${GH_REF}" HEAD:gh-pages > /dev/null 2>&1 51 | 52 | echo "✔ Deployed successfully." 53 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | /* global self, URL, postMessage */ 2 | self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.3/jszip.min.js') 3 | 4 | self.onmessage = (e) => { 5 | const data = e.data || {} 6 | if (e.data.type === 'compress') { 7 | compress(data) 8 | } 9 | } 10 | 11 | function compress (data) { 12 | const zip = new self.JSZip() 13 | const folder = zip.folder(`${data.room}-tracks`) 14 | 15 | data.files.forEach((file) => { 16 | folder.file(file.name, file.file) 17 | }) 18 | 19 | zip.generateAsync({ 20 | type: 'blob' 21 | }).then(blob => { 22 | postMessage({ 23 | type: 'compressed', 24 | name: `${data.room}.zip`, 25 | url: URL.createObjectURL(blob) 26 | }) 27 | }).catch((error) => handleErrors(error, 'Could not ZIP audio files')) 28 | } 29 | 30 | function handleErrors (error, message) { 31 | postMessage({ 32 | type: 'error', 33 | message, 34 | error 35 | }) 36 | } 37 | --------------------------------------------------------------------------------