1 /*
  2  * Copyright (C) 2015 Glyptodon LLC
  3  *
  4  * Permission is hereby granted, free of charge, to any person obtaining a copy
  5  * of this software and associated documentation files (the "Software"), to deal
  6  * in the Software without restriction, including without limitation the rights
  7  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  8  * copies of the Software, and to permit persons to whom the Software is
  9  * furnished to do so, subject to the following conditions:
 10  *
 11  * The above copyright notice and this permission notice shall be included in
 12  * all copies or substantial portions of the Software.
 13  *
 14  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 15  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 16  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 17  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 18  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 19  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 20  * THE SOFTWARE.
 21  */
 22 
 23 var Guacamole = Guacamole || {};
 24 
 25 /**
 26  * Abstract audio channel which queues and plays arbitrary audio data.
 27  *
 28  * @constructor
 29  */
 30 Guacamole.AudioChannel = function AudioChannel() {
 31 
 32     /**
 33      * Reference to this AudioChannel.
 34      *
 35      * @private
 36      * @type Guacamole.AudioChannel
 37      */
 38     var channel = this;
 39 
 40     /**
 41      * The earliest possible time that the next packet could play without
 42      * overlapping an already-playing packet, in milliseconds.
 43      *
 44      * @private
 45      * @type Number
 46      */
 47     var nextPacketTime = Guacamole.AudioChannel.getTimestamp();
 48 
 49     /**
 50      * The last time that sync() was called, in milliseconds. If sync() has
 51      * never been called, this will be the time the Guacamole.AudioChannel
 52      * was created.
 53      *
 54      * @type Number
 55      */
 56     var lastSync = nextPacketTime;
 57 
 58     /**
 59      * Notifies this Guacamole.AudioChannel that all audio up to the current
 60      * point in time has been given via play(), and that any difference in time
 61      * between queued audio packets and the current time can be considered
 62      * latency.
 63      */
 64     this.sync = function sync() {
 65 
 66         // Calculate elapsed time since last sync
 67         var now = Guacamole.AudioChannel.getTimestamp();
 68         var elapsed = now - lastSync;
 69 
 70         // Reschedule future playback time such that playback latency is
 71         // bounded within the duration of the last audio frame
 72         nextPacketTime = Math.min(nextPacketTime, now + elapsed);
 73 
 74         // Record sync time
 75         lastSync = now;
 76 
 77     };
 78 
 79     /**
 80      * Queues up the given data for playing by this channel once all previously
 81      * queued data has been played. If no data has been queued, the data will
 82      * play immediately.
 83      * 
 84      * @param {String} mimetype
 85      *     The mimetype of the audio data provided.
 86      *
 87      * @param {Number} duration
 88      *     The duration of the data provided, in milliseconds.
 89      *
 90      * @param {Blob} data
 91      *     The blob of audio data to play.
 92      */
 93     this.play = function play(mimetype, duration, data) {
 94 
 95         var packet = new Guacamole.AudioChannel.Packet(mimetype, data);
 96 
 97         // Determine exactly when packet CAN play
 98         var packetTime = Guacamole.AudioChannel.getTimestamp();
 99         if (nextPacketTime < packetTime)
100             nextPacketTime = packetTime;
101 
102         // Schedule packet
103         packet.play(nextPacketTime);
104 
105         // Update timeline
106         nextPacketTime += duration;
107 
108     };
109 
110 };
111 
112 // Define context if available
113 if (window.AudioContext) {
114     try {Guacamole.AudioChannel.context = new AudioContext();}
115     catch (e){}
116 }
117 
118 // Fallback to Webkit-specific AudioContext implementation
119 else if (window.webkitAudioContext) {
120     try {Guacamole.AudioChannel.context = new webkitAudioContext();}
121     catch (e){}
122 }
123 
124 /**
125  * Returns a base timestamp which can be used for scheduling future audio
126  * playback. Scheduling playback for the value returned by this function plus
127  * N will cause the associated audio to be played back N milliseconds after
128  * the function is called.
129  *
130  * @return {Number} An arbitrary channel-relative timestamp, in milliseconds.
131  */
132 Guacamole.AudioChannel.getTimestamp = function() {
133 
134     // If we have an audio context, use its timestamp
135     if (Guacamole.AudioChannel.context)
136         return Guacamole.AudioChannel.context.currentTime * 1000;
137 
138     // If we have high-resolution timers, use those
139     if (window.performance) {
140 
141         if (window.performance.now)
142             return window.performance.now();
143 
144         if (window.performance.webkitNow)
145             return window.performance.webkitNow();
146         
147     }
148 
149     // Fallback to millisecond-resolution system time
150     return new Date().getTime();
151 
152 };
153 
154 /**
155  * Abstract representation of an audio packet.
156  * 
157  * @constructor
158  * 
159  * @param {String} mimetype The mimetype of the data contained by this packet.
160  * @param {Blob} data The blob of sound data contained by this packet.
161  */
162 Guacamole.AudioChannel.Packet = function(mimetype, data) {
163 
164     /**
165      * Schedules this packet for playback at the given time.
166      *
167      * @function
168      * @param {Number} when The time this packet should be played, in
169      *                      milliseconds.
170      */
171     this.play = function(when) { /* NOP */ }; // Defined conditionally depending on support
172 
173     // If audio API available, use it.
174     if (Guacamole.AudioChannel.context) {
175 
176         var readyBuffer = null;
177 
178         // By default, when decoding finishes, store buffer for future
179         // playback
180         var handleReady = function(buffer) {
181             readyBuffer = buffer;
182         };
183 
184         // Read data and start decoding
185         var reader = new FileReader();
186         reader.onload = function() {
187             Guacamole.AudioChannel.context.decodeAudioData(
188                 reader.result,
189                 function(buffer) { handleReady(buffer); }
190             );
191         };
192         reader.readAsArrayBuffer(data);
193 
194         // Set up buffer source
195         var source = Guacamole.AudioChannel.context.createBufferSource();
196         source.connect(Guacamole.AudioChannel.context.destination);
197 
198         // Use noteOn() instead of start() if necessary
199         if (!source.start)
200             source.start = source.noteOn;
201 
202         var play_when;
203 
204         function playDelayed(buffer) {
205             source.buffer = buffer;
206             source.start(play_when / 1000);
207         }
208 
209         /** @ignore */
210         this.play = function(when) {
211             
212             play_when = when;
213             
214             // If buffer available, play it NOW
215             if (readyBuffer)
216                 playDelayed(readyBuffer);
217 
218             // Otherwise, play when decoded
219             else
220                 handleReady = playDelayed;
221 
222         };
223 
224     }
225 
226     else {
227 
228         var play_on_load = false;
229 
230         // Create audio element to house and play the data
231         var audio = null;
232         try { audio = new Audio(); }
233         catch (e) {}
234 
235         if (audio) {
236 
237             // Read data and start decoding
238             var reader = new FileReader();
239             reader.onload = function() {
240 
241                 var binary = "";
242                 var bytes = new Uint8Array(reader.result);
243 
244                 // Produce binary string from bytes in buffer
245                 for (var i=0; i<bytes.byteLength; i++)
246                     binary += String.fromCharCode(bytes[i]);
247 
248                 // Convert to data URI 
249                 audio.src = "data:" + mimetype + ";base64," + window.btoa(binary);
250 
251                 // Play if play was attempted but packet wasn't loaded yet
252                 if (play_on_load)
253                     audio.play();
254 
255             };
256             reader.readAsArrayBuffer(data);
257        
258             function play() {
259 
260                 // If audio data is ready, play now
261                 if (audio.src)
262                     audio.play();
263 
264                 // Otherwise, play when loaded
265                 else
266                     play_on_load = true;
267 
268             }
269             
270             /** @ignore */
271             this.play = function(when) {
272                 
273                 // Calculate time until play
274                 var now = Guacamole.AudioChannel.getTimestamp();
275                 var delay = when - now;
276                 
277                 // Play now if too late
278                 if (delay < 0)
279                     play();
280 
281                 // Otherwise, schedule later playback
282                 else
283                     window.setTimeout(play, delay);
284 
285             };
286 
287         }
288 
289     }
290 
291 };
292