From 8a9c7ce35f960680c9d83766d731d2bcf82cfd60 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Fri, 4 Sep 2015 18:57:20 -0700 Subject: [PATCH] GUAC-427: Limit audio latency to reasonable bounds relative to sync instructions. --- .../src/main/webapp/modules/AudioChannel.js | 76 ++++++++++++++----- .../src/main/webapp/modules/Client.js | 56 ++++++++++---- 2 files changed, 97 insertions(+), 35 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/AudioChannel.js b/guacamole-common-js/src/main/webapp/modules/AudioChannel.js index 8fe38e2e1..0f64c4d70 100644 --- a/guacamole-common-js/src/main/webapp/modules/AudioChannel.js +++ b/guacamole-common-js/src/main/webapp/modules/AudioChannel.js @@ -24,48 +24,86 @@ var Guacamole = Guacamole || {}; /** * Abstract audio channel which queues and plays arbitrary audio data. + * * @constructor */ -Guacamole.AudioChannel = function() { +Guacamole.AudioChannel = function AudioChannel() { /** * Reference to this AudioChannel. + * * @private + * @type Guacamole.AudioChannel */ var channel = this; /** - * When the next packet should play. + * The earliest possible time that the next packet could play without + * overlapping an already-playing packet, in milliseconds. + * * @private + * @type Number */ - var next_packet_time = 0; + 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 data provided. - * @param {Number} duration The duration of the data provided, in - * milliseconds. - * @param {Blob} data The blob data to play. + * @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(mimetype, duration, data) { + this.play = function play(mimetype, duration, data) { - var packet = - new Guacamole.AudioChannel.Packet(mimetype, data); + var packet = new Guacamole.AudioChannel.Packet(mimetype, data); - var now = Guacamole.AudioChannel.getTimestamp(); + // Determine exactly when packet CAN play + var packetTime = Guacamole.AudioChannel.getTimestamp(); + if (nextPacketTime < packetTime) + nextPacketTime = packetTime; - // If underflow is detected, reschedule new packets relative to now. - if (next_packet_time < now) { - duration += next_packet_time - now; - next_packet_time = now; - } + // Schedule packet + packet.play(nextPacketTime); - // Schedule next packet - packet.play(next_packet_time); - next_packet_time += duration; + // Update timeline + nextPacketTime += duration; }; diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 31b3b398b..c3546cd9b 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -77,12 +77,17 @@ Guacamole.Client = function(tunnel) { */ var layers = {}; + /** + * All audio channels currentl in use by the client. Initially, this will + * be empty, but channels may be allocated by the server upon request. + * + * @type Object. + */ + var audioChannels = {}; + // No initial parsers var parsers = []; - // No initial audio channels - var audio_channels = []; - // No initial streams var streams = []; @@ -494,6 +499,27 @@ 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 @@ -540,18 +566,6 @@ Guacamole.Client = function(tunnel) { } - function getAudioChannel(index) { - - var audio_channel = audio_channels[index]; - - // If audio channel not yet created, create it - if (audio_channel == null) - audio_channel = audio_channels[index] = new Guacamole.AudioChannel(); - - return audio_channel; - - } - /** * Handlers for all defined layer properties. * @private @@ -1097,11 +1111,21 @@ Guacamole.Client = function(tunnel) { var timestamp = parseInt(parameters[0]); // Flush display, send sync when done - display.flush(function __send_sync_response() { + display.flush(function displaySyncComplete() { + + // Synchronize all audio channels + for (var index in audioChannels) { + var audioChannel = audioChannels[index]; + if (audioChannel) + audioChannel.sync(); + } + + // Send sync response to server if (timestamp !== currentTimestamp) { tunnel.sendMessage("sync", timestamp); currentTimestamp = timestamp; } + }); // If received first update, no longer waiting.