Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
217 lines
7.4 KiB
JavaScript
Executable file
217 lines
7.4 KiB
JavaScript
Executable file
/* globals AudioContext */
|
|
|
|
/* exported TonePlayer */
|
|
|
|
'use strict';
|
|
|
|
const kMasterVolume = 0.5;
|
|
const kToneVolume = 0.7;
|
|
const kShortPressDuration = 0.15;
|
|
const kAttackDuration = 0.025;
|
|
const kDecayDuration = 0.025;
|
|
const kReleaseDuration = 0.05;
|
|
|
|
var TonePlayer = {
|
|
_audioContext: null,
|
|
_channel: null,
|
|
_gainNode: null,
|
|
_playingNodes: [],
|
|
_maybeTrashAudio: null,
|
|
_initialized: false,
|
|
|
|
/**
|
|
* Initializes the tone player by specifying which channel will be used to
|
|
* play sounds. The TonePlayer will lazily create an AudioContext to play
|
|
* sounds when needed and automatically dispose of it when the application is
|
|
* hidden. However if the 'telephony' channel is used then we'll keep the
|
|
* AudioContext around as long as there's an active call.
|
|
*
|
|
* @param channel {String} The default channel used to play sounds.
|
|
*/
|
|
init: function tp_init(channel) {
|
|
var telephony = navigator.mozTelephony;
|
|
|
|
this._reset();
|
|
this._channel = channel;
|
|
this._maybeTrashAudio = (function tp_maybeTrashAudio() {
|
|
var callIsActive = telephony && (telephony.calls.length ||
|
|
telephony.conferenceGroup.calls.length);
|
|
|
|
/* If the application is hidden dispose of the audio context unless we're
|
|
* in a call and we're using the 'telephony' channel. */
|
|
if (document.hidden &&
|
|
!((this._channel === 'telephony') && callIsActive)) {
|
|
this._trashAudio();
|
|
}
|
|
}).bind(this);
|
|
|
|
window.addEventListener('visibilitychange', this._maybeTrashAudio);
|
|
telephony && telephony.addEventListener('callschanged',
|
|
this._maybeTrashAudio);
|
|
|
|
this._initialized = true;
|
|
},
|
|
|
|
/**
|
|
* Tears down the tone player and removes the registered event listeners,
|
|
* mostly used for unit-testing.
|
|
*/
|
|
teardown: function tp_teardown() {
|
|
var telephony = navigator.mozTelephony;
|
|
|
|
telephony && telephony.removeEventListener('callschanged',
|
|
this._maybeTrashAudio);
|
|
window.removeEventListener('visibilitychange', this._maybeTrashAudio);
|
|
this._reset();
|
|
this._initialized = false;
|
|
},
|
|
|
|
/**
|
|
* Reset all internal state to its default value.
|
|
*/
|
|
_reset: function tp_reset() {
|
|
this._audioContext = null;
|
|
this._channel = null;
|
|
this._gainNode = null;
|
|
this._playingNodes = [];
|
|
this._maybeTrashAudio = null;
|
|
},
|
|
|
|
_ensureAudio: function tp_ensureAudio() {
|
|
if (this._audioContext || !this._initialized) {
|
|
return;
|
|
}
|
|
|
|
this._audioContext = new AudioContext();
|
|
},
|
|
|
|
_trashAudio: function tp_trashAudio() {
|
|
this.stop();
|
|
this._audioContext = null;
|
|
},
|
|
|
|
// Pass 0.0 for |when| to play as soon as possible.
|
|
// Pass 0.0 for |duration| to make the tone play until stop is called.
|
|
_startAt: function tp_startAt(frequencies, when, duration) {
|
|
var context = this._audioContext;
|
|
var sampleRate = context.sampleRate;
|
|
var envelope =
|
|
context.createBuffer(1, (duration ? duration : 0.05) * sampleRate,
|
|
sampleRate);
|
|
// ADSR
|
|
for (var i = 0; i < envelope.length; i++) {
|
|
var factor = kToneVolume;
|
|
var t = i / sampleRate;
|
|
if (t <= kAttackDuration) {
|
|
factor = t / kAttackDuration;
|
|
} else if (t - kAttackDuration <= kDecayDuration) {
|
|
factor = 1.0 - (1.0 - kToneVolume) *
|
|
(t - kAttackDuration) / kDecayDuration;
|
|
}
|
|
if (!duration) {
|
|
// The envelope buffer contains the difference from the sustain value
|
|
factor -= kToneVolume;
|
|
} else if (t > duration - kReleaseDuration) {
|
|
factor *= (duration - t) / kReleaseDuration;
|
|
}
|
|
envelope.getChannelData(0)[i] = factor * kMasterVolume;
|
|
}
|
|
|
|
var gainNode = context.createGain();
|
|
gainNode.connect(context.destination);
|
|
if (!duration) {
|
|
// For long presses, the gainNode will be used to release the tone.
|
|
this._gainNode = gainNode;
|
|
}
|
|
|
|
var envelopeNode = context.createBufferSource();
|
|
envelopeNode.buffer = envelope;
|
|
envelopeNode.start(when);
|
|
envelopeNode.connect(gainNode.gain);
|
|
// Set the gain which will be summed with the envelope buffer values
|
|
// and will be the constant gain at the end of the tone envelope. For
|
|
// tones with duration, the envelope covers the entire tone, so the gain
|
|
// at the end is zero. For tones with no duration, the envelope covers
|
|
// only the attack and delay phases after which the gain is the sustain
|
|
// value.
|
|
gainNode.gain.setValueAtTime(duration ? 0.0 : kToneVolume * kMasterVolume,
|
|
0.0);
|
|
|
|
for (i = 0; i < frequencies.length; ++i) {
|
|
var oscNode = this._audioContext.createOscillator();
|
|
oscNode.type = 'sine';
|
|
oscNode.frequency.value = frequencies[i];
|
|
oscNode.start(when);
|
|
if (duration) {
|
|
// If starting immediately, then add some extra time to allow the tone
|
|
// to start, so that the tone doesn't stop short of the end of the
|
|
// envelope.
|
|
oscNode.stop(Math.max(when, context.currentTime + 0.5) + duration);
|
|
} else {
|
|
this._playingNodes.push(oscNode);
|
|
}
|
|
oscNode.connect(gainNode);
|
|
}
|
|
},
|
|
|
|
start: function tp_start(frequencies, shortPress) {
|
|
this._ensureAudio();
|
|
this._startAt(frequencies, 0, shortPress ? kShortPressDuration : 0);
|
|
},
|
|
|
|
stop: function tp_stop() {
|
|
if (!this._gainNode) {
|
|
return;
|
|
}
|
|
var context = this._audioContext;
|
|
var sampleRate = context.sampleRate;
|
|
var gain = this._gainNode.gain;
|
|
this._gainNode = null;
|
|
|
|
var ramp =
|
|
context.createBuffer(1, kReleaseDuration * sampleRate, sampleRate);
|
|
for (var i = 0; i < ramp.length; i++) {
|
|
ramp.getChannelData(0)[i] =
|
|
(ramp.length - i - 1) / ramp.length * kToneVolume * kMasterVolume;
|
|
}
|
|
|
|
var rampNode = context.createBufferSource();
|
|
rampNode.buffer = ramp;
|
|
rampNode.start();
|
|
rampNode.connect(gain);
|
|
// Change the current base gain from kToneVolume * kMasterVolume to 0,
|
|
// cancelling the initial change from adding the ramp.
|
|
gain.setValueAtTime(0.0, 0.0);
|
|
|
|
// Stop the oscillators some time after the release ramp reaches 0.
|
|
// Some extra time is included to allow the release to start.
|
|
while (this._playingNodes.length) {
|
|
this._playingNodes.pop().
|
|
stop(context.currentTime + kReleaseDuration + 0.5);
|
|
}
|
|
},
|
|
|
|
// Takes an array of steps, a step being:
|
|
// - frequency for channel 1
|
|
// - frequency for channel 2
|
|
// - duration
|
|
playSequence: function tp_playSequence(sequence) {
|
|
this._ensureAudio();
|
|
// AudioContext.currentTime is the last time received on the main thread
|
|
// from the audio graph. Trying to start a tone at currentTime will not
|
|
// start playing until this main thread returns to the event loop and
|
|
// sends a message to the audio graph thread. Allow a little time for
|
|
// this. The way Gecko processes audio in chunks of 30ms means this
|
|
// should be at least 60 ms. Experiments on Buri indicate that in
|
|
// practice this delay is 165 to 190 ms, much of which is the code that
|
|
// runs before returning to the event loop.
|
|
var when = this._audioContext.currentTime + 0.2;
|
|
for (var index = 0; index < sequence.length; ++index) {
|
|
var step = sequence[index];
|
|
var frequencies = step.slice(0, 2);
|
|
var duration = step[2] / 1000;
|
|
this._startAt(frequencies, when, duration);
|
|
when += duration;
|
|
}
|
|
}
|
|
};
|