From ff5687c01ec3d93d4cdd4ed569a26250dfc41ef9 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Mon, 28 Sep 2015 12:37:09 -0700 Subject: [PATCH 1/8] GUAC-1354: Refactor Guacamole.AudioChannel to Guacamole.AudioPlayer. --- .../src/main/webapp/modules/AudioChannel.js | 291 ------------ .../src/main/webapp/modules/AudioPlayer.js | 418 ++++++++++++++++++ .../src/main/webapp/modules/Client.js | 88 ++-- 3 files changed, 465 insertions(+), 332 deletions(-) delete mode 100644 guacamole-common-js/src/main/webapp/modules/AudioChannel.js create mode 100644 guacamole-common-js/src/main/webapp/modules/AudioPlayer.js diff --git a/guacamole-common-js/src/main/webapp/modules/AudioChannel.js b/guacamole-common-js/src/main/webapp/modules/AudioChannel.js deleted file mode 100644 index 0f64c4d70..000000000 --- a/guacamole-common-js/src/main/webapp/modules/AudioChannel.js +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright (C) 2015 Glyptodon LLC - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -var Guacamole = Guacamole || {}; - -/** - * Abstract audio channel which queues and plays arbitrary audio data. - * - * @constructor - */ -Guacamole.AudioChannel = function AudioChannel() { - - /** - * Reference to this AudioChannel. - * - * @private - * @type Guacamole.AudioChannel - */ - var channel = this; - - /** - * The earliest possible time that the next packet could play without - * overlapping an already-playing packet, in milliseconds. - * - * @private - * @type Number - */ - var nextPacketTime = Guacamole.AudioChannel.getTimestamp(); - - /** - * The last time that sync() was called, in milliseconds. If sync() has - * never been called, this will be the time the Guacamole.AudioChannel - * was created. - * - * @type Number - */ - var lastSync = nextPacketTime; - - /** - * Notifies this Guacamole.AudioChannel that all audio up to the current - * point in time has been given via play(), and that any difference in time - * between queued audio packets and the current time can be considered - * latency. - */ - this.sync = function sync() { - - // Calculate elapsed time since last sync - var now = Guacamole.AudioChannel.getTimestamp(); - var elapsed = now - lastSync; - - // Reschedule future playback time such that playback latency is - // bounded within the duration of the last audio frame - nextPacketTime = Math.min(nextPacketTime, now + elapsed); - - // Record sync time - lastSync = now; - - }; - - /** - * Queues up the given data for playing by this channel once all previously - * queued data has been played. If no data has been queued, the data will - * play immediately. - * - * @param {String} mimetype - * The mimetype of the audio data provided. - * - * @param {Number} duration - * The duration of the data provided, in milliseconds. - * - * @param {Blob} data - * The blob of audio data to play. - */ - this.play = function play(mimetype, duration, data) { - - var packet = new Guacamole.AudioChannel.Packet(mimetype, data); - - // Determine exactly when packet CAN play - var packetTime = Guacamole.AudioChannel.getTimestamp(); - if (nextPacketTime < packetTime) - nextPacketTime = packetTime; - - // Schedule packet - packet.play(nextPacketTime); - - // Update timeline - nextPacketTime += duration; - - }; - -}; - -// Define context if available -if (window.AudioContext) { - try {Guacamole.AudioChannel.context = new AudioContext();} - catch (e){} -} - -// Fallback to Webkit-specific AudioContext implementation -else if (window.webkitAudioContext) { - try {Guacamole.AudioChannel.context = new webkitAudioContext();} - catch (e){} -} - -/** - * Returns a base timestamp which can be used for scheduling future audio - * playback. Scheduling playback for the value returned by this function plus - * N will cause the associated audio to be played back N milliseconds after - * the function is called. - * - * @return {Number} An arbitrary channel-relative timestamp, in milliseconds. - */ -Guacamole.AudioChannel.getTimestamp = function() { - - // If we have an audio context, use its timestamp - if (Guacamole.AudioChannel.context) - return Guacamole.AudioChannel.context.currentTime * 1000; - - // If we have high-resolution timers, use those - if (window.performance) { - - if (window.performance.now) - return window.performance.now(); - - if (window.performance.webkitNow) - return window.performance.webkitNow(); - - } - - // Fallback to millisecond-resolution system time - return new Date().getTime(); - -}; - -/** - * Abstract representation of an audio packet. - * - * @constructor - * - * @param {String} mimetype The mimetype of the data contained by this packet. - * @param {Blob} data The blob of sound data contained by this packet. - */ -Guacamole.AudioChannel.Packet = function(mimetype, data) { - - /** - * Schedules this packet for playback at the given time. - * - * @function - * @param {Number} when The time this packet should be played, in - * milliseconds. - */ - this.play = function(when) { /* NOP */ }; // Defined conditionally depending on support - - // If audio API available, use it. - if (Guacamole.AudioChannel.context) { - - var readyBuffer = null; - - // By default, when decoding finishes, store buffer for future - // playback - var handleReady = function(buffer) { - readyBuffer = buffer; - }; - - // Read data and start decoding - var reader = new FileReader(); - reader.onload = function() { - Guacamole.AudioChannel.context.decodeAudioData( - reader.result, - function(buffer) { handleReady(buffer); } - ); - }; - reader.readAsArrayBuffer(data); - - // Set up buffer source - var source = Guacamole.AudioChannel.context.createBufferSource(); - source.connect(Guacamole.AudioChannel.context.destination); - - // Use noteOn() instead of start() if necessary - if (!source.start) - source.start = source.noteOn; - - var play_when; - - function playDelayed(buffer) { - source.buffer = buffer; - source.start(play_when / 1000); - } - - /** @ignore */ - this.play = function(when) { - - play_when = when; - - // If buffer available, play it NOW - if (readyBuffer) - playDelayed(readyBuffer); - - // Otherwise, play when decoded - else - handleReady = playDelayed; - - }; - - } - - else { - - var play_on_load = false; - - // Create audio element to house and play the data - var audio = null; - try { audio = new Audio(); } - catch (e) {} - - if (audio) { - - // Read data and start decoding - var reader = new FileReader(); - reader.onload = function() { - - var binary = ""; - var bytes = new Uint8Array(reader.result); - - // Produce binary string from bytes in buffer - for (var i=0; i + * @type Object. */ - var audioChannels = {}; + var audioPlayers = {}; // No initial parsers var parsers = []; @@ -440,6 +440,25 @@ Guacamole.Client = function(tunnel) { */ this.onerror = null; + /** + * Fired when a audio stream is created. The stream provided to this event + * handler will contain its own event handlers for received data. + * + * @event + * @param {Guacamole.InputStream} stream + * The stream that will receive audio data from the server. + * + * @param {String} mimetype + * The mimetype of the audio data which will be received. + * + * @return {Guacamole.AudioPlayer} + * An object which implements the Guacamole.AudioPlayer interface and + * has been initialied to play the data in the provided stream, or null + * if the built-in audio players of the Guacamole client should be + * used. + */ + this.onaudio = null; + /** * Fired when the clipboard of the remote client is changing. * @@ -499,27 +518,6 @@ Guacamole.Client = function(tunnel) { */ this.onsync = null; - /** - * Returns the audio channel having the given index, creating a new channel - * if necessary. - * - * @param {Number} index - * The index of the audio channel to retrieve. - * - * @returns {Guacamole.AudioChannel} - * The audio channel having the given index. - */ - var getAudioChannel = function getAudioChannel(index) { - - // Get audio channel, creating it first if necessary - var audio_channel = audioChannels[index]; - if (!audio_channel) - audio_channel = audioChannels[index] = new Guacamole.AudioChannel(); - - return audio_channel; - - }; - /** * Returns the layer with the given index, creating it if necessary. * Positive indices refer to visible layers, an index of zero refers to @@ -626,24 +624,32 @@ Guacamole.Client = function(tunnel) { "audio": function(parameters) { var stream_index = parseInt(parameters[0]); - var channel = getAudioChannel(parseInt(parameters[1])); - var mimetype = parameters[2]; - var duration = parseFloat(parameters[3]); + var mimetype = parameters[1]; // Create stream var stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index); - // Assemble entire stream as a blob - var blob_reader = new Guacamole.BlobReader(stream, mimetype); + // Get player instance via callback + var audioPlayer = null; + if (guac_client.onaudio) + audioPlayer = guac_client.onaudio(stream, mimetype); - // Play blob as audio - blob_reader.onend = function() { - channel.play(mimetype, duration, blob_reader.getBlob()); - }; + // If unsuccessful, use a default implementation + if (!audioPlayer) { + if (Guacamole.RawAudioPlayer.isSupportedType(mimetype)) + audioPlayer = new Guacamole.RawAudioPlayer(stream, mimetype); + } - // Send success response - guac_client.sendAck(stream_index, "OK", 0x0000); + // If player somehow successfully retrieved, send success response + if (audioPlayer) { + audioPlayers[stream_index] = audioPlayer; + guac_client.sendAck(stream_index, "OK", 0x0000); + } + + // Otherwise, mimetype must be unsupported + else + guac_client.sendAck(stream_index, "BAD TYPE", 0x030F); }, @@ -1113,11 +1119,11 @@ Guacamole.Client = function(tunnel) { // Flush display, send sync when done display.flush(function displaySyncComplete() { - // Synchronize all audio channels - for (var index in audioChannels) { - var audioChannel = audioChannels[index]; - if (audioChannel) - audioChannel.sync(); + // Synchronize all audio players + for (var index in audioPlayers) { + var audioPlayer = audioPlayers[index]; + if (audioPlayer) + audioPlayer.sync(); } // Send sync response to server From df57eac616955478d172c4e81f126faef7fc8883 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Mon, 28 Sep 2015 13:23:40 -0700 Subject: [PATCH 2/8] GUAC-1354: Use past audio packet size to determine playback latency threshold for audio. Add missing private annotations. --- .../src/main/webapp/modules/AudioPlayer.js | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js index 5c0eeeb90..abda411d0 100644 --- a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js +++ b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js @@ -86,6 +86,7 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { /** * The format of audio this player will decode. * + * @private * @type Guacamole.RawAudioPlayer._Format */ var format = Guacamole.RawAudioPlayer._Format.parse(mimetype); @@ -94,6 +95,7 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { * An instance of a Web Audio API AudioContext object, or null if the * Web Audio API is not supported. * + * @private * @type AudioContext */ var context = (function getAudioContext() { @@ -122,6 +124,7 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { * N will cause the associated audio to be played back N milliseconds after * the function is called. * + * @private * @return {Number} * An arbitrary relative timestamp, in milliseconds. */ @@ -145,23 +148,25 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { */ var nextPacketTime = getTimestamp(); - /** - * The last time that sync() was called, in milliseconds. If sync() has - * never been called, this will be the time the Guacamole.AudioPlayer - * was created. - * - * @type Number - */ - var lastSync = nextPacketTime; - /** * Guacamole.ArrayBufferReader wrapped around the audio input stream * provided with this Guacamole.RawAudioPlayer was created. * + * @private * @type Guacamole.ArrayBufferReader */ var reader = new Guacamole.ArrayBufferReader(stream); + /** + * The maximum amount of latency to allow between the buffered data stream + * and the playback position, in milliseconds. Initially, this is set to + * roughly one third of a second, but it will be recalculated dynamically. + * + * @private + * @type Number + */ + var maxLatency = 300; + // Play each received raw packet of audio immediately reader.ondata = function playReceivedAudio(data) { @@ -171,6 +176,9 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { // Calculate overall duration (in milliseconds) var duration = samples * 1000 / format.rate; + // Recalculate latency threshold based on packet size + maxLatency = duration * 2; + // Determine exactly when packet CAN play var packetTime = getTimestamp(); if (nextPacketTime < packetTime) @@ -227,14 +235,10 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { // Calculate elapsed time since last sync var now = getTimestamp(); - var elapsed = now - lastSync; // Reschedule future playback time such that playback latency is - // bounded within the duration of the last audio frame - nextPacketTime = Math.min(nextPacketTime, now + elapsed); - - // Record sync time - lastSync = now; + // bounded within a reasonable latency threshold + nextPacketTime = Math.min(nextPacketTime, now + maxLatency); }; From f0e6da86c9b0268b18e224851b2f5da5eee1ef68 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Wed, 30 Sep 2015 17:02:18 -0700 Subject: [PATCH 3/8] GUAC-1354: Use Guacamole.AudioPlayer.getInstance(), etc. to abstract away the various implementations. --- .../src/main/webapp/modules/AudioPlayer.js | 64 +++++++++++++++++++ .../src/main/webapp/modules/Client.js | 10 ++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js index abda411d0..08bf4c82c 100644 --- a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js +++ b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js @@ -66,6 +66,70 @@ Guacamole.AudioPlayer.getTimestamp = function() { }; +/** + * Determines whether the given mimetype is supported by any built-in + * implementation of Guacamole.AudioPlayer, and thus will be properly handled + * by Guacamole.AudioPlayer.getInstance(). + * + * @param {String} mimetype + * The mimetype to check. + * + * @returns {Boolean} + * true if the given mimetype is supported by any built-in + * Guacamole.AudioPlayer, false otherwise. + */ +Guacamole.AudioPlayer.isSupportedType = function isSupportedType(mimetype) { + + return Guacamole.RawAudioPlayer.isSupportedType(mimetype); + +}; + +/** + * Returns a list of all mimetypes supported by any built-in + * Guacamole.AudioPlayer, in rough order of priority. Beware that only the core + * mimetypes themselves will be listed. Any mimetype parameters, even required + * ones, will not be included in the list. For example, "audio/L8" is a + * supported raw audio mimetype that is supported, but it is invalid without + * additional parameters. Something like "audio/L8;rate=44100" would be valid, + * however (see https://tools.ietf.org/html/rfc4856). + * + * @returns {String[]} + * A list of all mimetypes supported by any built-in Guacamole.AudioPlayer, + * excluding any parameters. + */ +Guacamole.AudioPlayer.getSupportedTypes = function getSupportedTypes() { + + return Guacamole.RawAudioPlayer.getSupportedTypes(); + +}; + +/** + * Returns an instance of Guacamole.AudioPlayer providing support for the given + * audio format. If support for the given audio format is not available, null + * is returned. + * + * @param {Guacamole.InputStream} stream + * The Guacamole.InputStream to read audio data from. + * + * @param {String} mimetype + * The mimetype of the audio data in the provided stream. + * + * @return {Guacamole.AudioPlayer} + * A Guacamole.AudioPlayer instance supporting the given mimetype and + * reading from the given stream, or null if support for the given mimetype + * is absent. + */ +Guacamole.AudioPlayer.getInstance = function getInstance(stream, mimetype) { + + // Use raw audio player if possible + if (Guacamole.RawAudioPlayer.isSupportedType(mimetype)) + return new Guacamole.RawAudioPlayer(stream, mimetype); + + // No support for given mimetype + return null; + +}; + /** * Implementation of Guacamole.AudioPlayer providing support for raw PCM format * audio. This player relies only on the Web Audio API and does not require any diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 8104159d4..894888738 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -635,13 +635,11 @@ Guacamole.Client = function(tunnel) { if (guac_client.onaudio) audioPlayer = guac_client.onaudio(stream, mimetype); - // If unsuccessful, use a default implementation - if (!audioPlayer) { - if (Guacamole.RawAudioPlayer.isSupportedType(mimetype)) - audioPlayer = new Guacamole.RawAudioPlayer(stream, mimetype); - } + // If unsuccessful, try to use a default implementation + if (!audioPlayer) + audioPlayer = Guacamole.AudioPlayer.getInstance(stream, mimetype); - // If player somehow successfully retrieved, send success response + // If we have successfully retrieved an audio player, send success response if (audioPlayer) { audioPlayers[stream_index] = audioPlayer; guac_client.sendAck(stream_index, "OK", 0x0000); From 572534c6d336d3e68bdb2a548d0854ac84499b8f Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Wed, 30 Sep 2015 17:06:23 -0700 Subject: [PATCH 4/8] GUAC-1354: Use Web Audio API timestamps directly - no need to convert to milliseconds and back. --- .../src/main/webapp/modules/AudioPlayer.js | 63 ++++--------------- 1 file changed, 11 insertions(+), 52 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js index 08bf4c82c..98a3592aa 100644 --- a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js +++ b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js @@ -44,28 +44,6 @@ Guacamole.AudioPlayer = function AudioPlayer() { }; -/** - * Returns a base timestamp which can be used for scheduling future audio - * playback. Scheduling playback for the value returned by this function plus - * N will cause the associated audio to be played back N milliseconds after - * the function is called. - * - * @return {Number} - * An arbitrary relative timestamp, in milliseconds. - */ -Guacamole.AudioPlayer.getTimestamp = function() { - - // If we have high-resolution timers, use those - if (window.performance) { - var now = performance.now || performance.webkitNow(); - return now(); - } - - // Fallback to millisecond-resolution system time - return Date.now(); - -}; - /** * Determines whether the given mimetype is supported by any built-in * implementation of Guacamole.AudioPlayer, and thus will be properly handled @@ -182,35 +160,16 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { })(); - /** - * Returns a base timestamp which can be used for scheduling future audio - * playback. Scheduling playback for the value returned by this function plus - * N will cause the associated audio to be played back N milliseconds after - * the function is called. - * - * @private - * @return {Number} - * An arbitrary relative timestamp, in milliseconds. - */ - var getTimestamp = function getTimestamp() { - - // If we have an audio context, use its timestamp - if (context) - return context.currentTime * 1000; - - // Otherwise, use the internal timestamp implementation - return Guacamole.AudioPlayer.getTimestamp(); - - }; - /** * The earliest possible time that the next packet could play without - * overlapping an already-playing packet, in milliseconds. + * overlapping an already-playing packet, in seconds. Note that while this + * value is in seconds, it is not an integer value and has microsecond + * resolution. * * @private * @type Number */ - var nextPacketTime = getTimestamp(); + var nextPacketTime = context.currentTime; /** * Guacamole.ArrayBufferReader wrapped around the audio input stream @@ -223,13 +182,13 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { /** * The maximum amount of latency to allow between the buffered data stream - * and the playback position, in milliseconds. Initially, this is set to + * and the playback position, in seconds. Initially, this is set to * roughly one third of a second, but it will be recalculated dynamically. * * @private * @type Number */ - var maxLatency = 300; + var maxLatency = 0.3; // Play each received raw packet of audio immediately reader.ondata = function playReceivedAudio(data) { @@ -237,14 +196,14 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { // Calculate total number of samples var samples = data.byteLength / format.channels / format.bytesPerSample; - // Calculate overall duration (in milliseconds) - var duration = samples * 1000 / format.rate; + // Calculate overall duration (in seconds) + var duration = samples / format.rate; // Recalculate latency threshold based on packet size maxLatency = duration * 2; // Determine exactly when packet CAN play - var packetTime = getTimestamp(); + var packetTime = context.currentTime; if (nextPacketTime < packetTime) nextPacketTime = packetTime; @@ -287,7 +246,7 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { // Schedule packet source.buffer = audioBuffer; - source.start(nextPacketTime / 1000); + source.start(nextPacketTime); // Update timeline nextPacketTime += duration; @@ -298,7 +257,7 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { this.sync = function sync() { // Calculate elapsed time since last sync - var now = getTimestamp(); + var now = context.currentTime; // Reschedule future playback time such that playback latency is // bounded within a reasonable latency threshold From 324c800167765b0fbe09dcbdb5ca3f546892ba53 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Wed, 30 Sep 2015 17:07:14 -0700 Subject: [PATCH 5/8] GUAC-1354: Use Guacamole.AudioPlayer.getSupportedTypes() to query available audio mimetypes within webapp. --- .../webapp/app/client/services/guacAudio.js | 77 +------------------ 1 file changed, 3 insertions(+), 74 deletions(-) diff --git a/guacamole/src/main/webapp/app/client/services/guacAudio.js b/guacamole/src/main/webapp/app/client/services/guacAudio.js index 061820584..bacc62842 100644 --- a/guacamole/src/main/webapp/app/client/services/guacAudio.js +++ b/guacamole/src/main/webapp/app/client/services/guacAudio.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014 Glyptodon LLC + * Copyright (C) 2015 Glyptodon LLC * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -31,82 +31,11 @@ angular.module('client').factory('guacAudio', [function guacAudio() { return new (function() { /** - * Array of codecs to test. + * Array of all supported audio mimetypes. * * @type String[] */ - var codecs = [ - 'audio/ogg; codecs="vorbis"', - 'audio/mp4; codecs="mp4a.40.5"', - 'audio/mpeg; codecs="mp3"', - 'audio/webm; codecs="vorbis"', - 'audio/wav; codecs=1' - ]; - - /** - * Array of all codecs that are reported as "probably" supported. - * - * @type String[] - */ - var probably_supported = []; - - /** - * Array of all codecs that are reported as "maybe" supported. - * - * @type String[] - */ - var maybe_supported = []; - - /** - * Internal audio element for the sake of testing codec support. If - * audio is explicitly not supported by the browser, this will instead - * be null. - * - * @type Audio - */ - var audio = null; - - // Attempt to create audio element - try { - audio = new Audio(); - } - catch (e) { - // If creation fails, allow audio to remain null - } - - /** - * Array of all supported audio mimetypes, ordered by liklihood of - * working. - */ - this.supported = []; - - // Build array of supported audio formats (if audio supported at all) - if (audio) { - codecs.forEach(function(mimetype) { - - var support_level = audio.canPlayType(mimetype); - - // Trim semicolon and trailer - var semicolon = mimetype.indexOf(";"); - if (semicolon !== -1) - mimetype = mimetype.substring(0, semicolon); - - // Partition by probably/maybe - if (support_level === "probably") - probably_supported.push(mimetype); - else if (support_level === "maybe") - maybe_supported.push(mimetype); - - }); - - // Add probably supported types first - Array.prototype.push.apply( - this.supported, probably_supported); - - // Prioritize "maybe" supported types second - Array.prototype.push.apply( - this.supported, maybe_supported); - } + this.supported = Guacamole.AudioPlayer.getSupportedTypes(); })(); From a3dd959dc41c0aafcaeb8f17694741b5f7ab4e48 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Wed, 30 Sep 2015 17:11:54 -0700 Subject: [PATCH 6/8] GUAC-1354: Do not recalculate max latency using packet duration. Audio packet duration will ALWAYS be roughly the same due to the max blob size. --- guacamole-common-js/src/main/webapp/modules/AudioPlayer.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js index 98a3592aa..938c04318 100644 --- a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js +++ b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js @@ -183,7 +183,7 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { /** * The maximum amount of latency to allow between the buffered data stream * and the playback position, in seconds. Initially, this is set to - * roughly one third of a second, but it will be recalculated dynamically. + * roughly one third of a second. * * @private * @type Number @@ -199,9 +199,6 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { // Calculate overall duration (in seconds) var duration = samples / format.rate; - // Recalculate latency threshold based on packet size - maxLatency = duration * 2; - // Determine exactly when packet CAN play var packetTime = context.currentTime; if (nextPacketTime < packetTime) From 079e3dad8c1df3e5ce4c0aba3f6b186461e0ace5 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Fri, 2 Oct 2015 16:42:24 -0700 Subject: [PATCH 7/8] GUAC-1354: Dynamically split and reassemble audio packets to minimize clicking. --- .../src/main/webapp/modules/AudioPlayer.js | 254 ++++++++++++++++-- 1 file changed, 232 insertions(+), 22 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js index 938c04318..98b3189ba 100644 --- a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js +++ b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js @@ -180,6 +180,18 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { */ var reader = new Guacamole.ArrayBufferReader(stream); + /** + * The minimum size of an audio packet split by splitAudioPacket(), in + * seconds. Audio packets smaller than this will not be split, nor will the + * split result of a larger packet ever be smaller in size than this + * minimum. + * + * @private + * @constant + * @type Number + */ + var MIN_SPLIT_SIZE = 0.02; + /** * The maximum amount of latency to allow between the buffered data stream * and the playback position, in seconds. Initially, this is set to @@ -190,32 +202,209 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { */ var maxLatency = 0.3; - // Play each received raw packet of audio immediately - reader.ondata = function playReceivedAudio(data) { + /** + * The type of typed array that will be used to represent each audio packet + * internally. This will be either Int8Array or Int16Array, depending on + * whether the raw audio format is 8-bit or 16-bit. + * + * @private + * @constructor + */ + var SampleArray = (format.bytesPerSample === 1) ? window.Int8Array : window.Int16Array; + + /** + * The maximum absolute value of any sample within a raw audio packet + * received by this audio player. This depends only on the size of each + * sample, and will be 128 for 8-bit audio and 32768 for 16-bit audio. + * + * @private + * @type Number + */ + var maxSampleValue = (format.bytesPerSample === 1) ? 128 : 32768; + + /** + * The queue of all pending audio packets, as an array of sample arrays. + * Audio packets which are pending playback will be added to this queue for + * further manipulation prior to scheduling via the Web Audio API. Once an + * audio packet leaves this queue and is scheduled via the Web Audio API, + * no further modifications can be made to that packet. + * + * @private + * @type SampleArray[] + */ + var packetQueue = []; + + /** + * Given an array of audio packets, returns a single audio packet + * containing the concatenation of those packets. + * + * @private + * @param {SampleArray[]} packets + * The array of audio packets to concatenate. + * + * @returns {SampleArray} + * A single audio packet containing the concatenation of all given + * audio packets. If no packets are provided, this will be undefined. + */ + var joinAudioPackets = function joinAudioPackets(packets) { + + // Do not bother joining if one or fewer packets are in the queue + if (packets.length <= 1) + return packets[0]; + + // Determine total sample length of the entire queue + var totalLength = 0; + packets.forEach(function addPacketLengths(packet) { + totalLength += packet.length; + }); + + // Append each packet within queue + var offset = 0; + var joined = new SampleArray(totalLength); + packets.forEach(function appendPacket(packet) { + joined.set(packet, offset); + offset += packet.length; + }); + + return joined; + + }; + + /** + * Given a single packet of audio data, splits off an arbitrary length of + * audio data from the beginning of that packet, returning the split result + * as an array of two packets. The split location is determined through an + * algorithm intended to minimize the liklihood of audible clicking between + * packets. If no such split location is possible, an array containing only + * the originally-provided audio packet is returned. + * + * @private + * @param {SampleArray} data + * The audio packet to split. + * + * @returns {SampleArray[]} + * An array of audio packets containing the result of splitting the + * provided audio packet. If splitting is possible, this array will + * contain two packets. If splitting is not possible, this array will + * contain only the originally-provided packet. + */ + var splitAudioPacket = function splitAudioPacket(data) { + + var minValue = Number.MAX_VALUE; + var optimalSplitLength = data.length; + + // Calculate number of whole samples in the provided audio packet AND + // in the minimum possible split packet + var samples = Math.floor(data.length / format.channels); + var minSplitSamples = Math.floor(format.rate * MIN_SPLIT_SIZE); + + // Calculate the beginning of the "end" of the audio packet + var start = Math.max( + format.channels * minSplitSamples, + format.channels * (samples - minSplitSamples) + ); + + // For all samples at the end of the given packet, find a point where + // the perceptible volume across all channels is lowest (and thus is + // the optimal point to split) + for (var offset = start; offset < data.length; offset += format.channels) { + + // Calculate the sum of all values across all channels (the result + // will be proportional to the average volume of a sample) + var totalValue = 0; + for (var channel = 0; channel < format.channels; channel++) { + totalValue += Math.abs(data[offset + channel]); + } + + // If this is the smallest average value thus far, set the split + // length such that the first packet ends with the current sample + if (totalValue <= minValue) { + optimalSplitLength = offset + format.channels; + minValue = totalValue; + } + + } + + // If packet is not split, return the supplied packet untouched + if (optimalSplitLength === data.length) + return [data]; + + // Otherwise, split the packet into two new packets according to the + // calculated optimal split length + return [ + data.slice(0, optimalSplitLength), + data.slice(optimalSplitLength) + ]; + + }; + + /** + * Pushes the given packet of audio data onto the playback queue. Unlike + * other private functions within Guacamole.RawAudioPlayer, the type of the + * ArrayBuffer packet of audio data here need not be specific to the type + * of audio (as with SampleArray). The ArrayBuffer type provided by a + * Guacamole.ArrayBufferReader, for example, is sufficient. Any necessary + * conversions will be performed automatically internally. + * + * @private + * @param {ArrayBuffer} data + * A raw packet of audio data that should be pushed onto the audio + * playback queue. + */ + var pushAudioPacket = function pushAudioPacket(data) { + packetQueue.push(new SampleArray(data)); + }; + + /** + * Shifts off and returns a packet of audio data from the beginning of the + * playback queue. The length of this audio packet is determined + * dynamically according to the click-reduction algorithm implemented by + * splitAudioPacket(). + * + * @returns {SampleArray} + * A packet of audio data pulled from the beginning of the playback + * queue. + */ + var shiftAudioPacket = function shiftAudioPacket() { + + // Flatten data in packet queue + var data = joinAudioPackets(packetQueue); + if (!data) + return null; + + // Pull an appropriate amount of data from the front of the queue + packetQueue = splitAudioPacket(data); + data = packetQueue.shift(); + + return data; + + }; + + /** + * Converts the given audio packet into an AudioBuffer, ready for playback + * by the Web Audio API. Unlike the raw audio packets received by this + * audio player, AudioBuffers require floating point samples and are split + * into isolated planes of channel-specific data. + * + * @private + * @param {SampleArray} data + * The raw audio packet that should be converted into a Web Audio API + * AudioBuffer. + * + * @returns {AudioBuffer} + * A new Web Audio API AudioBuffer containing the provided audio data, + * converted to the format used by the Web Audio API. + */ + var toAudioBuffer = function toAudioBuffer(data) { // Calculate total number of samples - var samples = data.byteLength / format.channels / format.bytesPerSample; - - // Calculate overall duration (in seconds) - var duration = samples / format.rate; + var samples = data.length / format.channels; // Determine exactly when packet CAN play var packetTime = context.currentTime; if (nextPacketTime < packetTime) nextPacketTime = packetTime; - // Obtain typed array view based on defined bytes per sample - var maxValue; - var source; - if (format.bytesPerSample === 1) { - source = new Int8Array(data); - maxValue = 128; - } - else { - source = new Int16Array(data); - maxValue = 32768; - } - // Get audio buffer for specified format var audioBuffer = context.createBuffer(format.channels, samples, format.rate); @@ -227,12 +416,33 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { // Fill audio buffer with data for channel var offset = channel; for (var i = 0; i < samples; i++) { - audioData[i] = source[offset] / maxValue; + audioData[i] = data[offset] / maxSampleValue; offset += format.channels; } } + return audioBuffer; + + }; + + // Defer playback of received audio packets slightly + reader.ondata = function playReceivedAudio(data) { + + // Push received samples onto queue + pushAudioPacket(new SampleArray(data)); + + // Shift off an arbitrary packet of audio data from the queue (this may + // be different in size from the packet just pushed) + var packet = shiftAudioPacket(); + if (!packet) + return; + + // Determine exactly when packet CAN play + var packetTime = context.currentTime; + if (nextPacketTime < packetTime) + nextPacketTime = packetTime; + // Set up buffer source var source = context.createBufferSource(); source.connect(context.destination); @@ -242,11 +452,11 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { source.start = source.noteOn; // Schedule packet - source.buffer = audioBuffer; + source.buffer = toAudioBuffer(packet); source.start(nextPacketTime); - // Update timeline - nextPacketTime += duration; + // Update timeline by duration of scheduled packet + nextPacketTime += packet.length / format.channels / format.rate; }; From d4f4ec0fb297b4c6fc4ec8ea0c8fa810ae255c48 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Fri, 2 Oct 2015 16:52:37 -0700 Subject: [PATCH 8/8] GUAC-1354: Use ArrayBuffer.slice - do not call slice directly on typed arrays (not widely supported). --- guacamole-common-js/src/main/webapp/modules/AudioPlayer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js index 98b3189ba..330881caa 100644 --- a/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js +++ b/guacamole-common-js/src/main/webapp/modules/AudioPlayer.js @@ -332,8 +332,8 @@ Guacamole.RawAudioPlayer = function RawAudioPlayer(stream, mimetype) { // Otherwise, split the packet into two new packets according to the // calculated optimal split length return [ - data.slice(0, optimalSplitLength), - data.slice(optimalSplitLength) + new SampleArray(data.buffer.slice(0, optimalSplitLength * format.bytesPerSample)), + new SampleArray(data.buffer.slice(optimalSplitLength * format.bytesPerSample)) ]; };