Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(131)

Side by Side Diff: chrome/browser/resources/chromeos/chromevox/cvox2/background/earcon_engine.js

Issue 1273363004: Initial commit of new ChromeVox earcon engine. (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: Address feedback from Peter Created 5 years, 3 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
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.
4
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 */
10
11 goog.provide('EarconEngine');
12
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.
19
20 /** @type {number} The master volume, as an amplification factor. */
21 this.masterVolume = 0.2;
22
23 /** @type {number} The base relative pitch adjustment, in half-steps. */
24 this.masterPitch = -4;
25
26 /** @type {number} The click volume, as an amplification factor. */
27 this.clickVolume = 0.4;
28
29 /**
30 * @type {number} The volume of the static sound, as an
31 * amplification factor.
32 */
33 this.staticVolume = 0.2;
34
35 /** @type {number} The base delay for repeated sounds, in seconds. */
36 this.baseDelay = 0.045;
37
38 /** @type {number} The master stereo panning, from -1 to 1. */
39 this.masterPan = 0;
40
41 /** @type {number} The master reverb level as an amplification factor. */
42 this.masterReverb = 0.4;
43
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';
49
50 /** @type {number} The base pitch for the 'wrap' sound in half-steps. */
51 this.wrapPitch = 0;
52
53 /** @type {number} The base pitch for the 'alert' sound in half-steps. */
54 this.alertPitch = 0;
55
56 /** @type {string} The choice of base sound for most controls. */
57 this.controlSound = 'control';
58
59 /**
60 * @type {number} The delay between sounds in the on/off sweep effect,
61 * in seconds.
62 */
63 this.sweepDelay = 0.045;
64
65 /**
66 * @type {number} The delay between echos in the on/off sweep, in seconds.
67 */
68 this.sweepEchoDelay = 0.15;
69
70 /** @type {number} The number of echos in the on/off sweep. */
71 this.sweepEchoCount = 3;
72
73 /** @type {number} The pitch offset of the on/off sweep, in half-steps. */
74 this.sweepPitch = -7;
75
76 /**
77 * @type {number} The final gain of the progress sound, as an
78 * amplification factor.
79 */
80 this.progressFinalGain = 0.05;
81
82 /** @type {number} The multiplicative decay rate of the progress ticks. */
83 this.progressGain_Decay = 0.7;
84
85 // Private variables.
86
87 /** @type {AudioContext} @private The audio context. */
88 this.context_ = new AudioContext();
89
90 /** @type {?ConvolverNode} @private The reverb node, lazily initialized. */
91 this.reverbConvolver_ = null;
92
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_ = {};
99
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_ = [];
108
109 /** @type {number} The current gain for progress sounds. @private */
110 this.progressGain_ = 1.0;
111
112 /** @type {?number} The current time for progress sounds. @private */
113 this.progressTime_ = this.context_.currentTime;
114
115 /**
116 * @type {?number} The window.setInterval ID for progress sounds.
117 * @private
118 */
119 this.progressIntervalID_ = null;
120
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));
127 };
128
129 /**
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'];
139
140 /**
141 * @type {Array<string>} The list of reverb data files to load.
142 * @const
143 */
144 EarconEngine.REVERBS = [
145 'small_room_2'];
146
147 /**
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);
152
153 /**
154 * @type {string} The base url for earcon sound resources.
155 * @const
156 */
157 EarconEngine.BASE_URL = chrome.extension.getURL('cvox2/background/earcons/');
158
159 /**
160 * Fetches a sound asynchronously and loads its data into an AudioBuffer.
161 *
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';
169
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();
179 };
180
181 /**
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.
187 *
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;
206
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 }
218
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 }
229
230 // Dry
231 last.connect(this.context_.destination);
232
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 }
241
242 return first;
243 };
244
245 /**
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.
250 *
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.
254 *
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];
270
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 }
276
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 }
284
285 var destination = this.createCommonFilters(opt_properties);
286 source.connect(destination);
287
288 if (opt_properties.time) {
289 source.start(this.context_.currentTime + opt_properties.time);
290 } else {
291 source.start(this.context_.currentTime);
292 }
293
294 return source;
295 };
296
297 /**
298 * Play the static sound.
299 */
300 EarconEngine.prototype.onStatic = function() {
301 this.play('static', {gain: this.staticVolume});
302 };
303
304 /**
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});
310 };
311
312 /**
313 * Play the button sound.
314 */
315 EarconEngine.prototype.onButton = function() {
316 this.play('static', {gain: this.clickVolume});
317 this.play(this.controlSound);
318 };
319
320 /**
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});
332 };
333
334 /**
335 * Play the pop up button sound.
336 */
337 EarconEngine.prototype.onPopUpButton = function() {
338 this.play('static', {gain: this.clickVolume});
339
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});
349 };
350
351 /**
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});
358 };
359
360 /**
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});
367 };
368
369 /**
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});
377 };
378
379 /**
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});
401 };
402
403 /**
404 * Play the skim sound.
405 */
406 EarconEngine.prototype.onSkim = function() {
407 this.play('skim');
408 };
409
410 /**
411 * Play the selection sound.
412 */
413 EarconEngine.prototype.onSelection = function() {
414 this.play('selection');
415 };
416
417 /**
418 * Play the selection reverse sound.
419 */
420 EarconEngine.prototype.onSelectionReverse = function() {
421 this.play('selection_reverse');
422 };
423
424 /**
425 * Generate a synthesized musical note based on a sum of sinusoidals shaped
426 * by an envelope, controlled by a number of properties.
427 *
428 * The sound has a frequency of |freq|, or if |endFreq| is specified, does
429 * an exponential ramp from |freq| to |endFreq|.
430 *
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.
434 *
435 * The envelope is shaped by the duration |dur|, the attack time |attack|,
436 * and the decay time |decay|, in seconds.
437 *
438 * As with other functions, |pan| and |reverb| can be used to override
439 * masterPan and masterReverb.
440 *
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);
458
459 var time = properties.time;
460 if (time === undefined) {
461 time = 0;
462 }
463
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);
475
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 }
484
485 osc.start(this.context_.currentTime + time);
486 osc.stop(this.context_.currentTime + time + properties.dur);
487
488 var gainNode = this.context_.createGain();
489 gainNode.gain.value = gain;
490 osc.connect(gainNode);
491 gainNode.connect(envelopeNode);
492
493 gain *= properties.overtoneFactor;
494 }
495
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);
506
507 // Route everything through the common filters like reverb at the end.
508 var destination = this.createCommonFilters({});
509 envelopeNode.connect(destination);
510 };
511
512 /**
513 * Play a sweep over a bunch of notes in a scale, with an echo,
514 * for the ChromeVox on or off sounds.
515 *
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];
520
521 if (reverse) {
522 pitches.reverse();
523 }
524
525 var attack = 0.015;
526 var dur = pitches.length * this.sweepDelay;
527
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);
539
540 var gainNode = this.context_.createGain();
541 osc.connect(gainNode);
542 gainNode.connect(envelopeNode);
543
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 }
566
567 overtoneGain *= 0.1 + 0.2 * k;
568 }
569
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 }
576 };
577
578 /**
579 * Play the "ChromeVox On" sound.
580 */
581 EarconEngine.prototype.onChromeVoxOn = function() {
582 this.onChromeVoxSweep(false);
583 };
584
585 /**
586 * Play the "ChromeVox Off" sound.
587 */
588 EarconEngine.prototype.onChromeVoxOff = function() {
589 this.onChromeVoxSweep(true);
590 };
591
592 /**
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});
612 };
613
614 /**
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});
629 };
630
631 /**
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_})]);
650
651 if (this.progressGain_ > this.progressFinalGain) {
652 this.progressGain_ *= this.progressGain_Decay;
653 }
654 t += 0.5;
655
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_})]);
667
668 if (this.progressGain_ > this.progressFinalGain) {
669 this.progressGain_ *= this.progressGain_Decay;
670 }
671
672 this.progressTime_ += 1.0;
673 }
674
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);
681 };
682
683 /**
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);
694 };
695
696 /**
697 * Stop playing any tick / tock progress sounds.
698 */
699 EarconEngine.prototype.cancelProgress = function() {
700 if (!this.progressIntervalID_) {
701 return;
702 }
703
704 for (var i = 0; i < this.progressSources_.length; i++) {
705 this.progressSources_[i][1].stop();
706 }
707 this.progressSources_ = [];
708
709 window.clearInterval(this.progressIntervalID_);
710 this.progressIntervalID_ = null;
711 };
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698