Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / chrome / browser / resources / chromeos / chromevox / cvox2 / background / earcon_engine.js
blobfc428c548276ea988edbc27fce3e79b4e934fc0f
1 // Copyright 2015 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 /**
6  * @fileoverview This is the low-level class that generates ChromeVox's
7  * earcons. It's designed to be self-contained and not depend on the
8  * rest of the code.
9  */
11 goog.provide('EarconEngine');
13 /**
14  * EarconEngine generates ChromeVox's earcons using the web audio API.
15  * @constructor
16  */
17 EarconEngine = function() {
18   // Public control parameters. All of these are meant to be adjustable.
20   /** @type {number} The master volume, as an amplification factor. */
21   this.masterVolume = 0.2;
23   /** @type {number} The base relative pitch adjustment, in half-steps. */
24   this.masterPitch = -4;
26   /** @type {number} The click volume, as an amplification factor. */
27   this.clickVolume = 0.4;
29   /**
30    * @type {number} The volume of the static sound, as an
31    * amplification factor.
32    */
33   this.staticVolume = 0.2;
35   /** @type {number} The base delay for repeated sounds, in seconds. */
36   this.baseDelay = 0.045;
38   /** @type {number} The master stereo panning, from -1 to 1. */
39   this.masterPan = 0;
41   /** @type {number} The master reverb level as an amplification factor. */
42   this.masterReverb = 0.4;
44   /**
45    * @type {string} The choice of the reverb impulse response to use.
46    * Must be one of the strings from EarconEngine.REVERBS.
47    */
48   this.reverbSound = 'small_room_2';
50   /** @type {number} The base pitch for the 'wrap' sound in half-steps. */
51   this.wrapPitch = 0;
53   /** @type {number} The base pitch for the 'alert' sound in half-steps. */
54   this.alertPitch = 0;
56   /** @type {string} The choice of base sound for most controls. */
57   this.controlSound = 'control';
59   /**
60    * @type {number} The delay between sounds in the on/off sweep effect,
61    * in seconds.
62    */
63   this.sweepDelay = 0.045;
65   /**
66    * @type {number} The delay between echos in the on/off sweep, in seconds.
67    */
68   this.sweepEchoDelay = 0.15;
70   /** @type {number} The number of echos in the on/off sweep. */
71   this.sweepEchoCount = 3;
73   /** @type {number} The pitch offset of the on/off sweep, in half-steps. */
74   this.sweepPitch = -7;
76   /**
77    * @type {number} The final gain of the progress sound, as an
78    * amplification factor.
79    */
80   this.progressFinalGain = 0.05;
82   /** @type {number} The multiplicative decay rate of the progress ticks. */
83   this.progressGain_Decay = 0.7;
85   // Private variables.
87   /** @type {AudioContext} @private The audio context. */
88   this.context_ = new AudioContext();
90   /** @type {?ConvolverNode} @private The reverb node, lazily initialized. */
91   this.reverbConvolver_ = null;
93   /**
94     * @type {Object<string, AudioBuffer>} A map between the name of an
95     *     audio data file and its loaded AudioBuffer.
96     * @private
97     */
98   this.buffers_ = {};
100   /**
101    * The source audio nodes for queued tick / tocks for progress.
102    * Kept around so they can be canceled.
103    *
104    * @type {Array<AudioNode>}
105    * @private
106    */
107   this.progressSources_ = [];
109   /** @type {number} The current gain for progress sounds. @private */
110   this.progressGain_ = 1.0;
112   /** @type {?number} The current time for progress sounds. @private */
113   this.progressTime_ = this.context_.currentTime;
115   /**
116    * @type {?number} The window.setInterval ID for progress sounds.
117    * @private
118    */
119   this.progressIntervalID_ = null;
121   // Initialization: load the base sound data files asynchronously.
122   var allSoundFilesToLoad = EarconEngine.SOUNDS.concat(EarconEngine.REVERBS);
123   allSoundFilesToLoad.forEach((function(sound) {
124     var url = EarconEngine.BASE_URL + sound + '.wav';
125     this.loadSound(sound, url);
126   }).bind(this));
130  * @type {Array<string>} The list of sound data files to load.
131  * @const
132  */
133 EarconEngine.SOUNDS = [
134   'control',
135   'selection',
136   'selection_reverse',
137   'skim',
138   'static'];
141  * @type {Array<string>} The list of reverb data files to load.
142  * @const
143  */
144 EarconEngine.REVERBS = [
145   'small_room_2'];
148  * @type {number} The scale factor for one half-step.
149  * @const
150  */
151 EarconEngine.HALF_STEP = Math.pow(2.0, 1.0 / 12.0);
154  * @type {string} The base url for earcon sound resources.
155  * @const
156  */
157 EarconEngine.BASE_URL = chrome.extension.getURL('cvox2/background/earcons/');
160  * Fetches a sound asynchronously and loads its data into an AudioBuffer.
162  * @param {string} name The name of the sound to load.
163  * @param {string} url The url where the sound should be fetched from.
164  */
165 EarconEngine.prototype.loadSound = function(name, url) {
166   var request = new XMLHttpRequest();
167   request.open('GET', url, true);
168   request.responseType = 'arraybuffer';
170   // Decode asynchronously.
171   request.onload = (function() {
172     this.context_.decodeAudioData(
173         /** @type {ArrayBuffer} */ (request.response),
174         (function(buffer) {
175           this.buffers_[name] = buffer;
176         }).bind(this));
177   }).bind(this);
178   request.send();
182  * Return an AudioNode containing the final processing that all
183  * sounds go through: master volume / gain, panning, and reverb.
184  * The chain is hooked up to the destination automatically, so you
185  * just need to connect your source to the return value from this
186  * method.
188  * @param {{gain: (number | undefined),
189  *          pan: (number | undefined),
190  *          reverb: (number | undefined)}} properties
191  *     An object where you can override the default
192  *     gain, pan, and reverb, otherwise these are taken from
193  *     masterVolume, masterPan, and masterReverb.
194  * @return {AudioNode} The filters to be applied to all sounds, connected
195  *     to the destination node.
196  */
197 EarconEngine.prototype.createCommonFilters = function(properties) {
198   var gain = this.masterVolume;
199   if (properties.gain) {
200     gain *= properties.gain;
201   }
202   var gainNode = this.context_.createGain();
203   gainNode.gain.value = gain;
204   var first = gainNode;
205   var last = gainNode;
207   var pan = this.masterPan;
208   if (properties.pan !== undefined) {
209     pan = properties.pan;
210   }
211   if (pan != 0) {
212     var panNode = this.context_.createPanner();
213     panNode.setPosition(pan, 0, -1);
214     panNode.setOrientation(0, 0, 1);
215     last.connect(panNode);
216     last = panNode;
217   }
219   var reverb = this.masterReverb;
220   if (properties.reverb !== undefined) {
221     reverb = properties.reverb;
222   }
223   if (reverb) {
224     if (!this.reverbConvolver_) {
225       this.reverbConvolver_ = this.context_.createConvolver();
226       this.reverbConvolver_.buffer = this.buffers_[this.reverbSound];
227       this.reverbConvolver_.connect(this.context_.destination);
228     }
230     // Dry
231     last.connect(this.context_.destination);
233     // Wet
234     var reverbGainNode = this.context_.createGain();
235     reverbGainNode.gain.value = reverb;
236     last.connect(reverbGainNode);
237     reverbGainNode.connect(this.reverbConvolver_);
238   } else {
239     last.connect(this.context_.destination);
240   }
242   return first;
246  * High-level interface to play a sound from a buffer source by name,
247  * with some simple adjustments like pitch change (in half-steps),
248  * a start time (relative to the current time, in seconds),
249  * gain, panning, and reverb.
251  * The only required parameter is the name of the sound. The time, pitch,
252  * gain, panning, and reverb are all optional and are passed in an
253  * object of optional properties.
255  * @param {string} sound The name of the sound to play. It must already
256  *     be loaded in a buffer.
257  * @param {{pitch: (number | undefined),
258  *          time: (number | undefined),
259  *          gain: (number | undefined),
260  *          pan: (number | undefined),
261  *          reverb: (number | undefined)}=} opt_properties
262  *     An object where you can override the default pitch, gain, pan,
263  *     and reverb.
264  * @return {AudioBufferSourceNode} The source node, so you can stop it
265  *     or set event handlers on it.
266  */
267 EarconEngine.prototype.play = function(sound, opt_properties) {
268   var source = this.context_.createBufferSource();
269   source.buffer = this.buffers_[sound];
271   if (!opt_properties) {
272     // This typecast looks silly, but the Closure compiler doesn't support
273     // optional fields in record types very well so this is the shortest hack.
274     opt_properties = /** @type {undefined} */({});
275   }
277   var pitch = this.masterPitch;
278   if (opt_properties.pitch) {
279     pitch += opt_properties.pitch;
280   }
281   if (pitch != 0) {
282     source.playbackRate.value = Math.pow(EarconEngine.HALF_STEP, pitch);
283   }
285   var destination = this.createCommonFilters(opt_properties);
286   source.connect(destination);
288   if (opt_properties.time) {
289     source.start(this.context_.currentTime + opt_properties.time);
290   } else {
291     source.start(this.context_.currentTime);
292   }
294   return source;
298  * Play the static sound.
299  */
300 EarconEngine.prototype.onStatic = function() {
301   this.play('static', {gain: this.staticVolume});
305  * Play the link sound.
306  */
307 EarconEngine.prototype.onLink = function() {
308   this.play('static', {gain: this.clickVolume});
309   this.play(this.controlSound, {pitch: 12});
313  * Play the button sound.
314  */
315 EarconEngine.prototype.onButton = function() {
316   this.play('static', {gain: this.clickVolume});
317   this.play(this.controlSound);
321  * Play the text field sound.
322  */
323 EarconEngine.prototype.onTextField = function() {
324   this.play('static', {gain: this.clickVolume});
325   this.play('static', {time: this.baseDelay * 1.5,
326                        gain: this.clickVolume * 0.5});
327   this.play(this.controlSound, {pitch: 4});
328   this.play(this.controlSound,
329             {pitch: 4,
330              time: this.baseDelay * 1.5,
331              gain: 0.5});
335  * Play the pop up button sound.
336  */
337 EarconEngine.prototype.onPopUpButton = function() {
338   this.play('static', {gain: this.clickVolume});
340   this.play(this.controlSound);
341   this.play(this.controlSound,
342             {time: this.baseDelay * 3,
343              gain: 0.2,
344              pitch: 12});
345   this.play(this.controlSound,
346             {time: this.baseDelay * 4.5,
347              gain: 0.2,
348              pitch: 12});
352  * Play the check on sound.
353  */
354 EarconEngine.prototype.onCheckOn = function() {
355   this.play('static', {gain: this.clickVolume});
356   this.play(this.controlSound, {pitch: -5});
357   this.play(this.controlSound, {pitch: 7, time: this.baseDelay * 2});
361  * Play the check off sound.
362  */
363 EarconEngine.prototype.onCheckOff = function() {
364   this.play('static', {gain: this.clickVolume});
365   this.play(this.controlSound, {pitch: 7});
366   this.play(this.controlSound, {pitch: -5, time: this.baseDelay * 2});
370  * Play the select control sound.
371  */
372 EarconEngine.prototype.onSelect = function() {
373   this.play('static', {gain: this.clickVolume});
374   this.play(this.controlSound);
375   this.play(this.controlSound, {time: this.baseDelay});
376   this.play(this.controlSound, {time: this.baseDelay * 2});
380  * Play the slider sound.
381  */
382 EarconEngine.prototype.onSlider = function() {
383   this.play('static', {gain: this.clickVolume});
384   this.play(this.controlSound);
385   this.play(this.controlSound,
386             {time: this.baseDelay,
387              gain: 0.5,
388              pitch: 2});
389   this.play(this.controlSound,
390             {time: this.baseDelay * 2,
391              gain: 0.25,
392              pitch: 4});
393   this.play(this.controlSound,
394             {time: this.baseDelay * 3,
395              gain: 0.125,
396              pitch: 6});
397   this.play(this.controlSound,
398             {time: this.baseDelay * 4,
399              gain: 0.0625,
400              pitch: 8});
404  * Play the skim sound.
405  */
406 EarconEngine.prototype.onSkim = function() {
407   this.play('skim');
411  * Play the selection sound.
412  */
413 EarconEngine.prototype.onSelection = function() {
414   this.play('selection');
418  * Play the selection reverse sound.
419  */
420 EarconEngine.prototype.onSelectionReverse = function() {
421   this.play('selection_reverse');
425  * Generate a synthesized musical note based on a sum of sinusoidals shaped
426  * by an envelope, controlled by a number of properties.
428  * The sound has a frequency of |freq|, or if |endFreq| is specified, does
429  * an exponential ramp from |freq| to |endFreq|.
431  * If |overtones| is greater than 1, the sound will be mixed with additional
432  * sinusoidals at multiples of |freq|, each one scaled by |overtoneFactor|.
433  * This creates a rounder tone than a pure sine wave.
435  * The envelope is shaped by the duration |dur|, the attack time |attack|,
436  * and the decay time |decay|, in seconds.
438  * As with other functions, |pan| and |reverb| can be used to override
439  * masterPan and masterReverb.
441  * @param {{gain: number,
442  *          freq: number,
443  *          endFreq: (number | undefined),
444  *          time: (number | undefined),
445  *          overtones: (number | undefined),
446  *          overtoneFactor: (number | undefined),
447  *          dur: (number | undefined),
448  *          attack: (number | undefined),
449  *          decay: (number | undefined),
450  *          pan: (number | undefined),
451  *          reverb: (number | undefined)}} properties
452  *     An object containing the properties that can be used to
453  *     control the sound, as described above.
454  */
455 EarconEngine.prototype.generateSinusoidal = function(properties) {
456   var envelopeNode = this.context_.createGain();
457   envelopeNode.connect(this.context_.destination);
459   var time = properties.time;
460   if (time === undefined) {
461     time = 0;
462   }
464   // Generate an oscillator for the frequency corresponding to the specified
465   // frequency, and then additional overtones at multiples of that frequency
466   // scaled by the overtoneFactor. Cue the oscillator to start and stop
467   // based on the start time and specified duration.
468   //
469   // If an end frequency is specified, do an exponential ramp to that end
470   // frequency.
471   var gain = properties.gain;
472   for (var i = 0; i < properties.overtones; i++) {
473     var osc = this.context_.createOscillator();
474     osc.frequency.value = properties.freq * (i + 1);
476     if (properties.endFreq) {
477       osc.frequency.setValueAtTime(
478           properties.freq * (i + 1),
479           this.context_.currentTime + time);
480       osc.frequency.exponentialRampToValueAtTime(
481           properties.endFreq * (i + 1),
482           this.context_.currentTime + properties.dur);
483     }
485     osc.start(this.context_.currentTime + time);
486     osc.stop(this.context_.currentTime + time + properties.dur);
488     var gainNode = this.context_.createGain();
489     gainNode.gain.value = gain;
490     osc.connect(gainNode);
491     gainNode.connect(envelopeNode);
493     gain *= properties.overtoneFactor;
494   }
496   // Shape the overall sound by an envelope based on the attack and
497   // decay times.
498   envelopeNode.gain.setValueAtTime(0, this.context_.currentTime + time);
499   envelopeNode.gain.linearRampToValueAtTime(
500       1, this.context_.currentTime + time + properties.attack);
501   envelopeNode.gain.setValueAtTime(
502           1, this.context_.currentTime + time +
503              properties.dur - properties.decay);
504   envelopeNode.gain.linearRampToValueAtTime(
505       0, this.context_.currentTime + time + properties.dur);
507   // Route everything through the common filters like reverb at the end.
508   var destination = this.createCommonFilters({});
509   envelopeNode.connect(destination);
513  * Play a sweep over a bunch of notes in a scale, with an echo,
514  * for the ChromeVox on or off sounds.
516  * @param {boolean} reverse Whether to play in the reverse direction.
517  */
518 EarconEngine.prototype.onChromeVoxSweep = function(reverse) {
519   var pitches = [-7, -5, 0, 5, 7, 12, 17, 19, 24];
521   if (reverse) {
522     pitches.reverse();
523   }
525   var attack = 0.015;
526   var dur = pitches.length * this.sweepDelay;
528   var destination = this.createCommonFilters({reverb: 2.0});
529   for (var k = 0; k < this.sweepEchoCount; k++) {
530     var envelopeNode = this.context_.createGain();
531     var startTime = this.context_.currentTime + this.sweepEchoDelay * k;
532     var sweepGain = Math.pow(0.3, k);
533     var overtones = 2;
534     var overtoneGain = sweepGain;
535     for (var i = 0; i < overtones; i++) {
536       var osc = this.context_.createOscillator();
537       osc.start(startTime);
538       osc.stop(startTime + dur);
540       var gainNode = this.context_.createGain();
541       osc.connect(gainNode);
542       gainNode.connect(envelopeNode);
544       for (var j = 0; j < pitches.length; j++) {
545         var freqDecay;
546         if (reverse) {
547           freqDecay = Math.pow(0.75, pitches.length - j);
548         } else {
549           freqDecay = Math.pow(0.75, j);
550         }
551         var gain = overtoneGain * freqDecay;
552         var freq = (i + 1) * 220 *
553             Math.pow(EarconEngine.HALF_STEP, pitches[j] + this.sweepPitch);
554         if (j == 0) {
555           osc.frequency.setValueAtTime(freq, startTime);
556           gainNode.gain.setValueAtTime(gain, startTime);
557         } else {
558           osc.frequency.exponentialRampToValueAtTime(
559               freq, startTime + j * this.sweepDelay);
560           gainNode.gain.linearRampToValueAtTime(
561               gain, startTime + j * this.sweepDelay);
562         }
563         osc.frequency.setValueAtTime(
564             freq, startTime + j * this.sweepDelay + this.sweepDelay - attack);
565       }
567       overtoneGain *= 0.1 + 0.2 * k;
568     }
570     envelopeNode.gain.setValueAtTime(0, startTime);
571     envelopeNode.gain.linearRampToValueAtTime(1, startTime + this.sweepDelay);
572     envelopeNode.gain.setValueAtTime(1, startTime + dur - attack * 2);
573     envelopeNode.gain.linearRampToValueAtTime(0, startTime + dur);
574     envelopeNode.connect(destination);
575   }
579  * Play the "ChromeVox On" sound.
580  */
581 EarconEngine.prototype.onChromeVoxOn = function() {
582   this.onChromeVoxSweep(false);
586  * Play the "ChromeVox Off" sound.
587  */
588 EarconEngine.prototype.onChromeVoxOff = function() {
589   this.onChromeVoxSweep(true);
593  * Play an alert sound.
594  */
595 EarconEngine.prototype.onAlert = function() {
596   var freq1 = 220 * Math.pow(EarconEngine.HALF_STEP, this.alertPitch - 2);
597   var freq2 = 220 * Math.pow(EarconEngine.HALF_STEP, this.alertPitch - 3);
598   this.generateSinusoidal({attack: 0.02,
599                            decay: 0.07,
600                            dur: 0.15,
601                            gain: 0.3,
602                            freq: freq1,
603                            overtones: 3,
604                            overtoneFactor: 0.1});
605   this.generateSinusoidal({attack: 0.02,
606                            decay: 0.07,
607                            dur: 0.15,
608                            gain: 0.3,
609                            freq: freq2,
610                            overtones: 3,
611                            overtoneFactor: 0.1});
615  * Play a wrap sound.
616  */
617 EarconEngine.prototype.onWrap = function() {
618   this.play('static', {gain: this.clickVolume * 0.3});
619   var freq1 = 220 * Math.pow(EarconEngine.HALF_STEP, this.wrapPitch - 8);
620   var freq2 = 220 * Math.pow(EarconEngine.HALF_STEP, this.wrapPitch + 8);
621   this.generateSinusoidal({attack: 0.01,
622                            decay: 0.1,
623                            dur: 0.15,
624                            gain: 0.3,
625                            freq: freq1,
626                            endFreq: freq2,
627                            overtones: 1,
628                            overtoneFactor: 0.1});
632  * Queue up a few tick/tock sounds for a progress bar. This is called
633  * repeatedly by setInterval to keep the sounds going continuously.
634  * @private
635  */
636 EarconEngine.prototype.generateProgressTickTocks_ = function() {
637   while (this.progressTime_ < this.context_.currentTime + 3.0) {
638     var t = this.progressTime_ - this.context_.currentTime;
639     this.progressSources_.push(
640         [this.progressTime_,
641          this.play('static',
642                    {gain: 0.5 * this.progressGain_,
643                     time: t})]);
644     this.progressSources_.push(
645         [this.progressTime_,
646          this.play(this.controlSound,
647                    {pitch: 20,
648                     time: t,
649                     gain: this.progressGain_})]);
651     if (this.progressGain_ > this.progressFinalGain) {
652       this.progressGain_ *= this.progressGain_Decay;
653     }
654     t += 0.5;
656     this.progressSources_.push(
657         [this.progressTime_,
658          this.play('static',
659                    {gain: 0.5 * this.progressGain_,
660                     time: t})]);
661     this.progressSources_.push(
662         [this.progressTime_,
663          this.play(this.controlSound,
664                    {pitch: 8,
665                     time: t,
666                     gain: this.progressGain_})]);
668     if (this.progressGain_ > this.progressFinalGain) {
669       this.progressGain_ *= this.progressGain_Decay;
670     }
672     this.progressTime_ += 1.0;
673   }
675   var removeCount = 0;
676   while (removeCount < this.progressSources_.length &&
677       this.progressSources_[removeCount][0] < this.context_.currentTime - 0.2) {
678     removeCount++;
679   }
680   this.progressSources_.splice(0, removeCount);
684  * Start playing tick / tock progress sounds continuously until
685  * explicitly canceled.
686  */
687 EarconEngine.prototype.startProgress = function() {
688   this.progressSources_ = [];
689   this.progressGain_ = 0.5;
690   this.progressTime_ = this.context_.currentTime;
691   this.generateProgressTickTocks_();
692   this.progressIntervalID_ = window.setInterval(
693       this.generateProgressTickTocks_.bind(this), 1000);
697  * Stop playing any tick / tock progress sounds.
698  */
699 EarconEngine.prototype.cancelProgress = function() {
700   if (!this.progressIntervalID_) {
701     return;
702   }
704   for (var i = 0; i < this.progressSources_.length; i++) {
705     this.progressSources_[i][1].stop();
706   }
707   this.progressSources_ = [];
709   window.clearInterval(this.progressIntervalID_);
710   this.progressIntervalID_ = null;