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

Unified 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 side-by-side diff with in-line comments
Download patch
Index: chrome/browser/resources/chromeos/chromevox/cvox2/background/earcon_engine.js
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/earcon_engine.js b/chrome/browser/resources/chromeos/chromevox/cvox2/background/earcon_engine.js
new file mode 100644
index 0000000000000000000000000000000000000000..fc428c548276ea988edbc27fce3e79b4e934fc0f
--- /dev/null
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/earcon_engine.js
@@ -0,0 +1,711 @@
+// Copyright 2015 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview This is the low-level class that generates ChromeVox's
+ * earcons. It's designed to be self-contained and not depend on the
+ * rest of the code.
+ */
+
+goog.provide('EarconEngine');
+
+/**
+ * EarconEngine generates ChromeVox's earcons using the web audio API.
+ * @constructor
+ */
+EarconEngine = function() {
+ // Public control parameters. All of these are meant to be adjustable.
+
+ /** @type {number} The master volume, as an amplification factor. */
+ this.masterVolume = 0.2;
+
+ /** @type {number} The base relative pitch adjustment, in half-steps. */
+ this.masterPitch = -4;
+
+ /** @type {number} The click volume, as an amplification factor. */
+ this.clickVolume = 0.4;
+
+ /**
+ * @type {number} The volume of the static sound, as an
+ * amplification factor.
+ */
+ this.staticVolume = 0.2;
+
+ /** @type {number} The base delay for repeated sounds, in seconds. */
+ this.baseDelay = 0.045;
+
+ /** @type {number} The master stereo panning, from -1 to 1. */
+ this.masterPan = 0;
+
+ /** @type {number} The master reverb level as an amplification factor. */
+ this.masterReverb = 0.4;
+
+ /**
+ * @type {string} The choice of the reverb impulse response to use.
+ * Must be one of the strings from EarconEngine.REVERBS.
+ */
+ this.reverbSound = 'small_room_2';
+
+ /** @type {number} The base pitch for the 'wrap' sound in half-steps. */
+ this.wrapPitch = 0;
+
+ /** @type {number} The base pitch for the 'alert' sound in half-steps. */
+ this.alertPitch = 0;
+
+ /** @type {string} The choice of base sound for most controls. */
+ this.controlSound = 'control';
+
+ /**
+ * @type {number} The delay between sounds in the on/off sweep effect,
+ * in seconds.
+ */
+ this.sweepDelay = 0.045;
+
+ /**
+ * @type {number} The delay between echos in the on/off sweep, in seconds.
+ */
+ this.sweepEchoDelay = 0.15;
+
+ /** @type {number} The number of echos in the on/off sweep. */
+ this.sweepEchoCount = 3;
+
+ /** @type {number} The pitch offset of the on/off sweep, in half-steps. */
+ this.sweepPitch = -7;
+
+ /**
+ * @type {number} The final gain of the progress sound, as an
+ * amplification factor.
+ */
+ this.progressFinalGain = 0.05;
+
+ /** @type {number} The multiplicative decay rate of the progress ticks. */
+ this.progressGain_Decay = 0.7;
+
+ // Private variables.
+
+ /** @type {AudioContext} @private The audio context. */
+ this.context_ = new AudioContext();
+
+ /** @type {?ConvolverNode} @private The reverb node, lazily initialized. */
+ this.reverbConvolver_ = null;
+
+ /**
+ * @type {Object<string, AudioBuffer>} A map between the name of an
+ * audio data file and its loaded AudioBuffer.
+ * @private
+ */
+ this.buffers_ = {};
+
+ /**
+ * The source audio nodes for queued tick / tocks for progress.
+ * Kept around so they can be canceled.
+ *
+ * @type {Array<AudioNode>}
+ * @private
+ */
+ this.progressSources_ = [];
+
+ /** @type {number} The current gain for progress sounds. @private */
+ this.progressGain_ = 1.0;
+
+ /** @type {?number} The current time for progress sounds. @private */
+ this.progressTime_ = this.context_.currentTime;
+
+ /**
+ * @type {?number} The window.setInterval ID for progress sounds.
+ * @private
+ */
+ this.progressIntervalID_ = null;
+
+ // Initialization: load the base sound data files asynchronously.
+ var allSoundFilesToLoad = EarconEngine.SOUNDS.concat(EarconEngine.REVERBS);
+ allSoundFilesToLoad.forEach((function(sound) {
+ var url = EarconEngine.BASE_URL + sound + '.wav';
+ this.loadSound(sound, url);
+ }).bind(this));
+};
+
+/**
+ * @type {Array<string>} The list of sound data files to load.
+ * @const
+ */
+EarconEngine.SOUNDS = [
+ 'control',
+ 'selection',
+ 'selection_reverse',
+ 'skim',
+ 'static'];
+
+/**
+ * @type {Array<string>} The list of reverb data files to load.
+ * @const
+ */
+EarconEngine.REVERBS = [
+ 'small_room_2'];
+
+/**
+ * @type {number} The scale factor for one half-step.
+ * @const
+ */
+EarconEngine.HALF_STEP = Math.pow(2.0, 1.0 / 12.0);
+
+/**
+ * @type {string} The base url for earcon sound resources.
+ * @const
+ */
+EarconEngine.BASE_URL = chrome.extension.getURL('cvox2/background/earcons/');
+
+/**
+ * Fetches a sound asynchronously and loads its data into an AudioBuffer.
+ *
+ * @param {string} name The name of the sound to load.
+ * @param {string} url The url where the sound should be fetched from.
+ */
+EarconEngine.prototype.loadSound = function(name, url) {
+ var request = new XMLHttpRequest();
+ request.open('GET', url, true);
+ request.responseType = 'arraybuffer';
+
+ // Decode asynchronously.
+ request.onload = (function() {
+ this.context_.decodeAudioData(
+ /** @type {ArrayBuffer} */ (request.response),
+ (function(buffer) {
+ this.buffers_[name] = buffer;
+ }).bind(this));
+ }).bind(this);
+ request.send();
+};
+
+/**
+ * Return an AudioNode containing the final processing that all
+ * sounds go through: master volume / gain, panning, and reverb.
+ * The chain is hooked up to the destination automatically, so you
+ * just need to connect your source to the return value from this
+ * method.
+ *
+ * @param {{gain: (number | undefined),
+ * pan: (number | undefined),
+ * reverb: (number | undefined)}} properties
+ * An object where you can override the default
+ * gain, pan, and reverb, otherwise these are taken from
+ * masterVolume, masterPan, and masterReverb.
+ * @return {AudioNode} The filters to be applied to all sounds, connected
+ * to the destination node.
+ */
+EarconEngine.prototype.createCommonFilters = function(properties) {
+ var gain = this.masterVolume;
+ if (properties.gain) {
+ gain *= properties.gain;
+ }
+ var gainNode = this.context_.createGain();
+ gainNode.gain.value = gain;
+ var first = gainNode;
+ var last = gainNode;
+
+ var pan = this.masterPan;
+ if (properties.pan !== undefined) {
+ pan = properties.pan;
+ }
+ if (pan != 0) {
+ var panNode = this.context_.createPanner();
+ panNode.setPosition(pan, 0, -1);
+ panNode.setOrientation(0, 0, 1);
+ last.connect(panNode);
+ last = panNode;
+ }
+
+ var reverb = this.masterReverb;
+ if (properties.reverb !== undefined) {
+ reverb = properties.reverb;
+ }
+ if (reverb) {
+ if (!this.reverbConvolver_) {
+ this.reverbConvolver_ = this.context_.createConvolver();
+ this.reverbConvolver_.buffer = this.buffers_[this.reverbSound];
+ this.reverbConvolver_.connect(this.context_.destination);
+ }
+
+ // Dry
+ last.connect(this.context_.destination);
+
+ // Wet
+ var reverbGainNode = this.context_.createGain();
+ reverbGainNode.gain.value = reverb;
+ last.connect(reverbGainNode);
+ reverbGainNode.connect(this.reverbConvolver_);
+ } else {
+ last.connect(this.context_.destination);
+ }
+
+ return first;
+};
+
+/**
+ * High-level interface to play a sound from a buffer source by name,
+ * with some simple adjustments like pitch change (in half-steps),
+ * a start time (relative to the current time, in seconds),
+ * gain, panning, and reverb.
+ *
+ * The only required parameter is the name of the sound. The time, pitch,
+ * gain, panning, and reverb are all optional and are passed in an
+ * object of optional properties.
+ *
+ * @param {string} sound The name of the sound to play. It must already
+ * be loaded in a buffer.
+ * @param {{pitch: (number | undefined),
+ * time: (number | undefined),
+ * gain: (number | undefined),
+ * pan: (number | undefined),
+ * reverb: (number | undefined)}=} opt_properties
+ * An object where you can override the default pitch, gain, pan,
+ * and reverb.
+ * @return {AudioBufferSourceNode} The source node, so you can stop it
+ * or set event handlers on it.
+ */
+EarconEngine.prototype.play = function(sound, opt_properties) {
+ var source = this.context_.createBufferSource();
+ source.buffer = this.buffers_[sound];
+
+ if (!opt_properties) {
+ // This typecast looks silly, but the Closure compiler doesn't support
+ // optional fields in record types very well so this is the shortest hack.
+ opt_properties = /** @type {undefined} */({});
+ }
+
+ var pitch = this.masterPitch;
+ if (opt_properties.pitch) {
+ pitch += opt_properties.pitch;
+ }
+ if (pitch != 0) {
+ source.playbackRate.value = Math.pow(EarconEngine.HALF_STEP, pitch);
+ }
+
+ var destination = this.createCommonFilters(opt_properties);
+ source.connect(destination);
+
+ if (opt_properties.time) {
+ source.start(this.context_.currentTime + opt_properties.time);
+ } else {
+ source.start(this.context_.currentTime);
+ }
+
+ return source;
+};
+
+/**
+ * Play the static sound.
+ */
+EarconEngine.prototype.onStatic = function() {
+ this.play('static', {gain: this.staticVolume});
+};
+
+/**
+ * Play the link sound.
+ */
+EarconEngine.prototype.onLink = function() {
+ this.play('static', {gain: this.clickVolume});
+ this.play(this.controlSound, {pitch: 12});
+};
+
+/**
+ * Play the button sound.
+ */
+EarconEngine.prototype.onButton = function() {
+ this.play('static', {gain: this.clickVolume});
+ this.play(this.controlSound);
+};
+
+/**
+ * Play the text field sound.
+ */
+EarconEngine.prototype.onTextField = function() {
+ this.play('static', {gain: this.clickVolume});
+ this.play('static', {time: this.baseDelay * 1.5,
+ gain: this.clickVolume * 0.5});
+ this.play(this.controlSound, {pitch: 4});
+ this.play(this.controlSound,
+ {pitch: 4,
+ time: this.baseDelay * 1.5,
+ gain: 0.5});
+};
+
+/**
+ * Play the pop up button sound.
+ */
+EarconEngine.prototype.onPopUpButton = function() {
+ this.play('static', {gain: this.clickVolume});
+
+ this.play(this.controlSound);
+ this.play(this.controlSound,
+ {time: this.baseDelay * 3,
+ gain: 0.2,
+ pitch: 12});
+ this.play(this.controlSound,
+ {time: this.baseDelay * 4.5,
+ gain: 0.2,
+ pitch: 12});
+};
+
+/**
+ * Play the check on sound.
+ */
+EarconEngine.prototype.onCheckOn = function() {
+ this.play('static', {gain: this.clickVolume});
+ this.play(this.controlSound, {pitch: -5});
+ this.play(this.controlSound, {pitch: 7, time: this.baseDelay * 2});
+};
+
+/**
+ * Play the check off sound.
+ */
+EarconEngine.prototype.onCheckOff = function() {
+ this.play('static', {gain: this.clickVolume});
+ this.play(this.controlSound, {pitch: 7});
+ this.play(this.controlSound, {pitch: -5, time: this.baseDelay * 2});
+};
+
+/**
+ * Play the select control sound.
+ */
+EarconEngine.prototype.onSelect = function() {
+ this.play('static', {gain: this.clickVolume});
+ this.play(this.controlSound);
+ this.play(this.controlSound, {time: this.baseDelay});
+ this.play(this.controlSound, {time: this.baseDelay * 2});
+};
+
+/**
+ * Play the slider sound.
+ */
+EarconEngine.prototype.onSlider = function() {
+ this.play('static', {gain: this.clickVolume});
+ this.play(this.controlSound);
+ this.play(this.controlSound,
+ {time: this.baseDelay,
+ gain: 0.5,
+ pitch: 2});
+ this.play(this.controlSound,
+ {time: this.baseDelay * 2,
+ gain: 0.25,
+ pitch: 4});
+ this.play(this.controlSound,
+ {time: this.baseDelay * 3,
+ gain: 0.125,
+ pitch: 6});
+ this.play(this.controlSound,
+ {time: this.baseDelay * 4,
+ gain: 0.0625,
+ pitch: 8});
+};
+
+/**
+ * Play the skim sound.
+ */
+EarconEngine.prototype.onSkim = function() {
+ this.play('skim');
+};
+
+/**
+ * Play the selection sound.
+ */
+EarconEngine.prototype.onSelection = function() {
+ this.play('selection');
+};
+
+/**
+ * Play the selection reverse sound.
+ */
+EarconEngine.prototype.onSelectionReverse = function() {
+ this.play('selection_reverse');
+};
+
+/**
+ * Generate a synthesized musical note based on a sum of sinusoidals shaped
+ * by an envelope, controlled by a number of properties.
+ *
+ * The sound has a frequency of |freq|, or if |endFreq| is specified, does
+ * an exponential ramp from |freq| to |endFreq|.
+ *
+ * If |overtones| is greater than 1, the sound will be mixed with additional
+ * sinusoidals at multiples of |freq|, each one scaled by |overtoneFactor|.
+ * This creates a rounder tone than a pure sine wave.
+ *
+ * The envelope is shaped by the duration |dur|, the attack time |attack|,
+ * and the decay time |decay|, in seconds.
+ *
+ * As with other functions, |pan| and |reverb| can be used to override
+ * masterPan and masterReverb.
+ *
+ * @param {{gain: number,
+ * freq: number,
+ * endFreq: (number | undefined),
+ * time: (number | undefined),
+ * overtones: (number | undefined),
+ * overtoneFactor: (number | undefined),
+ * dur: (number | undefined),
+ * attack: (number | undefined),
+ * decay: (number | undefined),
+ * pan: (number | undefined),
+ * reverb: (number | undefined)}} properties
+ * An object containing the properties that can be used to
+ * control the sound, as described above.
+ */
+EarconEngine.prototype.generateSinusoidal = function(properties) {
+ var envelopeNode = this.context_.createGain();
+ envelopeNode.connect(this.context_.destination);
+
+ var time = properties.time;
+ if (time === undefined) {
+ time = 0;
+ }
+
+ // Generate an oscillator for the frequency corresponding to the specified
+ // frequency, and then additional overtones at multiples of that frequency
+ // scaled by the overtoneFactor. Cue the oscillator to start and stop
+ // based on the start time and specified duration.
+ //
+ // If an end frequency is specified, do an exponential ramp to that end
+ // frequency.
+ var gain = properties.gain;
+ for (var i = 0; i < properties.overtones; i++) {
+ var osc = this.context_.createOscillator();
+ osc.frequency.value = properties.freq * (i + 1);
+
+ if (properties.endFreq) {
+ osc.frequency.setValueAtTime(
+ properties.freq * (i + 1),
+ this.context_.currentTime + time);
+ osc.frequency.exponentialRampToValueAtTime(
+ properties.endFreq * (i + 1),
+ this.context_.currentTime + properties.dur);
+ }
+
+ osc.start(this.context_.currentTime + time);
+ osc.stop(this.context_.currentTime + time + properties.dur);
+
+ var gainNode = this.context_.createGain();
+ gainNode.gain.value = gain;
+ osc.connect(gainNode);
+ gainNode.connect(envelopeNode);
+
+ gain *= properties.overtoneFactor;
+ }
+
+ // Shape the overall sound by an envelope based on the attack and
+ // decay times.
+ envelopeNode.gain.setValueAtTime(0, this.context_.currentTime + time);
+ envelopeNode.gain.linearRampToValueAtTime(
+ 1, this.context_.currentTime + time + properties.attack);
+ envelopeNode.gain.setValueAtTime(
+ 1, this.context_.currentTime + time +
+ properties.dur - properties.decay);
+ envelopeNode.gain.linearRampToValueAtTime(
+ 0, this.context_.currentTime + time + properties.dur);
+
+ // Route everything through the common filters like reverb at the end.
+ var destination = this.createCommonFilters({});
+ envelopeNode.connect(destination);
+};
+
+/**
+ * Play a sweep over a bunch of notes in a scale, with an echo,
+ * for the ChromeVox on or off sounds.
+ *
+ * @param {boolean} reverse Whether to play in the reverse direction.
+ */
+EarconEngine.prototype.onChromeVoxSweep = function(reverse) {
+ var pitches = [-7, -5, 0, 5, 7, 12, 17, 19, 24];
+
+ if (reverse) {
+ pitches.reverse();
+ }
+
+ var attack = 0.015;
+ var dur = pitches.length * this.sweepDelay;
+
+ var destination = this.createCommonFilters({reverb: 2.0});
+ for (var k = 0; k < this.sweepEchoCount; k++) {
+ var envelopeNode = this.context_.createGain();
+ var startTime = this.context_.currentTime + this.sweepEchoDelay * k;
+ var sweepGain = Math.pow(0.3, k);
+ var overtones = 2;
+ var overtoneGain = sweepGain;
+ for (var i = 0; i < overtones; i++) {
+ var osc = this.context_.createOscillator();
+ osc.start(startTime);
+ osc.stop(startTime + dur);
+
+ var gainNode = this.context_.createGain();
+ osc.connect(gainNode);
+ gainNode.connect(envelopeNode);
+
+ for (var j = 0; j < pitches.length; j++) {
+ var freqDecay;
+ if (reverse) {
+ freqDecay = Math.pow(0.75, pitches.length - j);
+ } else {
+ freqDecay = Math.pow(0.75, j);
+ }
+ var gain = overtoneGain * freqDecay;
+ var freq = (i + 1) * 220 *
+ Math.pow(EarconEngine.HALF_STEP, pitches[j] + this.sweepPitch);
+ if (j == 0) {
+ osc.frequency.setValueAtTime(freq, startTime);
+ gainNode.gain.setValueAtTime(gain, startTime);
+ } else {
+ osc.frequency.exponentialRampToValueAtTime(
+ freq, startTime + j * this.sweepDelay);
+ gainNode.gain.linearRampToValueAtTime(
+ gain, startTime + j * this.sweepDelay);
+ }
+ osc.frequency.setValueAtTime(
+ freq, startTime + j * this.sweepDelay + this.sweepDelay - attack);
+ }
+
+ overtoneGain *= 0.1 + 0.2 * k;
+ }
+
+ envelopeNode.gain.setValueAtTime(0, startTime);
+ envelopeNode.gain.linearRampToValueAtTime(1, startTime + this.sweepDelay);
+ envelopeNode.gain.setValueAtTime(1, startTime + dur - attack * 2);
+ envelopeNode.gain.linearRampToValueAtTime(0, startTime + dur);
+ envelopeNode.connect(destination);
+ }
+};
+
+/**
+ * Play the "ChromeVox On" sound.
+ */
+EarconEngine.prototype.onChromeVoxOn = function() {
+ this.onChromeVoxSweep(false);
+};
+
+/**
+ * Play the "ChromeVox Off" sound.
+ */
+EarconEngine.prototype.onChromeVoxOff = function() {
+ this.onChromeVoxSweep(true);
+};
+
+/**
+ * Play an alert sound.
+ */
+EarconEngine.prototype.onAlert = function() {
+ var freq1 = 220 * Math.pow(EarconEngine.HALF_STEP, this.alertPitch - 2);
+ var freq2 = 220 * Math.pow(EarconEngine.HALF_STEP, this.alertPitch - 3);
+ this.generateSinusoidal({attack: 0.02,
+ decay: 0.07,
+ dur: 0.15,
+ gain: 0.3,
+ freq: freq1,
+ overtones: 3,
+ overtoneFactor: 0.1});
+ this.generateSinusoidal({attack: 0.02,
+ decay: 0.07,
+ dur: 0.15,
+ gain: 0.3,
+ freq: freq2,
+ overtones: 3,
+ overtoneFactor: 0.1});
+};
+
+/**
+ * Play a wrap sound.
+ */
+EarconEngine.prototype.onWrap = function() {
+ this.play('static', {gain: this.clickVolume * 0.3});
+ var freq1 = 220 * Math.pow(EarconEngine.HALF_STEP, this.wrapPitch - 8);
+ var freq2 = 220 * Math.pow(EarconEngine.HALF_STEP, this.wrapPitch + 8);
+ this.generateSinusoidal({attack: 0.01,
+ decay: 0.1,
+ dur: 0.15,
+ gain: 0.3,
+ freq: freq1,
+ endFreq: freq2,
+ overtones: 1,
+ overtoneFactor: 0.1});
+};
+
+/**
+ * Queue up a few tick/tock sounds for a progress bar. This is called
+ * repeatedly by setInterval to keep the sounds going continuously.
+ * @private
+ */
+EarconEngine.prototype.generateProgressTickTocks_ = function() {
+ while (this.progressTime_ < this.context_.currentTime + 3.0) {
+ var t = this.progressTime_ - this.context_.currentTime;
+ this.progressSources_.push(
+ [this.progressTime_,
+ this.play('static',
+ {gain: 0.5 * this.progressGain_,
+ time: t})]);
+ this.progressSources_.push(
+ [this.progressTime_,
+ this.play(this.controlSound,
+ {pitch: 20,
+ time: t,
+ gain: this.progressGain_})]);
+
+ if (this.progressGain_ > this.progressFinalGain) {
+ this.progressGain_ *= this.progressGain_Decay;
+ }
+ t += 0.5;
+
+ this.progressSources_.push(
+ [this.progressTime_,
+ this.play('static',
+ {gain: 0.5 * this.progressGain_,
+ time: t})]);
+ this.progressSources_.push(
+ [this.progressTime_,
+ this.play(this.controlSound,
+ {pitch: 8,
+ time: t,
+ gain: this.progressGain_})]);
+
+ if (this.progressGain_ > this.progressFinalGain) {
+ this.progressGain_ *= this.progressGain_Decay;
+ }
+
+ this.progressTime_ += 1.0;
+ }
+
+ var removeCount = 0;
+ while (removeCount < this.progressSources_.length &&
+ this.progressSources_[removeCount][0] < this.context_.currentTime - 0.2) {
+ removeCount++;
+ }
+ this.progressSources_.splice(0, removeCount);
+};
+
+/**
+ * Start playing tick / tock progress sounds continuously until
+ * explicitly canceled.
+ */
+EarconEngine.prototype.startProgress = function() {
+ this.progressSources_ = [];
+ this.progressGain_ = 0.5;
+ this.progressTime_ = this.context_.currentTime;
+ this.generateProgressTickTocks_();
+ this.progressIntervalID_ = window.setInterval(
+ this.generateProgressTickTocks_.bind(this), 1000);
+};
+
+/**
+ * Stop playing any tick / tock progress sounds.
+ */
+EarconEngine.prototype.cancelProgress = function() {
+ if (!this.progressIntervalID_) {
+ return;
+ }
+
+ for (var i = 0; i < this.progressSources_.length; i++) {
+ this.progressSources_[i][1].stop();
+ }
+ this.progressSources_ = [];
+
+ window.clearInterval(this.progressIntervalID_);
+ this.progressIntervalID_ = null;
+};

Powered by Google App Engine
This is Rietveld 408576698