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.
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
11 goog.provide('EarconEngine');
14 * EarconEngine generates ChromeVox's earcons using the web audio API.
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;
30 * @type {number} The volume of the static sound, as an
31 * amplification factor.
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. */
41 /** @type {number} The master reverb level as an amplification factor. */
42 this.masterReverb = 0.4;
45 * @type {string} The choice of the reverb impulse response to use.
46 * Must be one of the strings from EarconEngine.REVERBS.
48 this.reverbSound = 'small_room_2';
50 /** @type {number} The base pitch for the 'wrap' sound in half-steps. */
53 /** @type {number} The base pitch for the 'alert' sound in half-steps. */
56 /** @type {string} The choice of base sound for most controls. */
57 this.controlSound = 'control';
60 * @type {number} The delay between sounds in the on/off sweep effect,
63 this.sweepDelay = 0.045;
66 * @type {number} The delay between echos in the on/off sweep, in seconds.
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. */
77 * @type {number} The final gain of the progress sound, as an
78 * amplification factor.
80 this.progressFinalGain = 0.05;
82 /** @type {number} The multiplicative decay rate of the progress ticks. */
83 this.progressGain_Decay = 0.7;
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;
94 * @type {Object<string, AudioBuffer>} A map between the name of an
95 * audio data file and its loaded AudioBuffer.
101 * The source audio nodes for queued tick / tocks for progress.
102 * Kept around so they can be canceled.
104 * @type {Array<AudioNode>}
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;
116 * @type {?number} The window.setInterval ID for progress sounds.
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);
130 * @type {Array<string>} The list of sound data files to load.
133 EarconEngine.SOUNDS = [
141 * @type {Array<string>} The list of reverb data files to load.
144 EarconEngine.REVERBS = [
148 * @type {number} The scale factor for one half-step.
151 EarconEngine.HALF_STEP = Math.pow(2.0, 1.0 / 12.0);
154 * @type {string} The base url for earcon sound resources.
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.
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),
175 this.buffers_[name] = buffer;
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
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.
197 EarconEngine.prototype.createCommonFilters = function(properties) {
198 var gain = this.masterVolume;
199 if (properties.gain) {
200 gain *= properties.gain;
202 var gainNode = this.context_.createGain();
203 gainNode.gain.value = gain;
204 var first = gainNode;
207 var pan = this.masterPan;
208 if (properties.pan !== undefined) {
209 pan = properties.pan;
212 var panNode = this.context_.createPanner();
213 panNode.setPosition(pan, 0, -1);
214 panNode.setOrientation(0, 0, 1);
215 last.connect(panNode);
219 var reverb = this.masterReverb;
220 if (properties.reverb !== undefined) {
221 reverb = properties.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);
231 last.connect(this.context_.destination);
234 var reverbGainNode = this.context_.createGain();
235 reverbGainNode.gain.value = reverb;
236 last.connect(reverbGainNode);
237 reverbGainNode.connect(this.reverbConvolver_);
239 last.connect(this.context_.destination);
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,
264 * @return {AudioBufferSourceNode} The source node, so you can stop it
265 * or set event handlers on it.
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} */({});
277 var pitch = this.masterPitch;
278 if (opt_properties.pitch) {
279 pitch += opt_properties.pitch;
282 source.playbackRate.value = Math.pow(EarconEngine.HALF_STEP, pitch);
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);
291 source.start(this.context_.currentTime);
298 * Play the static sound.
300 EarconEngine.prototype.onStatic = function() {
301 this.play('static', {gain: this.staticVolume});
305 * Play the link sound.
307 EarconEngine.prototype.onLink = function() {
308 this.play('static', {gain: this.clickVolume});
309 this.play(this.controlSound, {pitch: 12});
313 * Play the button sound.
315 EarconEngine.prototype.onButton = function() {
316 this.play('static', {gain: this.clickVolume});
317 this.play(this.controlSound);
321 * Play the text field sound.
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,
330 time: this.baseDelay * 1.5,
335 * Play the pop up button sound.
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,
345 this.play(this.controlSound,
346 {time: this.baseDelay * 4.5,
352 * Play the check on sound.
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.
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.
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.
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,
389 this.play(this.controlSound,
390 {time: this.baseDelay * 2,
393 this.play(this.controlSound,
394 {time: this.baseDelay * 3,
397 this.play(this.controlSound,
398 {time: this.baseDelay * 4,
404 * Play the skim sound.
406 EarconEngine.prototype.onSkim = function() {
411 * Play the selection sound.
413 EarconEngine.prototype.onSelection = function() {
414 this.play('selection');
418 * Play the selection reverse sound.
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,
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.
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) {
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.
469 // If an end frequency is specified, do an exponential ramp to that end
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);
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;
496 // Shape the overall sound by an envelope based on the attack and
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.
518 EarconEngine.prototype.onChromeVoxSweep = function(reverse) {
519 var pitches = [-7, -5, 0, 5, 7, 12, 17, 19, 24];
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);
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++) {
547 freqDecay = Math.pow(0.75, pitches.length - j);
549 freqDecay = Math.pow(0.75, j);
551 var gain = overtoneGain * freqDecay;
552 var freq = (i + 1) * 220 *
553 Math.pow(EarconEngine.HALF_STEP, pitches[j] + this.sweepPitch);
555 osc.frequency.setValueAtTime(freq, startTime);
556 gainNode.gain.setValueAtTime(gain, startTime);
558 osc.frequency.exponentialRampToValueAtTime(
559 freq, startTime + j * this.sweepDelay);
560 gainNode.gain.linearRampToValueAtTime(
561 gain, startTime + j * this.sweepDelay);
563 osc.frequency.setValueAtTime(
564 freq, startTime + j * this.sweepDelay + this.sweepDelay - attack);
567 overtoneGain *= 0.1 + 0.2 * k;
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);
579 * Play the "ChromeVox On" sound.
581 EarconEngine.prototype.onChromeVoxOn = function() {
582 this.onChromeVoxSweep(false);
586 * Play the "ChromeVox Off" sound.
588 EarconEngine.prototype.onChromeVoxOff = function() {
589 this.onChromeVoxSweep(true);
593 * Play an alert sound.
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,
604 overtoneFactor: 0.1});
605 this.generateSinusoidal({attack: 0.02,
611 overtoneFactor: 0.1});
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,
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.
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(
642 {gain: 0.5 * this.progressGain_,
644 this.progressSources_.push(
646 this.play(this.controlSound,
649 gain: this.progressGain_})]);
651 if (this.progressGain_ > this.progressFinalGain) {
652 this.progressGain_ *= this.progressGain_Decay;
656 this.progressSources_.push(
659 {gain: 0.5 * this.progressGain_,
661 this.progressSources_.push(
663 this.play(this.controlSound,
666 gain: this.progressGain_})]);
668 if (this.progressGain_ > this.progressFinalGain) {
669 this.progressGain_ *= this.progressGain_Decay;
672 this.progressTime_ += 1.0;
676 while (removeCount < this.progressSources_.length &&
677 this.progressSources_[removeCount][0] < this.context_.currentTime - 0.2) {
680 this.progressSources_.splice(0, removeCount);
684 * Start playing tick / tock progress sounds continuously until
685 * explicitly canceled.
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.
699 EarconEngine.prototype.cancelProgress = function() {
700 if (!this.progressIntervalID_) {
704 for (var i = 0; i < this.progressSources_.length; i++) {
705 this.progressSources_[i][1].stop();
707 this.progressSources_ = [];
709 window.clearInterval(this.progressIntervalID_);
710 this.progressIntervalID_ = null;