-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.js
395 lines (351 loc) · 13.9 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
/**
* Pumper - Web Audio API analysis & monitoring library
* @author njmcode
*
* This lib wraps the Web Audio API. It is designed to make realtime analysis of
* a web audio stream (media or microphone) easier. Created it for this project so
* we can easily react to volume levels and frequency spikes for reactive Canvas/GL
* visualizations.
*
* Instantiated as a singleton - pass it around the app via require().
*
* API:
* - Pumper.start(source, start = 1920, end = 16800, precision = 12)
* - source can be a media URL or 'mic'
* - 'start' and 'end' define the global frequency ranges
* - precision will decide how many lookups the analyzer will have
*
* - Pumper.update()
* - updates all exposed properties with latest data
*
* - Pumper.createBand(start, end, threshold, spikeTolerance, volScale = 1)
* - creates a new frequency range monitor and returns the instance
* - 'start' and 'end' define the band frequency ranges
* - frequency range is scaled to global values
* - 'volScale' optionally multiplies returned volume values
*
* Exposed properties:
* - Pumper.bands - array of all Band instances in the order they were created
* - Pumper.volume - current global average volume level. Set via Pumper.update()
* - Pumper.globalSpikeTolerance - distance over which a volume change is considered a spike
* - Pumper.globalThreshold - arbitrary threshold value for global volume level
* - Pumper.isSpiking - true if there was a volume spike since the last time update() was called
* - Pumper.isOverThreshold - true if the current global volume exceeds the set global threshold
* - Pumper.freqData - raw frequency data array
* - Pumper.timeData - raw time domain data array
**/
import 'webrtc-adapter';
const DEFAULTS = Object.freeze({
threshold: 127,
spikeTolerance: 30,
});
function getURLParam(name, url = window.location.href) {
const urlObj = new URL(url);
return urlObj.searchParams.get(name);
}
/**
* Get a media stream with low latency.
* @param targetLatency - target latency in seconds
* @param maxLatency - max latency in seconds
* @param increment - latency increment in reattempt
* @param fallback - if true, will return a regular stream if low latency stream fails
* @returns {Promise<MediaStream>}
**/
async function getLowLatencyMedia(targetLatency = 0.003, maxLatency = 0.04, increment = 0.01, fallback = true) {
let latency = targetLatency;
let stream = null;
while (!stream && latency <= maxLatency) {
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
latency: {
min: latency,
max: latency,
},
},
});
} catch (err) {
if (err.name === 'OverconstrainedError' && err.constraint === 'latency') {
console.warn(`Failed to get media stream with latency ${latency}`);
latency += increment;
} else {
console.warn('contraints', err.constraint);
throw err;
}
}
}
if (!stream) {
if (!fallback) throw new Error('Failed to get media stream with low latency');
console.warn('Failed to get media stream with low latency');
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
} catch (err) {
throw new Error('Failed to get media stream at all', err);
}
}
return stream;
}
/**
* 'Band' (frequency range) class.
**/
class Band {
/**
* @param start - frequency range start
* @param end - frequency range end
* @param threshold - arbitrary threshold value for volume level
* @param spikeTolerance - distance over which a volume change is considered a spike
* @param volScale - optionally multiplies returned volume values
**/
constructor(
start = 20,
end = 20000,
threshold = DEFAULTS.threshold,
spikeTolerance = DEFAULTS.spikeTolerance,
volScale = 1,
) {
this.startFreq = start;
this.endFreq = end;
this.volScale = volScale;
this.volume = 0;
this.isOverThreshold = false;
this.isSpiking = false;
}
_onSpike(spikeAmount) {
// TODO: fire event
}
_onThreshold() {
var over = this.volume - this.threshold;
// TODO: fire event
}
}
class Pumper {
constructor() {
this.volume = 0.0;
this.isSpiking = false;
this.isOverThreshold = false;
this.globalThreshold = DEFAULTS.threshold;
this.globalSpikeTolerance = DEFAULTS.spikeTolerance;
this.sensitivity = 1;
this.timeData = null;
this.timeDataLength = 0;
this.freqData = null;
this.freqDataLength = 0;
this.bands = [];
}
static _err(msg) {
throw new Error(`Pumper error: ${msg}`);
}
static _warn(msg) {
console.warn(`Pumper: ${msg}`);
}
/**
* Start the engine.
* @param srcValue - media URL or 'mic'
* @param startFreq - global frequency range start
* @param endFreq - global frequency range end
* @param precision - number of lookups the analyzer will have
* @returns {Promise<void>}
**/
async start(srcValue, start = 880, end = 7720, precision = 12) {
if (!srcValue) Pumper._err('Missing "source" param');
const ipt = getURLParam('input');
console.log('URL PARAM', ipt);
if (ipt === 'mic') this.FORCE_MIC = true;
// Init Web Audio API context
this.AUDIO = new (window.AudioContext || window.webkitAudioContext)();
if (!this.AUDIO) Pumper._err('Web Audio API not supported :(');
// Set up analyzer and buffers
this.analyzer = this.AUDIO.createAnalyser();
this.maxFreq = this.AUDIO.sampleRate / 2;
this.analyzer.fftSize = Math.pow(2, precision);
this.analyzer.minDecibels = -90;
this.analyzer.maxDecibels = -10;
console.debug(`analyser: ${this.analyzer}`);
this.startFreq = start;
this.endFreq = end;
this.freqDataLength = this.analyzer.frequencyBinCount;
this.timeDataLength = this.analyzer.frequencyBinCount;
this.freqData = new Uint8Array(this.freqDataLength);
this.timeData = new Uint8Array(this.timeDataLength);
if (this.FORCE_MIC || srcValue === 'mic') {
try {
// Request mic access, create source node and connect to analyzer
console.log('Pumper: requesting mic stream');
const stream = await getLowLatencyMedia();
window.stream = stream; // make stream available to console
const audioTracks = stream.getAudioTracks();
console.log('Using audio device: ' + audioTracks[0].label);
// TODO: throw 'ready' event
this.source = this.AUDIO.createMediaStreamSource(stream);
this.source.connect(this.analyzer); // Don't connect mic to output
console.log('Pumper: mic stream ready', this.source);
} catch (error) {
Pumper._err(`Error opening microphone stream ${error}`);
}
} else {
// Load track, create source node and connect to analyzer
const track = document.createElement('audio');
track.setAttribute('src', srcValue);
track.crossOrigin = 'anonymous';
this.source = this.AUDIO.createMediaElementSource(track);
this.source.connect(this.analyzer);
this.analyzer.connect(this.AUDIO.destination);
return new Promise(resolve => {
track.addEventListener(
'loadeddata',
() => {
// TODO: throw 'ready' event
console.log('Pumper: track ready', this.source);
resolve();
},
false,
);
});
}
}
/**
* Play the source node if it's a media element.
* @return {boolean} - true if successful
**/
play() {
if (this.source instanceof MediaElementAudioSourceNode || this.source instanceof MediaStreamAudioSourceNode) {
this.source.mediaElement.play();
return true;
} else {
Pumper._warn('Source is not ready or is not a media element');
return false;
}
}
/**
* Resumes the audio context.
**/
resume() {
this.AUDIO.resume();
}
/**
* Create a new freq watcher (band)
* @param start - start frequency
* @param end - end frequency
* @param threshold - volume threshold
* @param spikeTolerance - spike tolerance
* @param volScale - volume scale
* @return {Band} - the new band
**/
createBand(
start = 20,
end = 20000,
threshold = DEFAULTS.threshold,
spikeTolerance = DEFAULTS.spikeTolerance,
volScale = 1,
) {
// Range check start and end
if (start < 0 || start > this.maxFreq) Pumper._err(`Invalid start frequency: ${start}`);
if (end < 0 || end > this.maxFreq) Pumper._err(`Invalid end frequency: ${end}`);
if (start > end) Pumper._err(`Start frequency must be less than end frequency: ${start} > ${end}`);
const band = new Band(start, end, threshold, spikeTolerance, volScale);
this.bands.push(band);
return band;
}
/**
* Create a range of bands over the global scale
* @param start - start frequency
* @param end - end frequency
* @param count - number of bands to create
* @param volStart - start volume
* @param volEnd - end volume
* @param bleed - bleed factor
* @return {Band[]} - the new bands
**/
createBands(start = 20, end = 20000, count = 1, volStart = 1, volEnd = 1, bleed = 0.5) {
// Range check start and end
if (start < 0 || start > this.maxFreq) Pumper._err(`Invalid start frequency: ${start}`);
if (end < 0 || end > this.maxFreq) Pumper._err(`Invalid end frequency: ${end}`);
if (start > end) Pumper._err(`Start frequency must be less than end frequency: ${start} > ${end}`);
const freqRange = end - start;
const volRange = volEnd - volStart;
const bleedVal = (freqRange / count) * bleed;
const bands = [];
for (let i = 0; i < count; i++) {
const band = this.createBand(
start + (freqRange * i) / count - bleedVal, // start
start + (freqRange * (i + 1)) / count + bleedVal, // end
this.globalThreshold,
this.globalSpikeTolerance,
volStart + (volRange * i) / count, // volScale
);
bands.push(band);
}
return bands;
}
/**
* Perform analysis on the current audio, and update any registered bands.
* @return {boolean} - true if successful
* @throws {Error} - if source is not ready, a media element, or stream
**/
update() {
if (this.source instanceof MediaElementAudioSourceNode || this.source instanceof MediaStreamAudioSourceNode) {
// Update maxFreq in case it's changed
this.maxFreq = this.AUDIO.sampleRate / 2;
this.analyzer.getByteFrequencyData(this.freqData);
this.analyzer.getByteTimeDomainData(this.timeData);
// Calc global volume
const rangeStart = Math.round((this.startFreq / this.maxFreq) * (this.freqDataLength - 1));
const rangeEnd = Math.round((this.endFreq / this.maxFreq) * (this.freqDataLength - 1));
let globTotal = 0;
for (let i = rangeStart; i <= rangeEnd; i++) {
globTotal += this.freqData[i];
}
// TODO: add sensitivity control
// TODO: fire global events
const globalVolume = globTotal / (rangeEnd - rangeStart);
if (globalVolume - this.volume > this.globalSpikeTolerance) {
this.isSpiking = true;
} else {
this.isSpiking = false;
}
this.volume = globalVolume;
if (this.volume > this.globalThreshold) {
this.isOverThreshold = true;
} else {
this.isOverThreshold = false;
}
// Calc band volume levels
// TODO: optimize this
this.bands.forEach(band => {
const bRangeStart = Math.round((band.startFreq / this.maxFreq) * (this.freqDataLength - 1));
const bRangeEnd = Math.round((band.endFreq / this.maxFreq) * (this.freqDataLength - 1));
let bandTotal = 0;
for (let i = bRangeStart; i <= bRangeEnd; i++) {
bandTotal += this.freqData[i];
}
const bandVolume = (bandTotal / (bRangeEnd - bRangeStart)) * band.volScale;
if (bandVolume - band.volume > band.spikeTolerance) {
band.isSpiking = true;
band._onSpike(bandVolume - band.volume);
} else {
band.isSpiking = false;
}
band.volume = bandVolume;
if (band.volume > band.threshold) {
band.isOverThreshold = true;
band._onOverThreshold();
} else {
band.isOverThreshold = false;
}
});
return true;
} else {
throw new Error('Source is not ready, a media element, or stream', this.source);
}
}
}
export { Pumper }