UNPKG

8.49 kBJavaScriptView Raw
1const StartAudioContext = require('./StartAudioContext');
2const AudioContext = require('audio-context');
3
4const log = require('./log');
5const uid = require('./uid');
6
7const ADPCMSoundDecoder = require('./ADPCMSoundDecoder');
8const Loudness = require('./Loudness');
9const SoundPlayer = require('./SoundPlayer');
10
11const EffectChain = require('./effects/EffectChain');
12const PanEffect = require('./effects/PanEffect');
13const PitchEffect = require('./effects/PitchEffect');
14const VolumeEffect = require('./effects/VolumeEffect');
15
16const SoundBank = require('./SoundBank');
17
18/**
19 * Wrapper to ensure that audioContext.decodeAudioData is a promise
20 * @param {object} audioContext The current AudioContext
21 * @param {ArrayBuffer} buffer Audio data buffer to decode
22 * @return {Promise} A promise that resolves to the decoded audio
23 */
24const decodeAudioData = function (audioContext, buffer) {
25 // Check for newer promise-based API
26 if (audioContext.decodeAudioData.length === 1) {
27 return audioContext.decodeAudioData(buffer);
28 }
29 // Fall back to callback API
30 return new Promise((resolve, reject) => {
31 audioContext.decodeAudioData(buffer,
32 decodedAudio => resolve(decodedAudio),
33 error => reject(error)
34 );
35 });
36};
37
38/**
39 * There is a single instance of the AudioEngine. It handles global audio
40 * properties and effects, loads all the audio buffers for sounds belonging to
41 * sprites.
42 */
43class AudioEngine {
44 constructor (audioContext = new AudioContext()) {
45 /**
46 * AudioContext to play and manipulate sounds with a graph of source
47 * and effect nodes.
48 * @type {AudioContext}
49 */
50 this.audioContext = audioContext;
51 StartAudioContext(this.audioContext);
52
53 /**
54 * Master GainNode that all sounds plays through. Changing this node
55 * will change the volume for all sounds.
56 * @type {GainNode}
57 */
58 this.inputNode = this.audioContext.createGain();
59 this.inputNode.connect(this.audioContext.destination);
60
61 /**
62 * a map of soundIds to audio buffers, holding sounds for all sprites
63 * @type {Object<String, ArrayBuffer>}
64 */
65 this.audioBuffers = {};
66
67 /**
68 * A Loudness detector.
69 * @type {Loudness}
70 */
71 this.loudness = null;
72
73 /**
74 * Array of effects applied in order, left to right,
75 * Left is closest to input, Right is closest to output
76 */
77 this.effects = [PanEffect, PitchEffect, VolumeEffect];
78 }
79
80 /**
81 * Current time in the AudioEngine.
82 * @type {number}
83 */
84 get currentTime () {
85 return this.audioContext.currentTime;
86 }
87
88 /**
89 * Names of the audio effects.
90 * @enum {string}
91 */
92 get EFFECT_NAMES () {
93 return {
94 pitch: 'pitch',
95 pan: 'pan'
96 };
97 }
98
99 /**
100 * A short duration to transition audio prarameters.
101 *
102 * Used as a time constant for exponential transitions. A general value
103 * must be large enough that it does not cute off lower frequency, or bass,
104 * sounds. Human hearing lower limit is ~20Hz making a safe value 25
105 * milliseconds or 0.025 seconds, where half of a 20Hz wave will play along
106 * with the DECAY. Higher frequencies will play multiple waves during the
107 * same amount of time and avoid clipping.
108 *
109 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime}
110 * @const {number}
111 */
112 get DECAY_DURATION () {
113 return 0.025;
114 }
115
116 /**
117 * Some environments cannot smoothly change parameters immediately, provide
118 * a small delay before decaying.
119 *
120 * @see {@link https://bugzilla.mozilla.org/show_bug.cgi?id=1228207}
121 * @const {number}
122 */
123 get DECAY_WAIT () {
124 return 0.05;
125 }
126
127 /**
128 * Get the input node.
129 * @return {AudioNode} - audio node that is the input for this effect
130 */
131 getInputNode () {
132 return this.inputNode;
133 }
134
135 /**
136 * Decode a sound, decompressing it into audio samples.
137 * @param {object} sound - an object containing audio data and metadata for
138 * a sound
139 * @param {Buffer} sound.data - sound data loaded from scratch-storage
140 * @returns {?Promise} - a promise which will resolve to the sound id and
141 * buffer if decoded
142 */
143 _decodeSound (sound) {
144 // Make a copy of the buffer because decoding detaches the original
145 // buffer
146 const bufferCopy1 = sound.data.buffer.slice(0);
147
148 // todo: multiple decodings of the same buffer create duplicate decoded
149 // copies in audioBuffers. Create a hash id of the buffer or deprecate
150 // audioBuffers to avoid memory issues for large audio buffers.
151 const soundId = uid();
152
153 // Attempt to decode the sound using the browser's native audio data
154 // decoder If that fails, attempt to decode as ADPCM
155 const decoding = decodeAudioData(this.audioContext, bufferCopy1)
156 .catch(() => {
157 // If the file is empty, create an empty sound
158 if (sound.data.length === 0) {
159 return this._emptySound();
160 }
161
162 // The audio context failed to parse the sound data
163 // we gave it, so try to decode as 'adpcm'
164
165 // First we need to create another copy of our original data
166 const bufferCopy2 = sound.data.buffer.slice(0);
167 // Try decoding as adpcm
168 return new ADPCMSoundDecoder(this.audioContext).decode(bufferCopy2)
169 .catch(() => this._emptySound());
170 })
171 .then(
172 buffer => ([soundId, buffer]),
173 error => {
174 log.warn('audio data could not be decoded', error);
175 }
176 );
177
178 return decoding;
179 }
180
181 /**
182 * An empty sound buffer, for use when we are unable to decode a sound file.
183 * @returns {AudioBuffer} - an empty audio buffer.
184 */
185 _emptySound () {
186 return this.audioContext.createBuffer(1, 1, this.audioContext.sampleRate);
187 }
188
189 /**
190 * Decode a sound, decompressing it into audio samples.
191 *
192 * Store a reference to it the sound in the audioBuffers dictionary,
193 * indexed by soundId.
194 *
195 * @param {object} sound - an object containing audio data and metadata for
196 * a sound
197 * @param {Buffer} sound.data - sound data loaded from scratch-storage
198 * @returns {?Promise} - a promise which will resolve to the sound id
199 */
200 decodeSound (sound) {
201 return this._decodeSound(sound)
202 .then(([id, buffer]) => {
203 this.audioBuffers[id] = buffer;
204 return id;
205 });
206 }
207
208 /**
209 * Decode a sound, decompressing it into audio samples.
210 *
211 * Create a SoundPlayer instance that can be used to play the sound and
212 * stop and fade out playback.
213 *
214 * @param {object} sound - an object containing audio data and metadata for
215 * a sound
216 * @param {Buffer} sound.data - sound data loaded from scratch-storage
217 * @returns {?Promise} - a promise which will resolve to the buffer
218 */
219 decodeSoundPlayer (sound) {
220 return this._decodeSound(sound)
221 .then(([id, buffer]) => new SoundPlayer(this, {id, buffer}));
222 }
223
224 /**
225 * Get the current loudness of sound received by the microphone.
226 * Sound is measured in RMS and smoothed.
227 * @return {number} loudness scaled 0 to 100
228 */
229 getLoudness () {
230 // The microphone has not been set up, so try to connect to it
231 if (!this.loudness) {
232 this.loudness = new Loudness(this.audioContext);
233 }
234
235 return this.loudness.getLoudness();
236 }
237
238 /**
239 * Create an effect chain.
240 * @returns {EffectChain} chain of effects defined by this AudioEngine
241 */
242 createEffectChain () {
243 const effects = new EffectChain(this, this.effects);
244 effects.connect(this);
245 return effects;
246 }
247
248 /**
249 * Create a sound bank and effect chain.
250 * @returns {SoundBank} a sound bank configured with an effect chain
251 * defined by this AudioEngine
252 */
253 createBank () {
254 return new SoundBank(this, this.createEffectChain());
255 }
256}
257
258module.exports = AudioEngine;