1 | const StartAudioContext = require('./StartAudioContext');
|
2 | const AudioContext = require('audio-context');
|
3 |
|
4 | const log = require('./log');
|
5 | const uid = require('./uid');
|
6 |
|
7 | const ADPCMSoundDecoder = require('./ADPCMSoundDecoder');
|
8 | const Loudness = require('./Loudness');
|
9 | const SoundPlayer = require('./SoundPlayer');
|
10 |
|
11 | const EffectChain = require('./effects/EffectChain');
|
12 | const PanEffect = require('./effects/PanEffect');
|
13 | const PitchEffect = require('./effects/PitchEffect');
|
14 | const VolumeEffect = require('./effects/VolumeEffect');
|
15 |
|
16 | const 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 | */
|
24 | const 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 | */
|
43 | class 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 |
|
258 | module.exports = AudioEngine;
|