| // Copyright 2015 The Chromium Authors |
| // 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. |
| */ |
| |
| import {EarconId} from '../common/earcon_id.js'; |
| |
| interface PlayProperties { |
| pitch?: number; |
| time?: number; |
| gain?: number; |
| loop?: boolean; |
| pan?: number; |
| reverb?: number; |
| } |
| |
| interface GenerateSinusoidalProperties {gain: number; |
| freq: number; |
| endFreq?: number; |
| time?: number; |
| overtones?: number; |
| overtoneFactor?: number; |
| dur?: number; |
| attack?: number; |
| decay?: number; |
| pan?: number; |
| reverb?: number; |
| } |
| |
| /** EarconEngine generates ChromeVox's earcons using the web audio API. */ |
| export class EarconEngine { |
| |
| // Public control parameters. All of these are meant to be adjustable. |
| |
| /** The output volume, as an amplification factor. */ |
| outputVolume = 1.0; |
| |
| /** |
| * As notated below, all pitches are in the key of C. This can be set to |
| * transpose the key from C to another pitch. |
| */ |
| transposeToKey = Note.B_FLAT3; |
| |
| /** The click volume, as an amplification factor. */ |
| clickVolume = 0.4; |
| |
| /** |
| * The volume of the static sound, as an amplification factor. |
| */ |
| staticVolume = 0.2; |
| |
| /** The base delay for repeated sounds, in seconds. */ |
| baseDelay = 0.045; |
| |
| /** The base stereo panning, from -1 to 1. */ |
| basePan = CENTER_PAN; |
| |
| /** The base reverb level as an amplification factor. */ |
| baseReverb = 0.4; |
| |
| /** The choice of the reverb impulse response to use. */ |
| reverbSound = Reverb.SMALL_ROOM; |
| |
| /** The base pitch for the 'wrap' sound. */ |
| wrapPitch = Note.G_FLAT3; |
| |
| /** The base pitch for the 'alert' sound. */ |
| alertPitch = Note.G_FLAT3; |
| |
| /** The default pitch. */ |
| defaultPitch = Note.G3; |
| |
| /** The choice of base sound for most controls. */ |
| controlSound = WavSoundFile.CONTROL; |
| |
| /** |
| * The delay between sounds in the on/off sweep effect, |
| * in seconds. |
| */ |
| sweepDelay = 0.045; |
| |
| /** |
| * The delay between echos in the on/off sweep, in seconds. |
| */ |
| sweepEchoDelay = 0.15; |
| |
| /** The number of echos in the on/off sweep. */ |
| sweepEchoCount = 3; |
| |
| /** The pitch offset of the on/off sweep. */ |
| sweepPitch = Note.C3; |
| |
| /** |
| * The final gain of the progress sound, as an |
| * amplification factor. |
| */ |
| progressFinalGain = 0.05; |
| |
| /** The multiplicative decay rate of the progress ticks. */ |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| progressGain_Decay = 0.7; |
| |
| // Private variables. |
| |
| /** The audio context. */ |
| private context_ = new AudioContext(); |
| |
| /** The reverb node, lazily initialized. */ |
| private reverbConvolver_: ConvolverNode | null = null; |
| |
| /** |
| * @private A map between the name of an audio data file and its loaded AudioBuffer. |
| */ |
| private buffers_: { [key: string]: AudioBuffer } = {}; |
| |
| private loops_: { [key: string]: AudioBufferSourceNode } = {}; |
| |
| /** |
| * The source audio nodes for queued tick / tocks for progress. |
| * Kept around so they can be canceled. |
| */ |
| private progressSources_: Array<[number,AudioNode]> = []; |
| |
| /** The current gain for progress sounds. */ |
| private progressGain_ = 1.0; |
| |
| /** The current time for progress sounds. */ |
| private progressTime_?: number = this.context_.currentTime; |
| |
| /** The setInterval ID for progress sounds. */ |
| private progressIntervalID_: number | null = null; |
| |
| /** |
| * Maps a earcon name to the last source input audio for that |
| * earcon. |
| */ |
| private lastEarconSources_: Partial<Record<EarconId, AudioNode>> = {}; |
| |
| private currentTrackedEarcon_?: EarconId; |
| |
| constructor() { |
| |
| // Initialization: load the base sound data files asynchronously. |
| Object.values(WavSoundFile) |
| .concat(Object.values(Reverb)) |
| .forEach(sound => this.loadSound(sound, `${BASE_URL}${sound}.wav`)); |
| Object.values(OggSoundFile) |
| .forEach(sound => this.loadSound(sound, `${BASE_URL}${sound}.ogg`)); |
| } |
| |
| /** |
| * A high-level way to ask the engine to play a specific earcon. |
| * @param earcon The earcon to play. |
| */ |
| playEarcon(earcon: EarconId): void { |
| // These earcons are not tracked by the engine via their audio sources. |
| switch (earcon) { |
| case EarconId.CHROMEVOX_LOADED: |
| this.onChromeVoxLoaded(); |
| return; |
| case EarconId.CHROMEVOX_LOADING: |
| this.onChromeVoxLoading(); |
| return; |
| case EarconId.PAGE_FINISH_LOADING: |
| this.cancelProgress(); |
| return; |
| case EarconId.PAGE_START_LOADING: |
| this.startProgress(); |
| return; |
| case EarconId.POP_UP_BUTTON: |
| this.onPopUpButton(); |
| return; |
| |
| // These had earcons in previous versions of ChromeVox but |
| // they're currently unused / unassigned. |
| case EarconId.LIST_ITEM: |
| case EarconId.LONG_DESC: |
| case EarconId.MATH: |
| case EarconId.OBJECT_CLOSE: |
| case EarconId.OBJECT_ENTER: |
| case EarconId.OBJECT_EXIT: |
| case EarconId.OBJECT_OPEN: |
| case EarconId.OBJECT_SELECT: |
| case EarconId.RECOVER_FOCUS: |
| return; |
| } |
| |
| // These earcons are tracked by the engine via their audio sources. |
| if (this.lastEarconSources_[earcon] !== undefined) { |
| // Playback of |earcon| is in progress. |
| return; |
| } |
| |
| this.currentTrackedEarcon_ = earcon; |
| switch (earcon) { |
| case EarconId.ALERT_MODAL: |
| case EarconId.ALERT_NONMODAL: |
| this.onAlert(); |
| break; |
| case EarconId.BUTTON: |
| this.onButton(); |
| break; |
| case EarconId.CHECK_OFF: |
| this.onCheckOff(); |
| break; |
| case EarconId.CHECK_ON: |
| this.onCheckOn(); |
| break; |
| case EarconId.EDITABLE_TEXT: |
| this.onTextField(); |
| break; |
| case EarconId.INVALID_KEYPRESS: |
| this.onInvalidKeypress(); |
| break; |
| case EarconId.LINK: |
| this.onLink(); |
| break; |
| case EarconId.LISTBOX: |
| this.onSelect(); |
| break; |
| case EarconId.SELECTION: |
| this.onSelection(); |
| break; |
| case EarconId.SELECTION_REVERSE: |
| this.onSelectionReverse(); |
| break; |
| case EarconId.SKIP: |
| this.onSkim(); |
| break; |
| case EarconId.SLIDER: |
| this.onSlider(); |
| break; |
| case EarconId.SMART_STICKY_MODE_OFF: |
| this.onSmartStickyModeOff(); |
| break; |
| case EarconId.SMART_STICKY_MODE_ON: |
| this.onSmartStickyModeOn(); |
| break; |
| case EarconId.NO_POINTER_ANCHOR: |
| this.onNoPointerAnchor(); |
| break; |
| case EarconId.WRAP: |
| case EarconId.WRAP_EDGE: |
| this.onWrap(); |
| break; |
| } |
| this.currentTrackedEarcon_ = undefined; |
| |
| // Clear source once it finishes playing. |
| const source = this.lastEarconSources_[earcon]; |
| if (source !== undefined && |
| (source as any) instanceof AudioScheduledSourceNode) { |
| (source as AudioScheduledSourceNode).onended = () => { |
| delete this.lastEarconSources_[earcon]; |
| }; |
| } |
| } |
| |
| /** |
| * Fetches a sound asynchronously and loads its data into an AudioBuffer. |
| * |
| * @param name The name of the sound to load. |
| * @param url The url where the sound should be fetched from. |
| */ |
| async loadSound(name: string, url: string): Promise<void> { |
| const response = await fetch(url); |
| if (response.ok) { |
| const arrayBuffer = await response.arrayBuffer(); |
| const decodedAudio = await this.context_.decodeAudioData(arrayBuffer); |
| this.buffers_[name] = decodedAudio; |
| } |
| } |
| |
| /** |
| * Return an AudioNode containing the final processing that all |
| * sounds go through: output 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 properties |
| * An object where you can override the default |
| * gain, pan, and reverb, otherwise these are taken from |
| * outputVolume, basePan, and baseReverb. |
| * @return The filters to be applied to all sounds, connected |
| * to the destination node. |
| */ |
| createCommonFilters(properties: { gain?: number, pan?: number, reverb?: number }): AudioNode { |
| let gain = this.outputVolume; |
| if (properties.gain) { |
| gain *= properties.gain; |
| } |
| const gainNode = this.context_.createGain(); |
| gainNode.gain.value = gain; |
| const first = gainNode; |
| let last: AudioNode = gainNode; |
| |
| const pan = properties.pan ?? this.basePan; |
| if (pan !== 0) { |
| const panNode = this.context_.createPanner(); |
| panNode.setPosition(pan, 0, 0); |
| panNode.setOrientation(0, 0, 1); |
| last.connect(panNode); |
| last = panNode; |
| } |
| |
| const reverb = properties.reverb ?? this.baseReverb; |
| 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 |
| const 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 sound The name of the sound to play. It must already |
| * be loaded in a buffer. |
| * @param properties |
| * An object where you can override the default pitch, gain, pan, |
| * and reverb. |
| * @return The source node, so you can stop it |
| * or set event handlers on it. |
| */ |
| play(sound: string, properties: PlayProperties = {}): AudioBufferSourceNode { |
| const source = this.context_.createBufferSource(); |
| source.buffer = this.buffers_[sound]; |
| if (properties.loop) { |
| this.loops_[sound] = source; |
| } |
| |
| const pitch = properties.pitch ?? this.defaultPitch; |
| // Changes the playback rate of the sample – which also changes the pitch. |
| source.playbackRate.value = this.multiplierFor_(pitch); |
| source.loop = properties.loop ?? false; |
| |
| const destination = this.createCommonFilters(properties); |
| source.connect(destination); |
| if (this.currentTrackedEarcon_) { |
| this.lastEarconSources_[this.currentTrackedEarcon_] = source; |
| } |
| if (properties.time) { |
| source.start(this.context_.currentTime + properties.time); |
| } else { |
| source.start(this.context_.currentTime); |
| } |
| |
| return source; |
| } |
| |
| /** |
| * Stops the loop of the specified sound file, if one exists. |
| * @param sound The name of the sound file. |
| */ |
| stopLoop(sound: string): void { |
| if (!this.loops_[sound]) { |
| return; |
| } |
| |
| this.loops_[sound].stop(); |
| delete this.loops_[sound]; |
| } |
| |
| /** Play the static sound. */ |
| onStatic(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.staticVolume }); |
| } |
| |
| /** Play the link sound. */ |
| onLink(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume }); |
| this.play(this.controlSound, { pitch: Note.G4 }); |
| } |
| |
| /** Play the button sound. */ |
| onButton(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume }); |
| this.play(this.controlSound); |
| } |
| |
| /** Play the text field sound. */ |
| onTextField(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume }); |
| this.play( |
| WavSoundFile.STATIC, |
| { time: this.baseDelay * 1.5, gain: this.clickVolume * 0.5 }); |
| this.play(this.controlSound, { pitch: Note.B3 }); |
| this.play( |
| this.controlSound, |
| { pitch: Note.B3, time: this.baseDelay * 1.5, gain: 0.5 }); |
| } |
| |
| /** Play the pop up button sound. */ |
| onPopUpButton(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume }); |
| |
| this.play(this.controlSound); |
| this.play( |
| this.controlSound, |
| { time: this.baseDelay * 3, gain: 0.2, pitch: Note.G4 }); |
| this.play( |
| this.controlSound, |
| { time: this.baseDelay * 4.5, gain: 0.2, pitch: Note.G4 }); |
| } |
| |
| /** Play the check on sound. */ |
| onCheckOn(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume }); |
| this.play(this.controlSound, { pitch: Note.D3 }); |
| this.play(this.controlSound, { pitch: Note.D4, time: this.baseDelay * 2 }); |
| } |
| |
| /** Play the check off sound. */ |
| onCheckOff(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume }); |
| this.play(this.controlSound, { pitch: Note.D4 }); |
| this.play(this.controlSound, { pitch: Note.D3, time: this.baseDelay * 2 }); |
| } |
| |
| /** Play the smart sticky mode on sound. */ |
| onSmartStickyModeOn(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume * 0.5 }); |
| this.play(this.controlSound, { pitch: Note.D4 }); |
| } |
| |
| /** Play the smart sticky mode off sound. */ |
| onSmartStickyModeOff(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume * 0.5 }); |
| this.play(this.controlSound, { pitch: Note.D3 }); |
| } |
| |
| /** Play the select control sound. */ |
| onSelect(): void { |
| this.play(WavSoundFile.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. */ |
| onSlider(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume }); |
| this.play(this.controlSound); |
| this.play( |
| this.controlSound, { time: this.baseDelay, gain: 0.5, pitch: Note.A3 }); |
| this.play( |
| this.controlSound, |
| { time: this.baseDelay * 2, gain: 0.25, pitch: Note.B3 }); |
| this.play( |
| this.controlSound, |
| { time: this.baseDelay * 3, gain: 0.125, pitch: Note.D_FLAT4 }); |
| this.play( |
| this.controlSound, |
| { time: this.baseDelay * 4, gain: 0.0625, pitch: Note.E_FLAT4 }); |
| } |
| |
| /** Play the skim sound. */ |
| onSkim(): void { |
| this.play(WavSoundFile.SKIM); |
| } |
| |
| /** Play the selection sound. */ |
| onSelection(): void { |
| this.play(OggSoundFile.SELECTION); |
| } |
| |
| /** Play the selection reverse sound. */ |
| onSelectionReverse(): void { |
| this.play(OggSoundFile.SELECTION_REVERSE); |
| } |
| |
| /** Play the invalid keypress sound. */ |
| onInvalidKeypress(): void { |
| this.play(OggSoundFile.INVALID_KEYPRESS); |
| } |
| |
| onNoPointerAnchor(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume * 0.2 }); |
| const freq1 = this.frequencyFor_(Note.A_FLAT4); |
| this.generateSinusoidal({ |
| attack: 0.00001, |
| decay: 0.01, |
| dur: 0.1, |
| gain: 0.008, |
| freq: freq1, |
| overtones: 1, |
| overtoneFactor: 0.1, |
| }); |
| } |
| |
| /** |
| * 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 |
| * basePan and baseReverb. |
| * |
| * @param properties |
| * An object containing the properties that can be used to |
| * control the sound, as described above. |
| */ |
| generateSinusoidal(properties:GenerateSinusoidalProperties): void { |
| const envelopeNode = this.context_.createGain(); |
| envelopeNode.connect(this.context_.destination); |
| |
| const time = properties.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. |
| let gain = properties.gain; |
| // TODO(b/314203187): Determine if not null assertion is acceptable. |
| for (let i = 0; i < properties.overtones!; i++) { |
| const osc = this.context_.createOscillator(); |
| if (this.currentTrackedEarcon_) { |
| this.lastEarconSources_[this.currentTrackedEarcon_] = osc; |
| } |
| 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!); |
| |
| const 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. |
| // TODO(b/314203187): Determine if not null assertion is acceptable. |
| 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. |
| const destination = this.createCommonFilters({}); |
| envelopeNode.connect(destination); |
| } |
| |
| /** Play an alert sound. */ |
| onAlert(): void { |
| const freq1 = this.frequencyFor_(this.alertPitch - 2); |
| const freq2 = this.frequencyFor_(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, |
| }); |
| |
| this.currentTrackedEarcon_ = undefined; |
| } |
| |
| /** Play a wrap sound. */ |
| onWrap(): void { |
| this.play(WavSoundFile.STATIC, { gain: this.clickVolume * 0.3 }); |
| const freq1 = this.frequencyFor_(this.wrapPitch - 8); |
| const freq2 = this.frequencyFor_(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 generateProgressTickTocks_(): void { |
| // TODO(b/314203187): Determine if not null assertion is acceptable. |
| this.progressTime_ = this.progressTime_!; |
| while (this.progressTime_ < this.context_.currentTime + 3.0) { |
| let t = this.progressTime_ - this.context_.currentTime; |
| this.progressSources_.push([ |
| this.progressTime_, |
| this.play( |
| WavSoundFile.STATIC, { gain: 0.5 * this.progressGain_, time: t }), |
| ]); |
| this.progressSources_.push([ |
| this.progressTime_, |
| this.play( |
| this.controlSound, |
| { pitch: Note.E_FLAT5, 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( |
| WavSoundFile.STATIC, { gain: 0.5 * this.progressGain_, time: t }), |
| ]); |
| this.progressSources_.push([ |
| this.progressTime_, |
| this.play( |
| this.controlSound, |
| { pitch: Note.E_FLAT4, time: t, gain: this.progressGain_ }), |
| ]); |
| |
| if (this.progressGain_ > this.progressFinalGain) { |
| this.progressGain_ *= this.progressGain_Decay; |
| } |
| |
| this.progressTime_! += 1.0; |
| } |
| |
| let 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. |
| */ |
| startProgress(): void { |
| if (this.progressIntervalID_) { |
| this.cancelProgress(); |
| } |
| |
| this.progressSources_ = []; |
| this.progressGain_ = 0.5; |
| this.progressTime_ = this.context_.currentTime; |
| this.generateProgressTickTocks_(); |
| this.progressIntervalID_ = |
| setInterval(() => this.generateProgressTickTocks_(), 1000); |
| } |
| |
| /** Stop playing any tick / tock progress sounds. */ |
| cancelProgress(): void { |
| if (!this.progressIntervalID_) { |
| return; |
| } |
| |
| for (let i = 0; i < this.progressSources_.length; i++) { |
| (this.progressSources_[i][1] as AudioScheduledSourceNode).stop(); |
| } |
| this.progressSources_ = []; |
| |
| clearInterval(this.progressIntervalID_); |
| this.progressIntervalID_ = null; |
| } |
| |
| /** Plays sound indicating ChromeVox is loading. */ |
| onChromeVoxLoading(): void { |
| this.play(OggSoundFile.CHROMEVOX_LOADING, { loop: true }); |
| } |
| |
| /** |
| * Plays the sound indicating ChromeVox has loaded, and cancels the ChromeVox |
| * loading sound. |
| */ |
| onChromeVoxLoaded(): void { |
| this.stopLoop(OggSoundFile.CHROMEVOX_LOADING); |
| this.play(OggSoundFile.CHROMEVOX_LOADED); |
| } |
| |
| /** |
| * @param {chrome.automation.Rect} rect |
| * @param {chrome.automation.Rect} container |
| */ |
| setPositionForRect(rect: chrome.automation.Rect, container: chrome.automation.Rect): void { |
| // The horizontal position computed as a percentage relative to its |
| // container. |
| let x = (rect.left + rect.width / 2) / container.width; |
| |
| // Clamp. |
| x = Math.min(Math.max(x, 0.0), 1.0); |
| |
| // Map to between the negative maximum pan x position and the positive max x |
| // pan position. |
| x = (2 * x - 1) * MAX_PAN_ABS_X_POSITION; |
| |
| this.basePan = x; |
| } |
| |
| /** Resets panning to default (centered). */ |
| resetPan(): void { |
| this.basePan = CENTER_PAN; |
| } |
| |
| private multiplierFor_(note: Note | number): number { |
| const halfStepsFromA220 = note + HALF_STEPS_TO_C + this.transposeToKey; |
| return Math.pow(HALF_STEP, halfStepsFromA220); |
| } |
| |
| private frequencyFor_(note: Note | number): number { |
| return A3_HZ * this.multiplierFor_(note); |
| } |
| } |
| |
| // Local to module. |
| |
| /* The list of sound data files to load. */ |
| const WavSoundFile = { |
| CONTROL: 'control', |
| SKIM: 'skim', |
| STATIC: 'static', |
| }; |
| |
| /* The list of sound data files to load. */ |
| const OggSoundFile = { |
| CHROMEVOX_LOADED: 'chromevox_loaded', |
| CHROMEVOX_LOADING: 'chromevox_loading', |
| INVALID_KEYPRESS: 'invalid_keypress', |
| SELECTION: 'selection', |
| SELECTION_REVERSE: 'selection_reverse', |
| }; |
| |
| /** The list of reverb data files to load. */ |
| const Reverb = { |
| SMALL_ROOM: 'small_room_2', |
| }; |
| |
| /** Pitch values for different notes. */ |
| enum Note { |
| C2 = -24, |
| D_FLAT2 = -23, |
| D2 = -22, |
| E_FLAT2 = -21, |
| E2 = -20, |
| F2 = -19, |
| G_FLAT2 = -18, |
| G2 = -17, |
| A_FLAT2 = -16, |
| A2 = -15, |
| B_FLAT2 = -14, |
| B2 = -13, |
| C3 = -12, |
| D_FLAT3 = -11, |
| D3 = -10, |
| E_FLAT3 = -9, |
| E3 = -8, |
| F3 = -7, |
| G_FLAT3 = -6, |
| G3 = -5, |
| A_FLAT3 = -4, |
| A3 = -3, |
| B_FLAT3 = -2, |
| B3 = -1, |
| C4 = 0, |
| D_FLAT4 = 1, |
| D4 = 2, |
| E_FLAT4 = 3, |
| E4 = 4, |
| F4 = 5, |
| G_FLAT4 = 6, |
| G4 = 7, |
| A_FLAT4 = 8, |
| A4 = 9, |
| B_FLAT4 = 10, |
| B4 = 11, |
| C5 = 12, |
| D_FLAT5 = 13, |
| D5 = 14, |
| E_FLAT5 = 15, |
| E5 = 16, |
| F5 = 17, |
| G_FLAT5 = 18, |
| G5 = 19, |
| A_FLAT5 = 20, |
| A5 = 21, |
| B_FLAT5 = 22, |
| B5 = 23, |
| C6 = 24, |
| } |
| |
| /** The number of half-steps in an octave. */ |
| const HALF_STEPS_PER_OCTAVE = 12; |
| |
| /** |
| * The number of half-steps from the base pitch (A220Hz) to C4 (middle C). |
| */ |
| const HALF_STEPS_TO_C = 3; |
| |
| /**The scale factor for one half-step. */ |
| const HALF_STEP = Math.pow(2.0, 1.0 / HALF_STEPS_PER_OCTAVE); |
| |
| /**The frequency of the note A3, in Hertz. */ |
| const A3_HZ = 220; |
| |
| /** The base url for earcon sound resources. */ |
| const BASE_URL = chrome.extension.getURL('chromevox/earcons/'); |
| |
| /**The maximum value to pass to PannerNode.setPosition. */ |
| const MAX_PAN_ABS_X_POSITION = 4; |
| |
| /**Default (centered) pan position. */ |
| const CENTER_PAN = 0; |