diff --git a/README.md b/README.md index db2833e..811593d 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,18 @@ -**If you do not have specific requirements, please consider using the `webrtc` version instead: https://github.com/Johni0702/mumble-web/tree/webrtc (note that setup instructions differ significantly). -It should be near identical in features but less susceptible to performance issues. If you are having trouble with the `webrtc` version, please let us know.** - -PRs, unless webrtc-specific, should still target `master`. - # mumble-web mumble-web is an HTML5 [Mumble] client for use in modern browsers. -A live demo is running [here](https://voice.johni0702.de/?address=voice.johni0702.de&port=443/demo). +A live demo is running [here](https://voice.johni0702.de/?address=voice.johni0702.de&port=443/demo) (or [without WebRTC](https://voice.johni0702.de/?address=voice.johni0702.de&port=443/demo&webrtc=false)). The Mumble protocol uses TCP for control and UDP for voice. Running in a browser, both are unavailable to this client. -Instead Websockets are used for all communications. +Instead Websockets are used for control and WebRTC is used for voice (using Websockets as fallback if the server does not support WebRTC). -libopus, libcelt (0.7.1) and libsamplerate, compiled to JS via emscripten, are used for audio decoding. -Therefore, at the moment only the Opus and CELT Alpha codecs are supported. +In WebRTC mode (default) only the Opus codec is supported. + +In fallback mode, when WebRTC is not supported by the server, only the Opus and CELT Alpha codecs are supported. +This is accomplished with libopus, libcelt (0.7.1) and libsamplerate, compiled to JS via emscripten. +Performance is expected to be less reliable (especially on low-end devices) than in WebRTC mode and loading time will be significantly increased. Quite a few features, most noticeably all administrative functionallity, are still missing. @@ -23,7 +21,7 @@ administrative functionallity, are still missing. #### Download mumble-web can either be installed directly from npm with `npm install -g mumble-web` -or from git: +or from git (recommended because the npm version may be out of date): ``` git clone https://github.com/johni0702/mumble-web @@ -38,34 +36,14 @@ to e.g. customize the theme before building it. Either way you will end up with a `dist` folder that contains the static page. #### Setup -At the time of writing this there seems to be only one Mumble server (which is [grumble](https://github.com/mumble-voip/grumble)) -that natively support Websockets. To use this client with any other standard mumble -server, websockify must be set up (preferably on the same machine that the -Mumble server is running on). +At the time of writing this there do not seem to be any Mumble servers which natively support Websockets+WebRTC. +[Grumble](https://github.com/mumble-voip/grumble) natively supports Websockets and can run mumble-web in fallback mode but not (on its own) in WebRTC mode. +To use this client with any standard mumble server in WebRTC mode, [mumble-web-proxy] must be set up (preferably on the same machine that the Mumble server is running on). -You can install websockify via your package manager `apt install websockify` or -manually from the [websockify GitHub page]. Note that while some versions might -function better than others, the python version generally seems to be the best. +Additionally you will need some web server to serve static files and terminate the secure websocket connection (mumble-web-proxy only supports insecure ones). -There are two basic ways you can use websockify with mumble-web: -- Standalone, use websockify for both, websockets and serving static files -- Proxied, let your favorite web server serve static files and proxy websocket connections to websockify - -##### Standalone -This is the simplest but at the same time least flexible configuration. Replace `` with the URI of your mumble server. If `websockify` is running on the same machine as `mumble-server`, use `localhost`. -``` -websockify --cert=mycert.crt --key=mykey.key --ssl-only --ssl-target --web=path/to/dist 443 :64738 -``` - -##### Proxied -This configuration allows you to run websockify on a machine that already has -another webserver running. Replace `` with the URI of your mumble server. If `websockify` is running on the same machine as `mumble-server`, use `localhost`. - -``` -websockify --ssl-target 64737 :64738 -``` - -Here are two web server configuration files (one for [NGINX](https://www.nginx.com/) and one for [Caddy server](https://caddyserver.com/)) which will serve the mumble-web interface at `https://voice.example.com` and allow the websocket to connect at `wss://voice.example.com/demo` (similar to the demo server). Replace `` with the URI to the machine where `websockify` is running. If `websockify` is running on the same machine as your web server, use `localhost`. +Here are two web server configuration files (one for [NGINX](https://www.nginx.com/) and one for [Caddy server](https://caddyserver.com/)) which will serve the mumble-web interface at `https://voice.example.com` and allow the websocket to connect at `wss://voice.example.com/demo` (similar to the demo server). +Replace `` with the host name of the machine where `mumble-web-proxy` is running. If `mumble-web-proxy` is running on the same machine as your web server, use `localhost`. * NGINX configuration file ```Nginx @@ -79,7 +57,7 @@ server { root /path/to/dist; } location /demo { - proxy_pass http://:64737; + proxy_pass http://:64737; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; @@ -101,12 +79,19 @@ http://voice.example.com { https://voice.example.com { tls "/etc/letsencrypt/live/voice.example.com/fullchain.pem" "/etc/letsencrypt/live/voice.example.com/privkey.pem" root /path/to/dist - proxy /demo http://:64737 { + proxy /demo http://:64737 { websocket } } ``` +To run `mumble-web-proxy`, execute the following command. Replace `` with the host name of your Mumble server (the one you connect to using the normal Mumble client). +Note that even if your Mumble server is running on the same machine as your `mumble-web-proxy`, you should use the external name because (by default, for disabling see its README) `mumble-web-proxy` will try to verify the certificate provided by the Mumble server and fail if it does not match the given host name. +``` +mumble-web-proxy --listen-ws 64737 --server :64738 +``` +If your mumble-web-proxy is running behind a NAT or firewall, take note of the respective section in its README. + Make sure that your Mumble server is running. You may now open `https://voice.example.com` in a web browser. You will be prompted for server details: choose either `address: voice.example.com/demo` with `port: 443` or `address: voice.example.com` with `port: 443/demo`. You may prefill these values by appending `?address=voice.example.com/demo&port=443`. Choose a username, and click `Connect`: you should now be able to talk and use the chat. Here is an example of systemd service, put it in `/etc/systemd/system/mumble-web.service` and adapt it to your needs: @@ -180,6 +165,6 @@ See [here](https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFR ISC [Mumble]: https://wiki.mumble.info/wiki/Main_Page -[websockify GitHub page]: https://github.com/novnc/websockify +[mumble-web-proxy]: https://github.com/johni0702/mumble-web-proxy [MetroMumble]: https://github.com/xPoke/MetroMumble [Matrix]: https://matrix.org diff --git a/app/config.js b/app/config.js index 10941c1..32b057f 100644 --- a/app/config.js +++ b/app/config.js @@ -32,6 +32,7 @@ window.mumbleWebConfig = { 'token': '', 'username': '', 'password': '', + 'webrtc': 'auto', // whether to enable (true), disable (false) or auto-detect ('auto') WebRTC support 'joinDialog': false, // replace whole dialog with single "Join Conference" button 'matrix': false, // enable Matrix Widget support (mostly auto-detected; implies 'joinDialog') 'avatarurl': '', // download and set the user's Mumble avatar to the image at this URL diff --git a/app/index.js b/app/index.js index dfb3e23..7ff151e 100644 --- a/app/index.js +++ b/app/index.js @@ -5,6 +5,7 @@ import ByteBuffer from 'bytebuffer' import MumbleClient from 'mumble-client' import WorkerBasedMumbleConnector from './worker-client' import BufferQueueNode from 'web-audio-buffer-queue' +import mumbleConnect from 'mumble-client-websocket' import audioContext from 'audio-context' import ko from 'knockout' import _dompurify from 'dompurify' @@ -118,6 +119,9 @@ function ConnectDialog () { self.hide = self.visible.bind(self.visible, false) self.connect = function () { self.hide() + if (ui.detectWebRTC) { + ui.webrtc = true + } ui.connect(self.username(), self.address(), self.port(), self.tokens(), self.password(), self.channelName()) } @@ -336,7 +340,10 @@ class GlobalBindings { constructor (config) { this.config = config this.settings = new Settings(config.settings) - this.connector = new WorkerBasedMumbleConnector() + this.detectWebRTC = true + this.webrtc = true + this.fallbackConnector = new WorkerBasedMumbleConnector() + this.webrtcConnector = { connect: mumbleConnect } this.client = null this.userContextMenu = new ContextMenu() this.channelContextMenu = new ContextMenu() @@ -449,12 +456,27 @@ class GlobalBindings { // Note: This call needs to be delayed until the user has interacted with // the page in some way (which at this point they have), see: https://goo.gl/7K7WLu - this.connector.setSampleRate(audioContext().sampleRate) + let ctx = audioContext() + this.fallbackConnector.setSampleRate(ctx.sampleRate) + if (!this._delayedMicNode) { + this._micNode = ctx.createMediaStreamSource(this._micStream) + this._delayNode = ctx.createDelay() + this._delayNode.delayTime.value = 0.15 + this._delayedMicNode = ctx.createMediaStreamDestination() + } // TODO: token - this.connector.connect(`wss://${host}:${port}`, { + (this.webrtc ? this.webrtcConnector : this.fallbackConnector).connect(`wss://${host}:${port}`, { username: username, password: password, + webrtc: this.webrtc ? { + enabled: true, + required: true, + mic: this._delayedMicNode.stream, + audioContext: ctx + } : { + enabled: false, + }, tokens: tokens }).done(client => { log(translate('logentry.connected')) @@ -535,6 +557,10 @@ class GlobalBindings { this.connectErrorDialog.type(err.type) this.connectErrorDialog.reason(err.reason) this.connectErrorDialog.show() + } else if (err === 'server_does_not_support_webrtc' && this.detectWebRTC && this.webrtc) { + log(translate('logentry.connection_fallback_mode')) + this.webrtc = false + this.connect(username, host, port, tokens, password, channelName) } else { log(translate('logentry.connection_error'), err) } @@ -686,24 +712,32 @@ class GlobalBindings { } }).on('voice', stream => { console.log(`User ${user.username} started takling`) - var userNode = new BufferQueueNode({ - audioContext: audioContext() - }) - userNode.connect(audioContext().destination) - + let userNode + if (!this.webrtc) { + userNode = new BufferQueueNode({ + audioContext: audioContext() + }) + userNode.connect(audioContext().destination) + } + if (stream.target === 'normal') { + ui.talking('on') + } else if (stream.target === 'shout') { + ui.talking('shout') + } else if (stream.target === 'whisper') { + ui.talking('whisper') + } stream.on('data', data => { - if (data.target === 'normal') { - ui.talking('on') - } else if (data.target === 'shout') { - ui.talking('shout') - } else if (data.target === 'whisper') { - ui.talking('whisper') + if (this.webrtc) { + // mumble-client is in WebRTC mode, no pcm data should arrive this way + } else { + userNode.write(data.buffer) } - userNode.write(data.buffer) }).on('end', () => { console.log(`User ${user.username} stopped takling`) ui.talking('off') - userNode.end() + if (!this.webrtc) { + userNode.end() + } }) }) } @@ -825,6 +859,15 @@ class GlobalBindings { voiceHandler.setMute(true) } + this._micNode.disconnect() + this._delayNode.disconnect() + if (mode === 'vad') { + this._micNode.connect(this._delayNode) + this._delayNode.connect(this._delayedMicNode) + } else { + this._micNode.connect(this._delayedMicNode) + } + this.client.setAudioQuality( this.settings.audioBitrate, this.settings.samplesPerPacket @@ -1055,6 +1098,12 @@ function initializeUI () { if (queryParams.password) { ui.connectDialog.password(queryParams.password) } + if (queryParams.webrtc !== 'auto') { + ui.detectWebRTC = false + if (queryParams.webrtc == 'false') { + ui.webrtc = false + } + } if (queryParams.channelName) { ui.connectDialog.channelName(queryParams.channelName) } @@ -1251,23 +1300,26 @@ function translateEverything() { async function main() { await localizationInitialize(navigator.language); translateEverything(); - initializeUI(); - initVoice(data => { - if (testVoiceHandler) { - testVoiceHandler.write(data) - } - if (!ui.client) { - if (voiceHandler) { - voiceHandler.end() + try { + const userMedia = await initVoice(data => { + if (testVoiceHandler) { + testVoiceHandler.write(data) } - voiceHandler = null - } else if (voiceHandler) { - voiceHandler.write(data) - } - }, err => { - log(translate('logentry.mic_init_error'), err) - }) + if (!ui.client) { + if (voiceHandler) { + voiceHandler.end() + } + voiceHandler = null + } else if (voiceHandler) { + voiceHandler.write(data) + } + }) + ui._micStream = userMedia + } catch (err) { + window.alert('Failed to initialize user media\nRefresh page to retry.\n' + err) + return + } + initializeUI(); } window.onload = main - diff --git a/app/voice.js b/app/voice.js index 6b048de..22bad32 100644 --- a/app/voice.js +++ b/app/voice.js @@ -1,10 +1,10 @@ import { Writable } from 'stream' import MicrophoneStream from 'microphone-stream' import audioContext from 'audio-context' -import getUserMedia from 'getusermedia' import keyboardjs from 'keyboardjs' import vad from 'voice-activity-detection' import DropStream from 'drop-stream' +import { WorkerBasedMumbleClient } from './worker-client' class VoiceHandler extends Writable { constructor (client, settings) { @@ -33,8 +33,12 @@ class VoiceHandler extends Writable { return this._outbound } - // Note: the samplesPerPacket argument is handled in worker.js and not passed on - this._outbound = this._client.createVoiceStream(this._settings.samplesPerPacket) + if (this._client instanceof WorkerBasedMumbleClient) { + // Note: the samplesPerPacket argument is handled in worker.js and not passed on + this._outbound = this._client.createVoiceStream(this._settings.samplesPerPacket) + } else { + this._outbound = this._client.createVoiceStream() + } this.emit('started_talking') } @@ -160,16 +164,13 @@ export class VADVoiceHandler extends VoiceHandler { var theUserMedia = null -export function initVoice (onData, onUserMediaError) { - getUserMedia({ audio: true }, (err, userMedia) => { - if (err) { - onUserMediaError(err) - } else { - theUserMedia = userMedia - var micStream = new MicrophoneStream(userMedia, { objectMode: true, bufferSize: 1024 }) - micStream.on('data', data => { - onData(Buffer.from(data.getChannelData(0).buffer)) - }) - } +export function initVoice (onData) { + return window.navigator.mediaDevices.getUserMedia({ audio: true }).then((userMedia) => { + theUserMedia = userMedia + var micStream = new MicrophoneStream(userMedia, { objectMode: true, bufferSize: 1024 }) + micStream.on('data', data => { + onData(Buffer.from(data.getChannelData(0).buffer)) + }) + return userMedia }) } diff --git a/app/worker-client.js b/app/worker-client.js index c3ee400..5f52fad 100644 --- a/app/worker-client.js +++ b/app/worker-client.js @@ -125,7 +125,7 @@ class WorkerBasedMumbleConnector { } } -class WorkerBasedMumbleClient extends EventEmitter { +export class WorkerBasedMumbleClient extends EventEmitter { constructor (connector, clientId) { super() this._connector = connector @@ -342,11 +342,12 @@ class WorkerBasedMumbleUser extends EventEmitter { props ] } else if (name === 'voice') { - let [id] = args + let [id, target] = args let stream = new PassThrough({ objectMode: true }) this._connector._voiceStreams[id] = stream + stream.target = target args = [stream] } else if (name === 'remove') { delete this._client._users[this._id] diff --git a/app/worker.js b/app/worker.js index 41c0cf4..a1dbc81 100644 --- a/app/worker.js +++ b/app/worker.js @@ -164,7 +164,7 @@ import 'subworkers' }) }) - return [voiceId] + return [voiceId, stream.target] }) registerEventProxy(id, user, 'remove') diff --git a/loc/en.json b/loc/en.json index 60a1157..c493403 100644 --- a/loc/en.json +++ b/loc/en.json @@ -79,6 +79,7 @@ "connecting": "Connecting to server", "connected": "Connected!", "connection_error": "Connection error:", + "connection_fallback_mode": "Server does not support WebRTC, re-trying in fallback mode..", "unknown_voice_mode": "Unknown voice mode:", "mic_init_error": "Cannot initialize user media. Microphone will not work:" }, diff --git a/package-lock.json b/package-lock.json index 9b65fb4..3b92154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5501,13 +5501,12 @@ "dev": true }, "mumble-client": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/mumble-client/-/mumble-client-1.3.0.tgz", - "integrity": "sha512-4z/Frp+XwTsE0u+7g6BUQbYumV17iEaMBCZ5Oo5lQ5Jjq3sBnZYRH9pXDX1bU4/3HFU99/AVGcScH2R67olPPQ==", + "version": "github:johni0702/mumble-client#f73a08bcb223c530326d44484a357380dfe3e6ee", + "from": "github:johni0702/mumble-client#f73a08b", "dev": true, "requires": { "drop-stream": "^0.1.1", - "mumble-streams": "0.0.4", + "mumble-streams": "github:johni0702/mumble-streams#47b84d1", "promise": "^7.1.1", "reduplexer": "^1.1.0", "remove-value": "^1.0.0", @@ -5565,20 +5564,17 @@ } }, "mumble-client-websocket": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mumble-client-websocket/-/mumble-client-websocket-1.0.0.tgz", - "integrity": "sha1-QFT8SJgnFYo6bP4iw0oYxRdnoL8=", + "version": "github:johni0702/mumble-client-websocket#5b0ed8dc2eaa904d21cd9d11ab7a19558f13701a", + "from": "github:johni0702/mumble-client-websocket#5b0ed8d", "dev": true, "requires": { - "mumble-client": "^1.0.0", "promise": "^7.1.1", "websocket-stream": "^3.2.1" } }, "mumble-streams": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/mumble-streams/-/mumble-streams-0.0.4.tgz", - "integrity": "sha1-p6H50Rx437bPQcT+2V4YnXhT40g=", + "version": "github:johni0702/mumble-streams#47b84d190ada23df1035f02735f70b6731f58fa2", + "from": "github:johni0702/mumble-streams#47b84d1", "dev": true, "requires": { "protobufjs": "^5.0.1" diff --git a/package.json b/package.json index fe11318..2e6dde8 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,9 @@ "libsamplerate.js": "^1.0.0", "lodash.assign": "^4.2.0", "microphone-stream": "^5.1.0", - "mumble-client": "^1.3.0", + "mumble-client": "github:johni0702/mumble-client#f73a08b", "mumble-client-codecs-browser": "^1.2.0", - "mumble-client-websocket": "^1.0.0", + "mumble-client-websocket": "github:johni0702/mumble-client-websocket#5b0ed8d", "node-sass": "^4.14.1", "patch-package": "^6.2.1", "raw-loader": "^4.0.2",