audiomotion-analyzer 4.5.0-beta.0 → 4.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2526 -0
- package/package.json +14 -7
- package/src/audioMotion-analyzer.js +2 -2
- package/src/package.json +4 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2526 @@
|
|
|
1
|
+
(function (global, factory) {
|
|
2
|
+
if (typeof define === "function" && define.amd) {
|
|
3
|
+
define("AudioMotionAnalyzer", ["exports"], factory);
|
|
4
|
+
} else if (typeof exports !== "undefined") {
|
|
5
|
+
factory(exports);
|
|
6
|
+
} else {
|
|
7
|
+
var mod = {
|
|
8
|
+
exports: {}
|
|
9
|
+
};
|
|
10
|
+
factory(mod.exports);
|
|
11
|
+
global.AudioMotionAnalyzer = mod.exports.default;
|
|
12
|
+
}
|
|
13
|
+
})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function (_exports) {
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
Object.defineProperty(_exports, "__esModule", {
|
|
17
|
+
value: true
|
|
18
|
+
});
|
|
19
|
+
_exports.default = _exports.AudioMotionAnalyzer = void 0;
|
|
20
|
+
/**!
|
|
21
|
+
* audioMotion-analyzer
|
|
22
|
+
* High-resolution real-time graphic audio spectrum analyzer JS module
|
|
23
|
+
*
|
|
24
|
+
* @version 4.5.0-beta.1
|
|
25
|
+
* @author Henrique Avila Vianna <hvianna@gmail.com> <https://henriquevianna.com>
|
|
26
|
+
* @license AGPL-3.0-or-later
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const VERSION = '4.5.0-beta.1';
|
|
30
|
+
|
|
31
|
+
// internal constants
|
|
32
|
+
const PI = Math.PI,
|
|
33
|
+
TAU = 2 * PI,
|
|
34
|
+
HALF_PI = PI / 2,
|
|
35
|
+
C_1 = 8.17579892; // frequency for C -1
|
|
36
|
+
|
|
37
|
+
const CANVAS_BACKGROUND_COLOR = '#000',
|
|
38
|
+
CHANNEL_COMBINED = 'dual-combined',
|
|
39
|
+
CHANNEL_HORIZONTAL = 'dual-horizontal',
|
|
40
|
+
CHANNEL_SINGLE = 'single',
|
|
41
|
+
CHANNEL_VERTICAL = 'dual-vertical',
|
|
42
|
+
COLOR_BAR_INDEX = 'bar-index',
|
|
43
|
+
COLOR_BAR_LEVEL = 'bar-level',
|
|
44
|
+
COLOR_GRADIENT = 'gradient',
|
|
45
|
+
DEBOUNCE_TIMEOUT = 60,
|
|
46
|
+
EVENT_CLICK = 'click',
|
|
47
|
+
EVENT_FULLSCREENCHANGE = 'fullscreenchange',
|
|
48
|
+
EVENT_RESIZE = 'resize',
|
|
49
|
+
GRADIENT_DEFAULT_BGCOLOR = '#111',
|
|
50
|
+
FILTER_NONE = '',
|
|
51
|
+
FILTER_A = 'A',
|
|
52
|
+
FILTER_B = 'B',
|
|
53
|
+
FILTER_C = 'C',
|
|
54
|
+
FILTER_D = 'D',
|
|
55
|
+
FILTER_468 = '468',
|
|
56
|
+
FONT_FAMILY = 'sans-serif',
|
|
57
|
+
FPS_COLOR = '#0f0',
|
|
58
|
+
LEDS_UNLIT_COLOR = '#7f7f7f22',
|
|
59
|
+
MODE_GRAPH = 10,
|
|
60
|
+
REASON_CREATE = 'create',
|
|
61
|
+
REASON_FSCHANGE = 'fschange',
|
|
62
|
+
REASON_LORES = 'lores',
|
|
63
|
+
REASON_RESIZE = EVENT_RESIZE,
|
|
64
|
+
REASON_USER = 'user',
|
|
65
|
+
SCALEX_BACKGROUND_COLOR = '#000c',
|
|
66
|
+
SCALEX_LABEL_COLOR = '#fff',
|
|
67
|
+
SCALEX_HIGHLIGHT_COLOR = '#4f4',
|
|
68
|
+
SCALEY_LABEL_COLOR = '#888',
|
|
69
|
+
SCALEY_MIDLINE_COLOR = '#555',
|
|
70
|
+
SCALE_BARK = 'bark',
|
|
71
|
+
SCALE_LINEAR = 'linear',
|
|
72
|
+
SCALE_LOG = 'log',
|
|
73
|
+
SCALE_MEL = 'mel';
|
|
74
|
+
|
|
75
|
+
// built-in gradients
|
|
76
|
+
const PRISM = ['#a35', '#c66', '#e94', '#ed0', '#9d5', '#4d8', '#2cb', '#0bc', '#09c', '#36b'],
|
|
77
|
+
GRADIENTS = [['classic', {
|
|
78
|
+
colorStops: ['red', {
|
|
79
|
+
color: 'yellow',
|
|
80
|
+
level: .85,
|
|
81
|
+
pos: .6
|
|
82
|
+
}, {
|
|
83
|
+
color: 'lime',
|
|
84
|
+
level: .475
|
|
85
|
+
}]
|
|
86
|
+
}], ['prism', {
|
|
87
|
+
colorStops: PRISM
|
|
88
|
+
}], ['rainbow', {
|
|
89
|
+
dir: 'h',
|
|
90
|
+
colorStops: ['#817', ...PRISM, '#639']
|
|
91
|
+
}], ['orangered', {
|
|
92
|
+
bgColor: '#3e2f29',
|
|
93
|
+
colorStops: ['OrangeRed']
|
|
94
|
+
}], ['steelblue', {
|
|
95
|
+
bgColor: '#222c35',
|
|
96
|
+
colorStops: ['SteelBlue']
|
|
97
|
+
}]];
|
|
98
|
+
|
|
99
|
+
// settings defaults
|
|
100
|
+
const DEFAULT_SETTINGS = {
|
|
101
|
+
alphaBars: false,
|
|
102
|
+
ansiBands: false,
|
|
103
|
+
barSpace: 0.1,
|
|
104
|
+
bgAlpha: 0.7,
|
|
105
|
+
channelLayout: CHANNEL_SINGLE,
|
|
106
|
+
colorMode: COLOR_GRADIENT,
|
|
107
|
+
fftSize: 8192,
|
|
108
|
+
fillAlpha: 1,
|
|
109
|
+
frequencyScale: SCALE_LOG,
|
|
110
|
+
gradient: GRADIENTS[0][0],
|
|
111
|
+
gravity: 1,
|
|
112
|
+
height: undefined,
|
|
113
|
+
ledBars: false,
|
|
114
|
+
linearAmplitude: false,
|
|
115
|
+
linearBoost: 1,
|
|
116
|
+
lineWidth: 0,
|
|
117
|
+
loRes: false,
|
|
118
|
+
lumiBars: false,
|
|
119
|
+
maxDecibels: -25,
|
|
120
|
+
maxFPS: 0,
|
|
121
|
+
maxFreq: 22000,
|
|
122
|
+
minDecibels: -85,
|
|
123
|
+
minFreq: 20,
|
|
124
|
+
mirror: 0,
|
|
125
|
+
mode: 0,
|
|
126
|
+
noteLabels: false,
|
|
127
|
+
outlineBars: false,
|
|
128
|
+
overlay: false,
|
|
129
|
+
peakLine: false,
|
|
130
|
+
radial: false,
|
|
131
|
+
radialInvert: false,
|
|
132
|
+
radius: 0.3,
|
|
133
|
+
reflexAlpha: 0.15,
|
|
134
|
+
reflexBright: 1,
|
|
135
|
+
reflexFit: true,
|
|
136
|
+
reflexRatio: 0,
|
|
137
|
+
roundBars: false,
|
|
138
|
+
showBgColor: true,
|
|
139
|
+
showFPS: false,
|
|
140
|
+
showPeaks: true,
|
|
141
|
+
showScaleX: true,
|
|
142
|
+
showScaleY: false,
|
|
143
|
+
smoothing: 0.5,
|
|
144
|
+
spinSpeed: 0,
|
|
145
|
+
splitGradient: false,
|
|
146
|
+
start: true,
|
|
147
|
+
trueLeds: false,
|
|
148
|
+
useCanvas: true,
|
|
149
|
+
volume: 1,
|
|
150
|
+
weightingFilter: FILTER_NONE,
|
|
151
|
+
width: undefined
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// custom error messages
|
|
155
|
+
const ERR_AUDIO_CONTEXT_FAIL = ['ERR_AUDIO_CONTEXT_FAIL', 'Could not create audio context. Web Audio API not supported?'],
|
|
156
|
+
ERR_INVALID_AUDIO_CONTEXT = ['ERR_INVALID_AUDIO_CONTEXT', 'Provided audio context is not valid'],
|
|
157
|
+
ERR_UNKNOWN_GRADIENT = ['ERR_UNKNOWN_GRADIENT', 'Unknown gradient'],
|
|
158
|
+
ERR_FREQUENCY_TOO_LOW = ['ERR_FREQUENCY_TOO_LOW', 'Frequency values must be >= 1'],
|
|
159
|
+
ERR_INVALID_MODE = ['ERR_INVALID_MODE', 'Invalid mode'],
|
|
160
|
+
ERR_REFLEX_OUT_OF_RANGE = ['ERR_REFLEX_OUT_OF_RANGE', 'Reflex ratio must be >= 0 and < 1'],
|
|
161
|
+
ERR_INVALID_AUDIO_SOURCE = ['ERR_INVALID_AUDIO_SOURCE', 'Audio source must be an instance of HTMLMediaElement or AudioNode'],
|
|
162
|
+
ERR_GRADIENT_INVALID_NAME = ['ERR_GRADIENT_INVALID_NAME', 'Gradient name must be a non-empty string'],
|
|
163
|
+
ERR_GRADIENT_NOT_AN_OBJECT = ['ERR_GRADIENT_NOT_AN_OBJECT', 'Gradient options must be an object'],
|
|
164
|
+
ERR_GRADIENT_MISSING_COLOR = ['ERR_GRADIENT_MISSING_COLOR', 'Gradient colorStops must be a non-empty array'];
|
|
165
|
+
class AudioMotionError extends Error {
|
|
166
|
+
constructor(error, value) {
|
|
167
|
+
const [code, message] = error;
|
|
168
|
+
super(message + (value !== undefined ? `: ${value}` : ''));
|
|
169
|
+
this.name = 'AudioMotionError';
|
|
170
|
+
this.code = code;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// helper function - output deprecation warning message on console
|
|
175
|
+
const deprecate = (name, alternative) => console.warn(`${name} is deprecated. Use ${alternative} instead.`);
|
|
176
|
+
|
|
177
|
+
// helper function - check if a given object is empty (also returns `true` on null, undefined or any non-object value)
|
|
178
|
+
const isEmpty = obj => {
|
|
179
|
+
for (const p in obj) return false;
|
|
180
|
+
return true;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// helper function - validate a given value with an array of strings (by default, all lowercase)
|
|
184
|
+
// returns the validated value, or the first element of `list` if `value` is not found in the array
|
|
185
|
+
const validateFromList = (value, list, modifier = 'toLowerCase') => list[Math.max(0, list.indexOf(('' + value)[modifier]()))];
|
|
186
|
+
|
|
187
|
+
// helper function - find the Y-coordinate of a point located between two other points, given its X-coordinate
|
|
188
|
+
const findY = (x1, y1, x2, y2, x) => y1 + (y2 - y1) * (x - x1) / (x2 - x1);
|
|
189
|
+
|
|
190
|
+
// Polyfill for Array.findLastIndex()
|
|
191
|
+
if (!Array.prototype.findLastIndex) {
|
|
192
|
+
Array.prototype.findLastIndex = function (callback) {
|
|
193
|
+
let index = this.length;
|
|
194
|
+
while (index-- > 0) {
|
|
195
|
+
if (callback(this[index])) return index;
|
|
196
|
+
}
|
|
197
|
+
return -1;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// AudioMotionAnalyzer class
|
|
202
|
+
|
|
203
|
+
class AudioMotionAnalyzer {
|
|
204
|
+
/**
|
|
205
|
+
* CONSTRUCTOR
|
|
206
|
+
*
|
|
207
|
+
* @param {object} [container] DOM element where to insert the analyzer; if undefined, uses the document body
|
|
208
|
+
* @param {object} [options]
|
|
209
|
+
* @returns {object} AudioMotionAnalyzer object
|
|
210
|
+
*/
|
|
211
|
+
constructor(container, options = {}) {
|
|
212
|
+
this._ready = false;
|
|
213
|
+
|
|
214
|
+
// Initialize internal objects
|
|
215
|
+
this._aux = {}; // auxiliary variables
|
|
216
|
+
this._canvasGradients = []; // CanvasGradient objects for channels 0 and 1
|
|
217
|
+
this._destroyed = false;
|
|
218
|
+
this._energy = {
|
|
219
|
+
val: 0,
|
|
220
|
+
peak: 0,
|
|
221
|
+
hold: 0
|
|
222
|
+
};
|
|
223
|
+
this._flg = {}; // flags
|
|
224
|
+
this._fps = 0;
|
|
225
|
+
this._gradients = {}; // registered gradients
|
|
226
|
+
this._last = 0; // timestamp of last rendered frame
|
|
227
|
+
this._outNodes = []; // output nodes
|
|
228
|
+
this._ownContext = false;
|
|
229
|
+
this._selectedGrads = []; // names of the currently selected gradients for channels 0 and 1
|
|
230
|
+
this._sources = []; // input nodes
|
|
231
|
+
|
|
232
|
+
// Check if options object passed as first argument
|
|
233
|
+
if (!(container instanceof Element)) {
|
|
234
|
+
if (isEmpty(options) && !isEmpty(container)) options = container;
|
|
235
|
+
container = null;
|
|
236
|
+
}
|
|
237
|
+
this._ownCanvas = !(options.canvas instanceof HTMLCanvasElement);
|
|
238
|
+
|
|
239
|
+
// Create a new canvas or use the one provided by the user
|
|
240
|
+
const canvas = this._ownCanvas ? document.createElement('canvas') : options.canvas;
|
|
241
|
+
canvas.style = 'max-width: 100%;';
|
|
242
|
+
this._ctx = canvas.getContext('2d');
|
|
243
|
+
|
|
244
|
+
// Register built-in gradients
|
|
245
|
+
for (const [name, options] of GRADIENTS) this.registerGradient(name, options);
|
|
246
|
+
|
|
247
|
+
// Set container
|
|
248
|
+
this._container = container || !this._ownCanvas && canvas.parentElement || document.body;
|
|
249
|
+
|
|
250
|
+
// Make sure we have minimal width and height dimensions in case of an inline container
|
|
251
|
+
this._defaultWidth = this._container.clientWidth || 640;
|
|
252
|
+
this._defaultHeight = this._container.clientHeight || 270;
|
|
253
|
+
|
|
254
|
+
// Use audio context provided by user, or create a new one
|
|
255
|
+
|
|
256
|
+
let audioCtx;
|
|
257
|
+
if (options.source && (audioCtx = options.source.context)) {
|
|
258
|
+
// get audioContext from provided source audioNode
|
|
259
|
+
} else if (audioCtx = options.audioCtx) {
|
|
260
|
+
// use audioContext provided by user
|
|
261
|
+
} else {
|
|
262
|
+
try {
|
|
263
|
+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
264
|
+
this._ownContext = true;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw new AudioMotionError(ERR_AUDIO_CONTEXT_FAIL);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// make sure audioContext is valid
|
|
271
|
+
if (!audioCtx.createGain) throw new AudioMotionError(ERR_INVALID_AUDIO_CONTEXT);
|
|
272
|
+
|
|
273
|
+
/*
|
|
274
|
+
Connection routing:
|
|
275
|
+
===================
|
|
276
|
+
for dual channel layouts: +---> analyzer[0] ---+
|
|
277
|
+
| |
|
|
278
|
+
(source) ---> input ---> splitter ---+ +---> merger ---> output ---> (destination)
|
|
279
|
+
| |
|
|
280
|
+
+---> analyzer[1] ---+
|
|
281
|
+
for single channel layout:
|
|
282
|
+
(source) ---> input -----------------------> analyzer[0] ---------------------> output ---> (destination)
|
|
283
|
+
*/
|
|
284
|
+
|
|
285
|
+
// create the analyzer nodes, channel splitter and merger, and gain nodes for input/output connections
|
|
286
|
+
const analyzer = this._analyzer = [audioCtx.createAnalyser(), audioCtx.createAnalyser()];
|
|
287
|
+
const splitter = this._splitter = audioCtx.createChannelSplitter(2);
|
|
288
|
+
const merger = this._merger = audioCtx.createChannelMerger(2);
|
|
289
|
+
this._input = audioCtx.createGain();
|
|
290
|
+
this._output = audioCtx.createGain();
|
|
291
|
+
|
|
292
|
+
// connect audio source if provided in the options
|
|
293
|
+
if (options.source) this.connectInput(options.source);
|
|
294
|
+
|
|
295
|
+
// connect splitter -> analyzers
|
|
296
|
+
for (const i of [0, 1]) splitter.connect(analyzer[i], i);
|
|
297
|
+
|
|
298
|
+
// connect merger -> output
|
|
299
|
+
merger.connect(this._output);
|
|
300
|
+
|
|
301
|
+
// connect output -> destination (speakers)
|
|
302
|
+
if (options.connectSpeakers !== false) this.connectOutput();
|
|
303
|
+
|
|
304
|
+
// create auxiliary canvases for the X-axis and radial scale labels
|
|
305
|
+
for (const ctx of ['_scaleX', '_scaleR']) this[ctx] = document.createElement('canvas').getContext('2d');
|
|
306
|
+
|
|
307
|
+
// set fullscreen element (defaults to canvas)
|
|
308
|
+
this._fsEl = options.fsElement || canvas;
|
|
309
|
+
|
|
310
|
+
// Update canvas size on container / window resize and fullscreen events
|
|
311
|
+
|
|
312
|
+
// Fullscreen changes are handled quite differently across browsers:
|
|
313
|
+
// 1. Chromium browsers will trigger a `resize` event followed by a `fullscreenchange`
|
|
314
|
+
// 2. Firefox triggers the `fullscreenchange` first and then the `resize`
|
|
315
|
+
// 3. Chrome on Android (TV) won't trigger a `resize` event, only `fullscreenchange`
|
|
316
|
+
// 4. Safari won't trigger `fullscreenchange` events at all, and on iPadOS the `resize`
|
|
317
|
+
// event is triggered **on the window** only (last tested on iPadOS 14)
|
|
318
|
+
|
|
319
|
+
// helper function for resize events
|
|
320
|
+
const onResize = () => {
|
|
321
|
+
if (!this._fsTimeout) {
|
|
322
|
+
// delay the resize to prioritize a possible following `fullscreenchange` event
|
|
323
|
+
this._fsTimeout = window.setTimeout(() => {
|
|
324
|
+
if (!this._fsChanging) {
|
|
325
|
+
this._setCanvas(REASON_RESIZE);
|
|
326
|
+
this._fsTimeout = 0;
|
|
327
|
+
}
|
|
328
|
+
}, DEBOUNCE_TIMEOUT);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// if browser supports ResizeObserver, listen for resize on the container
|
|
333
|
+
if (window.ResizeObserver) {
|
|
334
|
+
this._observer = new ResizeObserver(onResize);
|
|
335
|
+
this._observer.observe(this._container);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// create an AbortController to remove event listeners on destroy()
|
|
339
|
+
this._controller = new AbortController();
|
|
340
|
+
const signal = this._controller.signal;
|
|
341
|
+
|
|
342
|
+
// listen for resize events on the window - required for fullscreen on iPadOS
|
|
343
|
+
window.addEventListener(EVENT_RESIZE, onResize, {
|
|
344
|
+
signal
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// listen for fullscreenchange events on the canvas - not available on Safari
|
|
348
|
+
canvas.addEventListener(EVENT_FULLSCREENCHANGE, () => {
|
|
349
|
+
// set flag to indicate a fullscreen change in progress
|
|
350
|
+
this._fsChanging = true;
|
|
351
|
+
|
|
352
|
+
// if there is a scheduled resize event, clear it
|
|
353
|
+
if (this._fsTimeout) window.clearTimeout(this._fsTimeout);
|
|
354
|
+
|
|
355
|
+
// update the canvas
|
|
356
|
+
this._setCanvas(REASON_FSCHANGE);
|
|
357
|
+
|
|
358
|
+
// delay clearing the flag to prevent any shortly following resize event
|
|
359
|
+
this._fsTimeout = window.setTimeout(() => {
|
|
360
|
+
this._fsChanging = false;
|
|
361
|
+
this._fsTimeout = 0;
|
|
362
|
+
}, DEBOUNCE_TIMEOUT);
|
|
363
|
+
}, {
|
|
364
|
+
signal
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Resume audio context if in suspended state (browsers' autoplay policy)
|
|
368
|
+
const unlockContext = () => {
|
|
369
|
+
if (audioCtx.state == 'suspended') audioCtx.resume();
|
|
370
|
+
window.removeEventListener(EVENT_CLICK, unlockContext);
|
|
371
|
+
};
|
|
372
|
+
window.addEventListener(EVENT_CLICK, unlockContext);
|
|
373
|
+
|
|
374
|
+
// reset FPS-related variables when window becomes visible (avoid FPS drop due to frames not rendered while hidden)
|
|
375
|
+
document.addEventListener('visibilitychange', () => {
|
|
376
|
+
if (document.visibilityState != 'hidden') {
|
|
377
|
+
this._frames = 0;
|
|
378
|
+
this._time = performance.now();
|
|
379
|
+
}
|
|
380
|
+
}, {
|
|
381
|
+
signal
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Set configuration options and use defaults for any missing properties
|
|
385
|
+
this._setProps(options, true);
|
|
386
|
+
|
|
387
|
+
// Add canvas to the container (only when canvas not provided by user)
|
|
388
|
+
if (this.useCanvas && this._ownCanvas) this._container.appendChild(canvas);
|
|
389
|
+
|
|
390
|
+
// Finish canvas setup
|
|
391
|
+
this._ready = true;
|
|
392
|
+
this._setCanvas(REASON_CREATE);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* ==========================================================================
|
|
397
|
+
*
|
|
398
|
+
* PUBLIC PROPERTIES GETTERS AND SETTERS
|
|
399
|
+
*
|
|
400
|
+
* ==========================================================================
|
|
401
|
+
*/
|
|
402
|
+
|
|
403
|
+
get alphaBars() {
|
|
404
|
+
return this._alphaBars;
|
|
405
|
+
}
|
|
406
|
+
set alphaBars(value) {
|
|
407
|
+
this._alphaBars = !!value;
|
|
408
|
+
this._calcBars();
|
|
409
|
+
}
|
|
410
|
+
get ansiBands() {
|
|
411
|
+
return this._ansiBands;
|
|
412
|
+
}
|
|
413
|
+
set ansiBands(value) {
|
|
414
|
+
this._ansiBands = !!value;
|
|
415
|
+
this._calcBars();
|
|
416
|
+
}
|
|
417
|
+
get barSpace() {
|
|
418
|
+
return this._barSpace;
|
|
419
|
+
}
|
|
420
|
+
set barSpace(value) {
|
|
421
|
+
this._barSpace = +value || 0;
|
|
422
|
+
this._calcBars();
|
|
423
|
+
}
|
|
424
|
+
get channelLayout() {
|
|
425
|
+
return this._chLayout;
|
|
426
|
+
}
|
|
427
|
+
set channelLayout(value) {
|
|
428
|
+
this._chLayout = validateFromList(value, [CHANNEL_SINGLE, CHANNEL_HORIZONTAL, CHANNEL_VERTICAL, CHANNEL_COMBINED]);
|
|
429
|
+
|
|
430
|
+
// update node connections
|
|
431
|
+
this._input.disconnect();
|
|
432
|
+
this._input.connect(this._chLayout != CHANNEL_SINGLE ? this._splitter : this._analyzer[0]);
|
|
433
|
+
this._analyzer[0].disconnect();
|
|
434
|
+
if (this._outNodes.length)
|
|
435
|
+
// connect analyzer only if the output is connected to other nodes
|
|
436
|
+
this._analyzer[0].connect(this._chLayout != CHANNEL_SINGLE ? this._merger : this._output);
|
|
437
|
+
this._calcBars();
|
|
438
|
+
this._makeGrad();
|
|
439
|
+
}
|
|
440
|
+
get colorMode() {
|
|
441
|
+
return this._colorMode;
|
|
442
|
+
}
|
|
443
|
+
set colorMode(value) {
|
|
444
|
+
this._colorMode = validateFromList(value, [COLOR_GRADIENT, COLOR_BAR_INDEX, COLOR_BAR_LEVEL]);
|
|
445
|
+
}
|
|
446
|
+
get fftSize() {
|
|
447
|
+
return this._analyzer[0].fftSize;
|
|
448
|
+
}
|
|
449
|
+
set fftSize(value) {
|
|
450
|
+
for (const i of [0, 1]) this._analyzer[i].fftSize = value;
|
|
451
|
+
const binCount = this._analyzer[0].frequencyBinCount;
|
|
452
|
+
this._fftData = [new Float32Array(binCount), new Float32Array(binCount)];
|
|
453
|
+
this._calcBars();
|
|
454
|
+
}
|
|
455
|
+
get frequencyScale() {
|
|
456
|
+
return this._frequencyScale;
|
|
457
|
+
}
|
|
458
|
+
set frequencyScale(value) {
|
|
459
|
+
this._frequencyScale = validateFromList(value, [SCALE_LOG, SCALE_BARK, SCALE_MEL, SCALE_LINEAR]);
|
|
460
|
+
this._calcBars();
|
|
461
|
+
}
|
|
462
|
+
get gradient() {
|
|
463
|
+
return this._selectedGrads[0];
|
|
464
|
+
}
|
|
465
|
+
set gradient(value) {
|
|
466
|
+
this._setGradient(value);
|
|
467
|
+
}
|
|
468
|
+
get gradientLeft() {
|
|
469
|
+
return this._selectedGrads[0];
|
|
470
|
+
}
|
|
471
|
+
set gradientLeft(value) {
|
|
472
|
+
this._setGradient(value, 0);
|
|
473
|
+
}
|
|
474
|
+
get gradientRight() {
|
|
475
|
+
return this._selectedGrads[1];
|
|
476
|
+
}
|
|
477
|
+
set gradientRight(value) {
|
|
478
|
+
this._setGradient(value, 1);
|
|
479
|
+
}
|
|
480
|
+
get gravity() {
|
|
481
|
+
return this._gravity;
|
|
482
|
+
}
|
|
483
|
+
set gravity(value) {
|
|
484
|
+
this._gravity = value > 0 ? +value : this._gravity || DEFAULT_SETTINGS.gravity;
|
|
485
|
+
}
|
|
486
|
+
get height() {
|
|
487
|
+
return this._height;
|
|
488
|
+
}
|
|
489
|
+
set height(h) {
|
|
490
|
+
this._height = h;
|
|
491
|
+
this._setCanvas(REASON_USER);
|
|
492
|
+
}
|
|
493
|
+
get ledBars() {
|
|
494
|
+
return this._showLeds;
|
|
495
|
+
}
|
|
496
|
+
set ledBars(value) {
|
|
497
|
+
this._showLeds = !!value;
|
|
498
|
+
this._calcBars();
|
|
499
|
+
}
|
|
500
|
+
get linearAmplitude() {
|
|
501
|
+
return this._linearAmplitude;
|
|
502
|
+
}
|
|
503
|
+
set linearAmplitude(value) {
|
|
504
|
+
this._linearAmplitude = !!value;
|
|
505
|
+
}
|
|
506
|
+
get linearBoost() {
|
|
507
|
+
return this._linearBoost;
|
|
508
|
+
}
|
|
509
|
+
set linearBoost(value) {
|
|
510
|
+
this._linearBoost = value >= 1 ? +value : 1;
|
|
511
|
+
}
|
|
512
|
+
get lineWidth() {
|
|
513
|
+
return this._lineWidth;
|
|
514
|
+
}
|
|
515
|
+
set lineWidth(value) {
|
|
516
|
+
this._lineWidth = +value || 0;
|
|
517
|
+
}
|
|
518
|
+
get loRes() {
|
|
519
|
+
return this._loRes;
|
|
520
|
+
}
|
|
521
|
+
set loRes(value) {
|
|
522
|
+
this._loRes = !!value;
|
|
523
|
+
this._setCanvas(REASON_LORES);
|
|
524
|
+
}
|
|
525
|
+
get lumiBars() {
|
|
526
|
+
return this._lumiBars;
|
|
527
|
+
}
|
|
528
|
+
set lumiBars(value) {
|
|
529
|
+
this._lumiBars = !!value;
|
|
530
|
+
this._calcBars();
|
|
531
|
+
this._makeGrad();
|
|
532
|
+
}
|
|
533
|
+
get maxDecibels() {
|
|
534
|
+
return this._analyzer[0].maxDecibels;
|
|
535
|
+
}
|
|
536
|
+
set maxDecibels(value) {
|
|
537
|
+
for (const i of [0, 1]) this._analyzer[i].maxDecibels = value;
|
|
538
|
+
}
|
|
539
|
+
get maxFPS() {
|
|
540
|
+
return this._maxFPS;
|
|
541
|
+
}
|
|
542
|
+
set maxFPS(value) {
|
|
543
|
+
this._maxFPS = value < 0 ? 0 : +value || 0;
|
|
544
|
+
}
|
|
545
|
+
get maxFreq() {
|
|
546
|
+
return this._maxFreq;
|
|
547
|
+
}
|
|
548
|
+
set maxFreq(value) {
|
|
549
|
+
if (value < 1) throw new AudioMotionError(ERR_FREQUENCY_TOO_LOW);else {
|
|
550
|
+
this._maxFreq = Math.min(value, this.audioCtx.sampleRate / 2);
|
|
551
|
+
this._calcBars();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
get minDecibels() {
|
|
555
|
+
return this._analyzer[0].minDecibels;
|
|
556
|
+
}
|
|
557
|
+
set minDecibels(value) {
|
|
558
|
+
for (const i of [0, 1]) this._analyzer[i].minDecibels = value;
|
|
559
|
+
}
|
|
560
|
+
get minFreq() {
|
|
561
|
+
return this._minFreq;
|
|
562
|
+
}
|
|
563
|
+
set minFreq(value) {
|
|
564
|
+
if (value < 1) throw new AudioMotionError(ERR_FREQUENCY_TOO_LOW);else {
|
|
565
|
+
this._minFreq = +value;
|
|
566
|
+
this._calcBars();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
get mirror() {
|
|
570
|
+
return this._mirror;
|
|
571
|
+
}
|
|
572
|
+
set mirror(value) {
|
|
573
|
+
this._mirror = Math.sign(value) | 0; // ensure only -1, 0 or 1
|
|
574
|
+
this._calcBars();
|
|
575
|
+
this._makeGrad();
|
|
576
|
+
}
|
|
577
|
+
get mode() {
|
|
578
|
+
return this._mode;
|
|
579
|
+
}
|
|
580
|
+
set mode(value) {
|
|
581
|
+
const mode = value | 0;
|
|
582
|
+
if (mode >= 0 && mode <= 10 && mode != 9) {
|
|
583
|
+
this._mode = mode;
|
|
584
|
+
this._calcBars();
|
|
585
|
+
this._makeGrad();
|
|
586
|
+
} else throw new AudioMotionError(ERR_INVALID_MODE, value);
|
|
587
|
+
}
|
|
588
|
+
get noteLabels() {
|
|
589
|
+
return this._noteLabels;
|
|
590
|
+
}
|
|
591
|
+
set noteLabels(value) {
|
|
592
|
+
this._noteLabels = !!value;
|
|
593
|
+
this._createScales();
|
|
594
|
+
}
|
|
595
|
+
get outlineBars() {
|
|
596
|
+
return this._outlineBars;
|
|
597
|
+
}
|
|
598
|
+
set outlineBars(value) {
|
|
599
|
+
this._outlineBars = !!value;
|
|
600
|
+
this._calcBars();
|
|
601
|
+
}
|
|
602
|
+
get peakLine() {
|
|
603
|
+
return this._peakLine;
|
|
604
|
+
}
|
|
605
|
+
set peakLine(value) {
|
|
606
|
+
this._peakLine = !!value;
|
|
607
|
+
}
|
|
608
|
+
get radial() {
|
|
609
|
+
return this._radial;
|
|
610
|
+
}
|
|
611
|
+
set radial(value) {
|
|
612
|
+
this._radial = !!value;
|
|
613
|
+
this._calcBars();
|
|
614
|
+
this._makeGrad();
|
|
615
|
+
}
|
|
616
|
+
get radialInvert() {
|
|
617
|
+
return this._radialInvert;
|
|
618
|
+
}
|
|
619
|
+
set radialInvert(value) {
|
|
620
|
+
this._radialInvert = !!value;
|
|
621
|
+
this._calcBars();
|
|
622
|
+
this._makeGrad();
|
|
623
|
+
}
|
|
624
|
+
get radius() {
|
|
625
|
+
return this._radius;
|
|
626
|
+
}
|
|
627
|
+
set radius(value) {
|
|
628
|
+
this._radius = +value || 0;
|
|
629
|
+
this._calcBars();
|
|
630
|
+
this._makeGrad();
|
|
631
|
+
}
|
|
632
|
+
get reflexRatio() {
|
|
633
|
+
return this._reflexRatio;
|
|
634
|
+
}
|
|
635
|
+
set reflexRatio(value) {
|
|
636
|
+
value = +value || 0;
|
|
637
|
+
if (value < 0 || value >= 1) throw new AudioMotionError(ERR_REFLEX_OUT_OF_RANGE);else {
|
|
638
|
+
this._reflexRatio = value;
|
|
639
|
+
this._calcBars();
|
|
640
|
+
this._makeGrad();
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
get roundBars() {
|
|
644
|
+
return this._roundBars;
|
|
645
|
+
}
|
|
646
|
+
set roundBars(value) {
|
|
647
|
+
this._roundBars = !!value;
|
|
648
|
+
this._calcBars();
|
|
649
|
+
}
|
|
650
|
+
get smoothing() {
|
|
651
|
+
return this._analyzer[0].smoothingTimeConstant;
|
|
652
|
+
}
|
|
653
|
+
set smoothing(value) {
|
|
654
|
+
for (const i of [0, 1]) this._analyzer[i].smoothingTimeConstant = value;
|
|
655
|
+
}
|
|
656
|
+
get spinSpeed() {
|
|
657
|
+
return this._spinSpeed;
|
|
658
|
+
}
|
|
659
|
+
set spinSpeed(value) {
|
|
660
|
+
value = +value || 0;
|
|
661
|
+
if (this._spinSpeed === undefined || value == 0) this._spinAngle = -HALF_PI; // initialize or reset the rotation angle
|
|
662
|
+
this._spinSpeed = value;
|
|
663
|
+
}
|
|
664
|
+
get splitGradient() {
|
|
665
|
+
return this._splitGradient;
|
|
666
|
+
}
|
|
667
|
+
set splitGradient(value) {
|
|
668
|
+
this._splitGradient = !!value;
|
|
669
|
+
this._makeGrad();
|
|
670
|
+
}
|
|
671
|
+
get stereo() {
|
|
672
|
+
deprecate('stereo', 'channelLayout');
|
|
673
|
+
return this._chLayout != CHANNEL_SINGLE;
|
|
674
|
+
}
|
|
675
|
+
set stereo(value) {
|
|
676
|
+
deprecate('stereo', 'channelLayout');
|
|
677
|
+
this.channelLayout = value ? CHANNEL_VERTICAL : CHANNEL_SINGLE;
|
|
678
|
+
}
|
|
679
|
+
get trueLeds() {
|
|
680
|
+
return this._trueLeds;
|
|
681
|
+
}
|
|
682
|
+
set trueLeds(value) {
|
|
683
|
+
this._trueLeds = !!value;
|
|
684
|
+
}
|
|
685
|
+
get volume() {
|
|
686
|
+
return this._output.gain.value;
|
|
687
|
+
}
|
|
688
|
+
set volume(value) {
|
|
689
|
+
this._output.gain.value = value;
|
|
690
|
+
}
|
|
691
|
+
get weightingFilter() {
|
|
692
|
+
return this._weightingFilter;
|
|
693
|
+
}
|
|
694
|
+
set weightingFilter(value) {
|
|
695
|
+
this._weightingFilter = validateFromList(value, [FILTER_NONE, FILTER_A, FILTER_B, FILTER_C, FILTER_D, FILTER_468], 'toUpperCase');
|
|
696
|
+
}
|
|
697
|
+
get width() {
|
|
698
|
+
return this._width;
|
|
699
|
+
}
|
|
700
|
+
set width(w) {
|
|
701
|
+
this._width = w;
|
|
702
|
+
this._setCanvas(REASON_USER);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Read only properties
|
|
706
|
+
|
|
707
|
+
get audioCtx() {
|
|
708
|
+
return this._input.context;
|
|
709
|
+
}
|
|
710
|
+
get canvas() {
|
|
711
|
+
return this._ctx.canvas;
|
|
712
|
+
}
|
|
713
|
+
get canvasCtx() {
|
|
714
|
+
return this._ctx;
|
|
715
|
+
}
|
|
716
|
+
get connectedSources() {
|
|
717
|
+
return this._sources;
|
|
718
|
+
}
|
|
719
|
+
get connectedTo() {
|
|
720
|
+
return this._outNodes;
|
|
721
|
+
}
|
|
722
|
+
get fps() {
|
|
723
|
+
return this._fps;
|
|
724
|
+
}
|
|
725
|
+
get fsHeight() {
|
|
726
|
+
return this._fsHeight;
|
|
727
|
+
}
|
|
728
|
+
get fsWidth() {
|
|
729
|
+
return this._fsWidth;
|
|
730
|
+
}
|
|
731
|
+
get isAlphaBars() {
|
|
732
|
+
return this._flg.isAlpha;
|
|
733
|
+
}
|
|
734
|
+
get isBandsMode() {
|
|
735
|
+
return this._flg.isBands;
|
|
736
|
+
}
|
|
737
|
+
get isDestroyed() {
|
|
738
|
+
return this._destroyed;
|
|
739
|
+
}
|
|
740
|
+
get isFullscreen() {
|
|
741
|
+
return this._fsEl && (document.fullscreenElement || document.webkitFullscreenElement) === this._fsEl;
|
|
742
|
+
}
|
|
743
|
+
get isLedBars() {
|
|
744
|
+
return this._flg.isLeds;
|
|
745
|
+
}
|
|
746
|
+
get isLumiBars() {
|
|
747
|
+
return this._flg.isLumi;
|
|
748
|
+
}
|
|
749
|
+
get isOctaveBands() {
|
|
750
|
+
return this._flg.isOctaves;
|
|
751
|
+
}
|
|
752
|
+
get isOn() {
|
|
753
|
+
return !!this._runId;
|
|
754
|
+
}
|
|
755
|
+
get isOutlineBars() {
|
|
756
|
+
return this._flg.isOutline;
|
|
757
|
+
}
|
|
758
|
+
get pixelRatio() {
|
|
759
|
+
return this._pixelRatio;
|
|
760
|
+
}
|
|
761
|
+
get isRoundBars() {
|
|
762
|
+
return this._flg.isRound;
|
|
763
|
+
}
|
|
764
|
+
static get version() {
|
|
765
|
+
return VERSION;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* ==========================================================================
|
|
770
|
+
*
|
|
771
|
+
* PUBLIC METHODS
|
|
772
|
+
*
|
|
773
|
+
* ==========================================================================
|
|
774
|
+
*/
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Connects an HTML media element or audio node to the analyzer
|
|
778
|
+
*
|
|
779
|
+
* @param {object} an instance of HTMLMediaElement or AudioNode
|
|
780
|
+
* @returns {object} a MediaElementAudioSourceNode object if created from HTML element, or the same input object otherwise
|
|
781
|
+
*/
|
|
782
|
+
connectInput(source) {
|
|
783
|
+
const isHTML = source instanceof HTMLMediaElement;
|
|
784
|
+
if (!(isHTML || source.connect)) throw new AudioMotionError(ERR_INVALID_AUDIO_SOURCE);
|
|
785
|
+
|
|
786
|
+
// if source is an HTML element, create an audio node for it; otherwise, use the provided audio node
|
|
787
|
+
const node = isHTML ? this.audioCtx.createMediaElementSource(source) : source;
|
|
788
|
+
if (!this._sources.includes(node)) {
|
|
789
|
+
node.connect(this._input);
|
|
790
|
+
this._sources.push(node);
|
|
791
|
+
}
|
|
792
|
+
return node;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Connects the analyzer output to another audio node
|
|
797
|
+
*
|
|
798
|
+
* @param [{object}] an AudioNode; if undefined, the output is connected to the audio context destination (speakers)
|
|
799
|
+
*/
|
|
800
|
+
connectOutput(node = this.audioCtx.destination) {
|
|
801
|
+
if (this._outNodes.includes(node)) return;
|
|
802
|
+
this._output.connect(node);
|
|
803
|
+
this._outNodes.push(node);
|
|
804
|
+
|
|
805
|
+
// when connecting the first node, also connect the analyzer nodes to the merger / output nodes
|
|
806
|
+
if (this._outNodes.length == 1) {
|
|
807
|
+
for (const i of [0, 1]) this._analyzer[i].connect(this._chLayout == CHANNEL_SINGLE && !i ? this._output : this._merger, 0, i);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Destroys instance
|
|
813
|
+
*/
|
|
814
|
+
destroy() {
|
|
815
|
+
if (!this._ready) return;
|
|
816
|
+
const {
|
|
817
|
+
audioCtx,
|
|
818
|
+
canvas,
|
|
819
|
+
_controller,
|
|
820
|
+
_input,
|
|
821
|
+
_merger,
|
|
822
|
+
_observer,
|
|
823
|
+
_ownCanvas,
|
|
824
|
+
_ownContext,
|
|
825
|
+
_splitter
|
|
826
|
+
} = this;
|
|
827
|
+
this._destroyed = true;
|
|
828
|
+
this._ready = false;
|
|
829
|
+
this.stop();
|
|
830
|
+
|
|
831
|
+
// remove event listeners
|
|
832
|
+
_controller.abort();
|
|
833
|
+
if (_observer) _observer.disconnect();
|
|
834
|
+
|
|
835
|
+
// clear callbacks and fullscreen element
|
|
836
|
+
this.onCanvasResize = null;
|
|
837
|
+
this.onCanvasDraw = null;
|
|
838
|
+
this._fsEl = null;
|
|
839
|
+
|
|
840
|
+
// disconnect audio nodes
|
|
841
|
+
this.disconnectInput();
|
|
842
|
+
this.disconnectOutput(); // also disconnects analyzer nodes
|
|
843
|
+
_input.disconnect();
|
|
844
|
+
_splitter.disconnect();
|
|
845
|
+
_merger.disconnect();
|
|
846
|
+
|
|
847
|
+
// if audio context is our own (not provided by the user), close it
|
|
848
|
+
if (_ownContext) audioCtx.close();
|
|
849
|
+
|
|
850
|
+
// remove canvas from the DOM (if not provided by the user)
|
|
851
|
+
if (_ownCanvas) canvas.remove();
|
|
852
|
+
|
|
853
|
+
// reset flags
|
|
854
|
+
this._calcBars();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Disconnects audio sources from the analyzer
|
|
859
|
+
*
|
|
860
|
+
* @param [{object|array}] a connected AudioNode object or an array of such objects; if falsy, all connected nodes are disconnected
|
|
861
|
+
* @param [{boolean}] if true, stops/releases audio tracks from disconnected media streams (e.g. microphone)
|
|
862
|
+
*/
|
|
863
|
+
disconnectInput(sources, stopTracks) {
|
|
864
|
+
if (!sources) sources = Array.from(this._sources);else if (!Array.isArray(sources)) sources = [sources];
|
|
865
|
+
for (const node of sources) {
|
|
866
|
+
const idx = this._sources.indexOf(node);
|
|
867
|
+
if (stopTracks && node.mediaStream) {
|
|
868
|
+
for (const track of node.mediaStream.getAudioTracks()) {
|
|
869
|
+
track.stop();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (idx >= 0) {
|
|
873
|
+
node.disconnect(this._input);
|
|
874
|
+
this._sources.splice(idx, 1);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Disconnects the analyzer output from other audio nodes
|
|
881
|
+
*
|
|
882
|
+
* @param [{object}] a connected AudioNode object; if undefined, all connected nodes are disconnected
|
|
883
|
+
*/
|
|
884
|
+
disconnectOutput(node) {
|
|
885
|
+
if (node && !this._outNodes.includes(node)) return;
|
|
886
|
+
this._output.disconnect(node);
|
|
887
|
+
this._outNodes = node ? this._outNodes.filter(e => e !== node) : [];
|
|
888
|
+
|
|
889
|
+
// if disconnected from all nodes, also disconnect the analyzer nodes so they keep working on Chromium
|
|
890
|
+
// see https://github.com/hvianna/audioMotion-analyzer/issues/13#issuecomment-808764848
|
|
891
|
+
if (this._outNodes.length == 0) {
|
|
892
|
+
for (const i of [0, 1]) this._analyzer[i].disconnect();
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Returns analyzer bars data
|
|
898
|
+
*
|
|
899
|
+
* @returns {array}
|
|
900
|
+
*/
|
|
901
|
+
getBars() {
|
|
902
|
+
return Array.from(this._bars, ({
|
|
903
|
+
posX,
|
|
904
|
+
freq,
|
|
905
|
+
freqLo,
|
|
906
|
+
freqHi,
|
|
907
|
+
hold,
|
|
908
|
+
peak,
|
|
909
|
+
value
|
|
910
|
+
}) => ({
|
|
911
|
+
posX,
|
|
912
|
+
freq,
|
|
913
|
+
freqLo,
|
|
914
|
+
freqHi,
|
|
915
|
+
hold,
|
|
916
|
+
peak,
|
|
917
|
+
value
|
|
918
|
+
}));
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Returns the energy of a frequency, or average energy of a range of frequencies
|
|
923
|
+
*
|
|
924
|
+
* @param [{number|string}] single or initial frequency (Hz), or preset name; if undefined, returns the overall energy
|
|
925
|
+
* @param [{number}] ending frequency (Hz)
|
|
926
|
+
* @returns {number|null} energy value (0 to 1) or null, if the specified preset is unknown
|
|
927
|
+
*/
|
|
928
|
+
getEnergy(startFreq, endFreq) {
|
|
929
|
+
if (startFreq === undefined) return this._energy.val;
|
|
930
|
+
|
|
931
|
+
// if startFreq is a string, check for presets
|
|
932
|
+
if (startFreq != +startFreq) {
|
|
933
|
+
if (startFreq == 'peak') return this._energy.peak;
|
|
934
|
+
const presets = {
|
|
935
|
+
bass: [20, 250],
|
|
936
|
+
lowMid: [250, 500],
|
|
937
|
+
mid: [500, 2e3],
|
|
938
|
+
highMid: [2e3, 4e3],
|
|
939
|
+
treble: [4e3, 16e3]
|
|
940
|
+
};
|
|
941
|
+
if (!presets[startFreq]) return null;
|
|
942
|
+
[startFreq, endFreq] = presets[startFreq];
|
|
943
|
+
}
|
|
944
|
+
const startBin = this._freqToBin(startFreq),
|
|
945
|
+
endBin = endFreq ? this._freqToBin(endFreq) : startBin,
|
|
946
|
+
chnCount = this._chLayout == CHANNEL_SINGLE ? 1 : 2;
|
|
947
|
+
let energy = 0;
|
|
948
|
+
for (let channel = 0; channel < chnCount; channel++) {
|
|
949
|
+
for (let i = startBin; i <= endBin; i++) energy += this._normalizedB(this._fftData[channel][i]);
|
|
950
|
+
}
|
|
951
|
+
return energy / (endBin - startBin + 1) / chnCount;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Returns current analyzer settings in object format
|
|
956
|
+
*
|
|
957
|
+
* @param [{string|array}] a property name or an array of property names to not include in the returned object
|
|
958
|
+
* @returns {object} Options object
|
|
959
|
+
*/
|
|
960
|
+
getOptions(ignore) {
|
|
961
|
+
if (!Array.isArray(ignore)) ignore = [ignore];
|
|
962
|
+
let options = {};
|
|
963
|
+
for (const prop of Object.keys(DEFAULT_SETTINGS)) {
|
|
964
|
+
if (!ignore.includes(prop)) {
|
|
965
|
+
if (prop == 'gradient' && this.gradientLeft != this.gradientRight) {
|
|
966
|
+
options.gradientLeft = this.gradientLeft;
|
|
967
|
+
options.gradientRight = this.gradientRight;
|
|
968
|
+
} else if (prop != 'start') options[prop] = this[prop];
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return options;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Registers a custom gradient
|
|
976
|
+
*
|
|
977
|
+
* @param {string} name
|
|
978
|
+
* @param {object} options
|
|
979
|
+
*/
|
|
980
|
+
registerGradient(name, options) {
|
|
981
|
+
if (typeof name != 'string' || name.trim().length == 0) throw new AudioMotionError(ERR_GRADIENT_INVALID_NAME);
|
|
982
|
+
if (typeof options != 'object') throw new AudioMotionError(ERR_GRADIENT_NOT_AN_OBJECT);
|
|
983
|
+
const {
|
|
984
|
+
colorStops
|
|
985
|
+
} = options;
|
|
986
|
+
if (!Array.isArray(colorStops) || !colorStops.length) throw new AudioMotionError(ERR_GRADIENT_MISSING_COLOR);
|
|
987
|
+
const count = colorStops.length,
|
|
988
|
+
isInvalid = val => +val != val || val < 0 || val > 1;
|
|
989
|
+
|
|
990
|
+
// normalize all colorStops as objects with `pos`, `color` and `level` properties
|
|
991
|
+
colorStops.forEach((colorStop, index) => {
|
|
992
|
+
const pos = index / Math.max(1, count - 1);
|
|
993
|
+
if (typeof colorStop != 'object')
|
|
994
|
+
// only color string was defined
|
|
995
|
+
colorStops[index] = {
|
|
996
|
+
pos,
|
|
997
|
+
color: colorStop
|
|
998
|
+
};else if (isInvalid(colorStop.pos)) colorStop.pos = pos;
|
|
999
|
+
if (isInvalid(colorStop.level)) colorStops[index].level = 1 - index / count;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// make sure colorStops is in descending `level` order and that the first one has `level == 1`
|
|
1003
|
+
// this is crucial for proper operation of 'bar-level' colorMode!
|
|
1004
|
+
colorStops.sort((a, b) => a.level < b.level ? 1 : a.level > b.level ? -1 : 0);
|
|
1005
|
+
colorStops[0].level = 1;
|
|
1006
|
+
this._gradients[name] = {
|
|
1007
|
+
bgColor: options.bgColor || GRADIENT_DEFAULT_BGCOLOR,
|
|
1008
|
+
dir: options.dir,
|
|
1009
|
+
colorStops: colorStops
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
// if the registered gradient is one of the currently selected gradients, regenerate them
|
|
1013
|
+
if (this._selectedGrads.includes(name)) this._makeGrad();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Set dimensions of analyzer's canvas
|
|
1018
|
+
*
|
|
1019
|
+
* @param {number} w width in pixels
|
|
1020
|
+
* @param {number} h height in pixels
|
|
1021
|
+
*/
|
|
1022
|
+
setCanvasSize(w, h) {
|
|
1023
|
+
this._width = w;
|
|
1024
|
+
this._height = h;
|
|
1025
|
+
this._setCanvas(REASON_USER);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Set desired frequency range
|
|
1030
|
+
*
|
|
1031
|
+
* @param {number} min lowest frequency represented in the x-axis
|
|
1032
|
+
* @param {number} max highest frequency represented in the x-axis
|
|
1033
|
+
*/
|
|
1034
|
+
setFreqRange(min, max) {
|
|
1035
|
+
if (min < 1 || max < 1) throw new AudioMotionError(ERR_FREQUENCY_TOO_LOW);else {
|
|
1036
|
+
this._minFreq = Math.min(min, max);
|
|
1037
|
+
this.maxFreq = Math.max(min, max); // use the setter for maxFreq
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Set custom parameters for LED effect
|
|
1043
|
+
* If called with no arguments or if any property is invalid, clears any previous custom parameters
|
|
1044
|
+
*
|
|
1045
|
+
* @param {object} [params]
|
|
1046
|
+
*/
|
|
1047
|
+
setLedParams(params) {
|
|
1048
|
+
let maxLeds, spaceV, spaceH;
|
|
1049
|
+
|
|
1050
|
+
// coerce parameters to Number; `NaN` results are rejected in the condition below
|
|
1051
|
+
if (params) {
|
|
1052
|
+
maxLeds = params.maxLeds | 0,
|
|
1053
|
+
// ensure integer
|
|
1054
|
+
spaceV = +params.spaceV, spaceH = +params.spaceH;
|
|
1055
|
+
}
|
|
1056
|
+
this._ledParams = maxLeds > 0 && spaceV > 0 && spaceH >= 0 ? [maxLeds, spaceV, spaceH] : undefined;
|
|
1057
|
+
this._calcBars();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Shorthand function for setting several options at once
|
|
1062
|
+
*
|
|
1063
|
+
* @param {object} options
|
|
1064
|
+
*/
|
|
1065
|
+
setOptions(options) {
|
|
1066
|
+
this._setProps(options);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Adjust the analyzer's sensitivity
|
|
1071
|
+
*
|
|
1072
|
+
* @param {number} min minimum decibels value
|
|
1073
|
+
* @param {number} max maximum decibels value
|
|
1074
|
+
*/
|
|
1075
|
+
setSensitivity(min, max) {
|
|
1076
|
+
for (const i of [0, 1]) {
|
|
1077
|
+
this._analyzer[i].minDecibels = Math.min(min, max);
|
|
1078
|
+
this._analyzer[i].maxDecibels = Math.max(min, max);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Start the analyzer
|
|
1084
|
+
*/
|
|
1085
|
+
start() {
|
|
1086
|
+
this.toggleAnalyzer(true);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Stop the analyzer
|
|
1091
|
+
*/
|
|
1092
|
+
stop() {
|
|
1093
|
+
this.toggleAnalyzer(false);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Start / stop canvas animation
|
|
1098
|
+
*
|
|
1099
|
+
* @param {boolean} [force] if undefined, inverts the current state
|
|
1100
|
+
* @returns {boolean} resulting state after the change
|
|
1101
|
+
*/
|
|
1102
|
+
toggleAnalyzer(force) {
|
|
1103
|
+
const hasStarted = this.isOn;
|
|
1104
|
+
if (force === undefined) force = !hasStarted;
|
|
1105
|
+
|
|
1106
|
+
// Stop the analyzer if it was already running and must be disabled
|
|
1107
|
+
if (hasStarted && !force) {
|
|
1108
|
+
cancelAnimationFrame(this._runId);
|
|
1109
|
+
this._runId = 0;
|
|
1110
|
+
}
|
|
1111
|
+
// Start the analyzer if it was stopped and must be enabled
|
|
1112
|
+
else if (!hasStarted && force && !this._destroyed) {
|
|
1113
|
+
this._frames = 0;
|
|
1114
|
+
this._time = performance.now();
|
|
1115
|
+
this._runId = requestAnimationFrame(timestamp => this._draw(timestamp)); // arrow function preserves the scope of *this*
|
|
1116
|
+
}
|
|
1117
|
+
return this.isOn;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Toggles canvas full-screen mode
|
|
1122
|
+
*/
|
|
1123
|
+
toggleFullscreen() {
|
|
1124
|
+
if (this.isFullscreen) {
|
|
1125
|
+
if (document.exitFullscreen) document.exitFullscreen();else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
|
|
1126
|
+
} else {
|
|
1127
|
+
const fsEl = this._fsEl;
|
|
1128
|
+
if (!fsEl) return;
|
|
1129
|
+
if (fsEl.requestFullscreen) fsEl.requestFullscreen();else if (fsEl.webkitRequestFullscreen) fsEl.webkitRequestFullscreen();
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* ==========================================================================
|
|
1135
|
+
*
|
|
1136
|
+
* PRIVATE METHODS
|
|
1137
|
+
*
|
|
1138
|
+
* ==========================================================================
|
|
1139
|
+
*/
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Return the frequency (in Hz) for a given FFT bin
|
|
1143
|
+
*/
|
|
1144
|
+
_binToFreq(bin) {
|
|
1145
|
+
return bin * this.audioCtx.sampleRate / this.fftSize || 1; // returns 1 for bin 0
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Compute all internal data required for the analyzer, based on its current settings
|
|
1150
|
+
*/
|
|
1151
|
+
_calcBars() {
|
|
1152
|
+
const bars = this._bars = []; // initialize object property
|
|
1153
|
+
|
|
1154
|
+
if (!this._ready) {
|
|
1155
|
+
this._flg = {
|
|
1156
|
+
isAlpha: false,
|
|
1157
|
+
isBands: false,
|
|
1158
|
+
isLeds: false,
|
|
1159
|
+
isLumi: false,
|
|
1160
|
+
isOctaves: false,
|
|
1161
|
+
isOutline: false,
|
|
1162
|
+
isRound: false,
|
|
1163
|
+
noLedGap: false
|
|
1164
|
+
};
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const {
|
|
1168
|
+
_ansiBands,
|
|
1169
|
+
_barSpace,
|
|
1170
|
+
canvas,
|
|
1171
|
+
_chLayout,
|
|
1172
|
+
_maxFreq,
|
|
1173
|
+
_minFreq,
|
|
1174
|
+
_mirror,
|
|
1175
|
+
_mode,
|
|
1176
|
+
_radial,
|
|
1177
|
+
_radialInvert,
|
|
1178
|
+
_reflexRatio
|
|
1179
|
+
} = this,
|
|
1180
|
+
centerX = canvas.width >> 1,
|
|
1181
|
+
centerY = canvas.height >> 1,
|
|
1182
|
+
isDualVertical = _chLayout == CHANNEL_VERTICAL && !_radial,
|
|
1183
|
+
isDualHorizontal = _chLayout == CHANNEL_HORIZONTAL,
|
|
1184
|
+
// COMPUTE FLAGS
|
|
1185
|
+
|
|
1186
|
+
isBands = _mode % 10 != 0,
|
|
1187
|
+
// true for modes 1 to 9
|
|
1188
|
+
isOctaves = isBands && this._frequencyScale == SCALE_LOG,
|
|
1189
|
+
isLeds = this._showLeds && isBands && !_radial,
|
|
1190
|
+
isLumi = this._lumiBars && isBands && !_radial,
|
|
1191
|
+
isAlpha = this._alphaBars && !isLumi && _mode != MODE_GRAPH,
|
|
1192
|
+
isOutline = this._outlineBars && isBands && !isLumi && !isLeds,
|
|
1193
|
+
isRound = this._roundBars && isBands && !isLumi && !isLeds,
|
|
1194
|
+
noLedGap = _chLayout != CHANNEL_VERTICAL || _reflexRatio > 0 && !isLumi,
|
|
1195
|
+
// COMPUTE AUXILIARY VALUES
|
|
1196
|
+
|
|
1197
|
+
// channelHeight is the total canvas height dedicated to each channel, including the reflex area, if any)
|
|
1198
|
+
channelHeight = canvas.height - (isDualVertical && !isLeds ? .5 : 0) >> isDualVertical,
|
|
1199
|
+
// analyzerHeight is the effective height used to render the analyzer, excluding the reflex area
|
|
1200
|
+
analyzerHeight = channelHeight * (isLumi || _radial ? 1 : 1 - _reflexRatio) | 0,
|
|
1201
|
+
analyzerWidth = canvas.width - centerX * (isDualHorizontal || _mirror != 0),
|
|
1202
|
+
// channelGap is **0** if isLedDisplay == true (LEDs already have spacing); **1** if canvas height is odd (windowed); **2** if it's even
|
|
1203
|
+
// TODO: improve this, make it configurable?
|
|
1204
|
+
channelGap = isDualVertical ? canvas.height - channelHeight * 2 : 0,
|
|
1205
|
+
initialX = centerX * (_mirror == -1 && !isDualHorizontal && !_radial);
|
|
1206
|
+
let innerRadius = Math.min(canvas.width, canvas.height) * .375 * (_chLayout == CHANNEL_VERTICAL ? 1 : this._radius) | 0,
|
|
1207
|
+
outerRadius = Math.min(centerX, centerY);
|
|
1208
|
+
if (_radialInvert && _chLayout != CHANNEL_VERTICAL) [innerRadius, outerRadius] = [outerRadius, innerRadius];
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* CREATE ANALYZER BANDS
|
|
1212
|
+
*
|
|
1213
|
+
* USES:
|
|
1214
|
+
* analyzerWidth
|
|
1215
|
+
* initialX
|
|
1216
|
+
* isBands
|
|
1217
|
+
* isOctaves
|
|
1218
|
+
*
|
|
1219
|
+
* GENERATES:
|
|
1220
|
+
* bars (populates this._bars)
|
|
1221
|
+
* bardWidth
|
|
1222
|
+
* scaleMin
|
|
1223
|
+
* unitWidth
|
|
1224
|
+
*/
|
|
1225
|
+
|
|
1226
|
+
// helper function
|
|
1227
|
+
// bar object: { posX, freq, freqLo, freqHi, binLo, binHi, ratioLo, ratioHi, peak, hold, value }
|
|
1228
|
+
const barsPush = args => bars.push({
|
|
1229
|
+
...args,
|
|
1230
|
+
peak: [0, 0],
|
|
1231
|
+
hold: [0],
|
|
1232
|
+
value: [0]
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
/*
|
|
1236
|
+
A simple interpolation is used to obtain an approximate amplitude value for any given frequency,
|
|
1237
|
+
from the available FFT data. We find the FFT bin which closer matches the desired frequency and
|
|
1238
|
+
interpolate its value with that of the next adjacent bin, like so:
|
|
1239
|
+
v = v0 + ( v1 - v0 ) * ( log2( f / f0 ) / log2( f1 / f0 ) )
|
|
1240
|
+
\__________________________________/
|
|
1241
|
+
|
|
|
1242
|
+
ratio
|
|
1243
|
+
where:
|
|
1244
|
+
f - desired frequency
|
|
1245
|
+
v - amplitude (volume) of desired frequency
|
|
1246
|
+
f0 - frequency represented by the lower FFT bin
|
|
1247
|
+
f1 - frequency represented by the upper FFT bin
|
|
1248
|
+
v0 - amplitude of f0
|
|
1249
|
+
v1 - amplitude of f1
|
|
1250
|
+
ratio is calculated in advance here, to reduce computational complexity during real-time rendering.
|
|
1251
|
+
*/
|
|
1252
|
+
|
|
1253
|
+
// helper function to calculate FFT bin and interpolation ratio for a given frequency
|
|
1254
|
+
const calcRatio = freq => {
|
|
1255
|
+
const bin = this._freqToBin(freq, 'floor'),
|
|
1256
|
+
// find closest FFT bin
|
|
1257
|
+
lower = this._binToFreq(bin),
|
|
1258
|
+
upper = this._binToFreq(bin + 1),
|
|
1259
|
+
ratio = Math.log2(freq / lower) / Math.log2(upper / lower);
|
|
1260
|
+
return [bin, ratio];
|
|
1261
|
+
};
|
|
1262
|
+
let barWidth, scaleMin, unitWidth;
|
|
1263
|
+
if (isOctaves) {
|
|
1264
|
+
// helper function to round a value to a given number of significant digits
|
|
1265
|
+
// `atLeast` set to true prevents reducing the number of integer significant digits
|
|
1266
|
+
const roundSD = (value, digits, atLeast) => +value.toPrecision(atLeast ? Math.max(digits, 1 + Math.log10(value) | 0) : digits);
|
|
1267
|
+
|
|
1268
|
+
// helper function to find the nearest preferred number (Renard series) for a given value
|
|
1269
|
+
const nearestPreferred = value => {
|
|
1270
|
+
// R20 series is used here, as it provides closer approximations for 1/2 octave bands (non-standard)
|
|
1271
|
+
const preferred = [1, 1.12, 1.25, 1.4, 1.6, 1.8, 2, 2.24, 2.5, 2.8, 3.15, 3.55, 4, 4.5, 5, 5.6, 6.3, 7.1, 8, 9, 10],
|
|
1272
|
+
power = Math.log10(value) | 0,
|
|
1273
|
+
normalized = value / 10 ** power;
|
|
1274
|
+
let i = 1;
|
|
1275
|
+
while (i < preferred.length && normalized > preferred[i]) i++;
|
|
1276
|
+
if (normalized - preferred[i - 1] < preferred[i] - normalized) i--;
|
|
1277
|
+
return (preferred[i] * 10 ** (power + 5) | 0) / 1e5; // keep 5 significant digits
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
// ANSI standard octave bands use the base-10 frequency ratio, as preferred by [ANSI S1.11-2004, p.2]
|
|
1281
|
+
// The equal-tempered scale uses the base-2 ratio
|
|
1282
|
+
const bands = [0, 24, 12, 8, 6, 4, 3, 2, 1][_mode],
|
|
1283
|
+
bandWidth = _ansiBands ? 10 ** (3 / (bands * 10)) : 2 ** (1 / bands),
|
|
1284
|
+
// 10^(3/10N) or 2^(1/N)
|
|
1285
|
+
halfBand = bandWidth ** .5;
|
|
1286
|
+
let analyzerBars = [],
|
|
1287
|
+
currFreq = _ansiBands ? 7.94328235 / (bands % 2 ? 1 : halfBand) : C_1;
|
|
1288
|
+
// For ANSI bands with even denominators (all except 1/1 and 1/3), the reference frequency (1 kHz)
|
|
1289
|
+
// must fall on the edges of a pair of adjacent bands, instead of midband [ANSI S1.11-2004, p.2]
|
|
1290
|
+
// In the equal-tempered scale, all midband frequencies represent a musical note or quarter-tone.
|
|
1291
|
+
|
|
1292
|
+
do {
|
|
1293
|
+
let freq = currFreq; // midband frequency
|
|
1294
|
+
|
|
1295
|
+
const freqLo = roundSD(freq / halfBand, 4, true),
|
|
1296
|
+
// lower edge frequency
|
|
1297
|
+
freqHi = roundSD(freq * halfBand, 4, true),
|
|
1298
|
+
// upper edge frequency
|
|
1299
|
+
[binLo, ratioLo] = calcRatio(freqLo),
|
|
1300
|
+
[binHi, ratioHi] = calcRatio(freqHi);
|
|
1301
|
+
|
|
1302
|
+
// for 1/1, 1/2 and 1/3 ANSI bands, use the preferred numbers to find the nominal midband frequency
|
|
1303
|
+
// for 1/4 to 1/24, round to 2 or 3 significant digits, according to the MSD [ANSI S1.11-2004, p.12]
|
|
1304
|
+
if (_ansiBands) freq = bands < 4 ? nearestPreferred(freq) : roundSD(freq, freq.toString()[0] < 5 ? 3 : 2);else freq = roundSD(freq, 4, true);
|
|
1305
|
+
if (freq >= _minFreq) barsPush({
|
|
1306
|
+
posX: 0,
|
|
1307
|
+
freq,
|
|
1308
|
+
freqLo,
|
|
1309
|
+
freqHi,
|
|
1310
|
+
binLo,
|
|
1311
|
+
binHi,
|
|
1312
|
+
ratioLo,
|
|
1313
|
+
ratioHi
|
|
1314
|
+
});
|
|
1315
|
+
currFreq *= bandWidth;
|
|
1316
|
+
} while (currFreq <= _maxFreq);
|
|
1317
|
+
barWidth = analyzerWidth / bars.length;
|
|
1318
|
+
bars.forEach((bar, index) => bar.posX = initialX + index * barWidth);
|
|
1319
|
+
const firstBar = bars[0],
|
|
1320
|
+
lastBar = bars[bars.length - 1];
|
|
1321
|
+
scaleMin = this._freqScaling(firstBar.freqLo);
|
|
1322
|
+
unitWidth = analyzerWidth / (this._freqScaling(lastBar.freqHi) - scaleMin);
|
|
1323
|
+
|
|
1324
|
+
// clamp edge frequencies to minFreq / maxFreq, if necessary
|
|
1325
|
+
// this is done after computing scaleMin and unitWidth, for the proper positioning of labels on the X-axis
|
|
1326
|
+
if (firstBar.freqLo < _minFreq) {
|
|
1327
|
+
firstBar.freqLo = _minFreq;
|
|
1328
|
+
[firstBar.binLo, firstBar.ratioLo] = calcRatio(_minFreq);
|
|
1329
|
+
}
|
|
1330
|
+
if (lastBar.freqHi > _maxFreq) {
|
|
1331
|
+
lastBar.freqHi = _maxFreq;
|
|
1332
|
+
[lastBar.binHi, lastBar.ratioHi] = calcRatio(_maxFreq);
|
|
1333
|
+
}
|
|
1334
|
+
} else if (isBands) {
|
|
1335
|
+
// a bands mode is selected, but frequency scale is not logarithmic
|
|
1336
|
+
|
|
1337
|
+
const bands = [0, 24, 12, 8, 6, 4, 3, 2, 1][_mode] * 10;
|
|
1338
|
+
const invFreqScaling = x => {
|
|
1339
|
+
switch (this._frequencyScale) {
|
|
1340
|
+
case SCALE_BARK:
|
|
1341
|
+
return 1960 / (26.81 / (x + .53) - 1);
|
|
1342
|
+
case SCALE_MEL:
|
|
1343
|
+
return 700 * (2 ** x - 1);
|
|
1344
|
+
case SCALE_LINEAR:
|
|
1345
|
+
return x;
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
barWidth = analyzerWidth / bands;
|
|
1349
|
+
scaleMin = this._freqScaling(_minFreq);
|
|
1350
|
+
unitWidth = analyzerWidth / (this._freqScaling(_maxFreq) - scaleMin);
|
|
1351
|
+
for (let i = 0, posX = 0; i < bands; i++, posX += barWidth) {
|
|
1352
|
+
const freqLo = invFreqScaling(scaleMin + posX / unitWidth),
|
|
1353
|
+
freq = invFreqScaling(scaleMin + (posX + barWidth / 2) / unitWidth),
|
|
1354
|
+
freqHi = invFreqScaling(scaleMin + (posX + barWidth) / unitWidth),
|
|
1355
|
+
[binLo, ratioLo] = calcRatio(freqLo),
|
|
1356
|
+
[binHi, ratioHi] = calcRatio(freqHi);
|
|
1357
|
+
barsPush({
|
|
1358
|
+
posX: initialX + posX,
|
|
1359
|
+
freq,
|
|
1360
|
+
freqLo,
|
|
1361
|
+
freqHi,
|
|
1362
|
+
binLo,
|
|
1363
|
+
binHi,
|
|
1364
|
+
ratioLo,
|
|
1365
|
+
ratioHi
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
} else {
|
|
1369
|
+
// Discrete frequencies modes
|
|
1370
|
+
barWidth = 1;
|
|
1371
|
+
scaleMin = this._freqScaling(_minFreq);
|
|
1372
|
+
unitWidth = analyzerWidth / (this._freqScaling(_maxFreq) - scaleMin);
|
|
1373
|
+
const minIndex = this._freqToBin(_minFreq, 'floor'),
|
|
1374
|
+
maxIndex = this._freqToBin(_maxFreq);
|
|
1375
|
+
let lastPos = -999;
|
|
1376
|
+
for (let i = minIndex; i <= maxIndex; i++) {
|
|
1377
|
+
const freq = this._binToFreq(i),
|
|
1378
|
+
// frequency represented by this index
|
|
1379
|
+
posX = initialX + Math.round(unitWidth * (this._freqScaling(freq) - scaleMin)); // avoid fractionary pixel values
|
|
1380
|
+
|
|
1381
|
+
// if it's on a different X-coordinate, create a new bar for this frequency
|
|
1382
|
+
if (posX > lastPos) {
|
|
1383
|
+
barsPush({
|
|
1384
|
+
posX,
|
|
1385
|
+
freq,
|
|
1386
|
+
freqLo: freq,
|
|
1387
|
+
freqHi: freq,
|
|
1388
|
+
binLo: i,
|
|
1389
|
+
binHi: i,
|
|
1390
|
+
ratioLo: 0,
|
|
1391
|
+
ratioHi: 0
|
|
1392
|
+
});
|
|
1393
|
+
lastPos = posX;
|
|
1394
|
+
} // otherwise, add this frequency to the last bar's range
|
|
1395
|
+
else if (bars.length) {
|
|
1396
|
+
const lastBar = bars[bars.length - 1];
|
|
1397
|
+
lastBar.binHi = i;
|
|
1398
|
+
lastBar.freqHi = freq;
|
|
1399
|
+
lastBar.freq = (lastBar.freqLo * freq) ** .5; // compute center frequency (geometric mean)
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* COMPUTE ATTRIBUTES FOR THE LED BARS
|
|
1406
|
+
*
|
|
1407
|
+
* USES:
|
|
1408
|
+
* analyzerHeight
|
|
1409
|
+
* barWidth
|
|
1410
|
+
* noLedGap
|
|
1411
|
+
*
|
|
1412
|
+
* GENERATES:
|
|
1413
|
+
* spaceH
|
|
1414
|
+
* spaceV
|
|
1415
|
+
* this._leds
|
|
1416
|
+
*/
|
|
1417
|
+
|
|
1418
|
+
let spaceH = 0,
|
|
1419
|
+
spaceV = 0;
|
|
1420
|
+
if (isLeds) {
|
|
1421
|
+
// adjustment for high pixel-ratio values on low-resolution screens (Android TV)
|
|
1422
|
+
const dPR = this._pixelRatio / (window.devicePixelRatio > 1 && window.screen.height <= 540 ? 2 : 1);
|
|
1423
|
+
const params = [[], [128, 3, .45],
|
|
1424
|
+
// mode 1
|
|
1425
|
+
[128, 4, .225],
|
|
1426
|
+
// mode 2
|
|
1427
|
+
[96, 6, .225],
|
|
1428
|
+
// mode 3
|
|
1429
|
+
[80, 6, .225],
|
|
1430
|
+
// mode 4
|
|
1431
|
+
[80, 6, .125],
|
|
1432
|
+
// mode 5
|
|
1433
|
+
[64, 6, .125],
|
|
1434
|
+
// mode 6
|
|
1435
|
+
[48, 8, .125],
|
|
1436
|
+
// mode 7
|
|
1437
|
+
[24, 16, .125] // mode 8
|
|
1438
|
+
];
|
|
1439
|
+
|
|
1440
|
+
// use custom LED parameters if set, or the default parameters for the current mode
|
|
1441
|
+
const customParams = this._ledParams,
|
|
1442
|
+
[maxLeds, spaceVRatio, spaceHRatio] = customParams || params[_mode];
|
|
1443
|
+
let ledCount,
|
|
1444
|
+
maxHeight = analyzerHeight;
|
|
1445
|
+
if (customParams) {
|
|
1446
|
+
const minHeight = 2 * dPR;
|
|
1447
|
+
let blockHeight;
|
|
1448
|
+
ledCount = maxLeds + 1;
|
|
1449
|
+
do {
|
|
1450
|
+
ledCount--;
|
|
1451
|
+
blockHeight = maxHeight / ledCount / (1 + spaceVRatio);
|
|
1452
|
+
spaceV = blockHeight * spaceVRatio;
|
|
1453
|
+
} while ((blockHeight < minHeight || spaceV < minHeight) && ledCount > 1);
|
|
1454
|
+
} else {
|
|
1455
|
+
// calculate vertical spacing - aim for the reference ratio, but make sure it's at least 2px
|
|
1456
|
+
const refRatio = 540 / spaceVRatio;
|
|
1457
|
+
spaceV = Math.min(spaceVRatio * dPR, Math.max(2, maxHeight / refRatio + .1 | 0));
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// remove the extra spacing below the last line of LEDs
|
|
1461
|
+
if (noLedGap) maxHeight += spaceV;
|
|
1462
|
+
|
|
1463
|
+
// recalculate the number of leds, considering the effective spaceV
|
|
1464
|
+
if (!customParams) ledCount = Math.min(maxLeds, maxHeight / (spaceV * 2) | 0);
|
|
1465
|
+
spaceH = spaceHRatio >= 1 ? spaceHRatio : barWidth * spaceHRatio;
|
|
1466
|
+
this._leds = [ledCount, spaceH, spaceV, maxHeight / ledCount - spaceV // ledHeight
|
|
1467
|
+
];
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// COMPUTE ADDITIONAL BAR POSITIONING, ACCORDING TO THE CURRENT SETTINGS
|
|
1471
|
+
// uses: _barSpace, barWidth, spaceH
|
|
1472
|
+
|
|
1473
|
+
const barSpacePx = Math.min(barWidth - 1, _barSpace * (_barSpace > 0 && _barSpace < 1 ? barWidth : 1));
|
|
1474
|
+
if (isBands) barWidth -= Math.max(isLeds ? spaceH : 0, barSpacePx);
|
|
1475
|
+
bars.forEach((bar, index) => {
|
|
1476
|
+
let posX = bar.posX,
|
|
1477
|
+
width = barWidth;
|
|
1478
|
+
|
|
1479
|
+
// in bands modes we need to update bar.posX to account for bar/led spacing
|
|
1480
|
+
|
|
1481
|
+
if (isBands) {
|
|
1482
|
+
if (_barSpace == 0 && !isLeds) {
|
|
1483
|
+
// when barSpace == 0 use integer values for perfect gapless positioning
|
|
1484
|
+
posX |= 0;
|
|
1485
|
+
width |= 0;
|
|
1486
|
+
if (index > 0 && posX > bars[index - 1].posX + bars[index - 1].width) {
|
|
1487
|
+
posX--;
|
|
1488
|
+
width++;
|
|
1489
|
+
}
|
|
1490
|
+
} else posX += Math.max(isLeds ? spaceH : 0, barSpacePx) / 2;
|
|
1491
|
+
bar.posX = posX; // update
|
|
1492
|
+
}
|
|
1493
|
+
bar.barCenter = posX + (barWidth == 1 ? 0 : width / 2);
|
|
1494
|
+
bar.width = width;
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
// COMPUTE CHANNEL COORDINATES (uses spaceV)
|
|
1498
|
+
|
|
1499
|
+
const channelCoords = [];
|
|
1500
|
+
for (const channel of [0, 1]) {
|
|
1501
|
+
const channelTop = _chLayout == CHANNEL_VERTICAL ? (channelHeight + channelGap) * channel : 0,
|
|
1502
|
+
channelBottom = channelTop + channelHeight,
|
|
1503
|
+
analyzerBottom = channelTop + analyzerHeight - (!isLeds || noLedGap ? 0 : spaceV);
|
|
1504
|
+
channelCoords.push({
|
|
1505
|
+
channelTop,
|
|
1506
|
+
channelBottom,
|
|
1507
|
+
analyzerBottom
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// SAVE INTERNAL PROPERTIES
|
|
1512
|
+
|
|
1513
|
+
this._aux = {
|
|
1514
|
+
analyzerHeight,
|
|
1515
|
+
analyzerWidth,
|
|
1516
|
+
centerX,
|
|
1517
|
+
centerY,
|
|
1518
|
+
channelCoords,
|
|
1519
|
+
channelHeight,
|
|
1520
|
+
channelGap,
|
|
1521
|
+
initialX,
|
|
1522
|
+
innerRadius,
|
|
1523
|
+
outerRadius,
|
|
1524
|
+
scaleMin,
|
|
1525
|
+
unitWidth
|
|
1526
|
+
};
|
|
1527
|
+
this._flg = {
|
|
1528
|
+
isAlpha,
|
|
1529
|
+
isBands,
|
|
1530
|
+
isLeds,
|
|
1531
|
+
isLumi,
|
|
1532
|
+
isOctaves,
|
|
1533
|
+
isOutline,
|
|
1534
|
+
isRound,
|
|
1535
|
+
noLedGap
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
// generate the X-axis and radial scales
|
|
1539
|
+
this._createScales();
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Generate the X-axis and radial scales in auxiliary canvases
|
|
1544
|
+
*/
|
|
1545
|
+
_createScales() {
|
|
1546
|
+
if (!this._ready) return;
|
|
1547
|
+
const {
|
|
1548
|
+
analyzerWidth,
|
|
1549
|
+
initialX,
|
|
1550
|
+
innerRadius,
|
|
1551
|
+
scaleMin,
|
|
1552
|
+
unitWidth
|
|
1553
|
+
} = this._aux,
|
|
1554
|
+
{
|
|
1555
|
+
canvas,
|
|
1556
|
+
_frequencyScale,
|
|
1557
|
+
_mirror,
|
|
1558
|
+
_noteLabels,
|
|
1559
|
+
_radial,
|
|
1560
|
+
_scaleX,
|
|
1561
|
+
_scaleR
|
|
1562
|
+
} = this,
|
|
1563
|
+
canvasX = _scaleX.canvas,
|
|
1564
|
+
canvasR = _scaleR.canvas,
|
|
1565
|
+
freqLabels = [],
|
|
1566
|
+
isDualHorizontal = this._chLayout == CHANNEL_HORIZONTAL,
|
|
1567
|
+
isDualVertical = this._chLayout == CHANNEL_VERTICAL,
|
|
1568
|
+
minDimension = Math.min(canvas.width, canvas.height),
|
|
1569
|
+
scale = ['C',, 'D',, 'E', 'F',, 'G',, 'A',, 'B'],
|
|
1570
|
+
// for note labels (no sharp notes)
|
|
1571
|
+
scaleHeight = minDimension / 34 | 0,
|
|
1572
|
+
// circular scale height (radial mode)
|
|
1573
|
+
fontSizeX = canvasX.height >> 1,
|
|
1574
|
+
fontSizeR = scaleHeight >> 1,
|
|
1575
|
+
labelWidthX = fontSizeX * (_noteLabels ? .7 : 1.5),
|
|
1576
|
+
labelWidthR = fontSizeR * (_noteLabels ? 1 : 2),
|
|
1577
|
+
root12 = 2 ** (1 / 12);
|
|
1578
|
+
if (!_noteLabels && (this._ansiBands || _frequencyScale != SCALE_LOG)) {
|
|
1579
|
+
freqLabels.push(16, 31.5, 63, 125, 250, 500, 1e3, 2e3, 4e3);
|
|
1580
|
+
if (_frequencyScale == SCALE_LINEAR) freqLabels.push(6e3, 8e3, 10e3, 12e3, 14e3, 16e3, 18e3, 20e3, 22e3);else freqLabels.push(8e3, 16e3);
|
|
1581
|
+
} else {
|
|
1582
|
+
let freq = C_1;
|
|
1583
|
+
for (let octave = -1; octave < 11; octave++) {
|
|
1584
|
+
for (let note = 0; note < 12; note++) {
|
|
1585
|
+
if (freq >= this._minFreq && freq <= this._maxFreq) {
|
|
1586
|
+
const pitch = scale[note],
|
|
1587
|
+
isC = pitch == 'C';
|
|
1588
|
+
if (pitch && _noteLabels && !_mirror && !isDualHorizontal || isC) freqLabels.push(_noteLabels ? [freq, pitch + (isC ? octave : '')] : freq);
|
|
1589
|
+
}
|
|
1590
|
+
freq *= root12;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// in radial dual-vertical layout, the scale is positioned exactly between both channels, by making the canvas a bit larger than the inner diameter
|
|
1596
|
+
canvasR.width = canvasR.height = Math.max(minDimension * .15, (innerRadius << 1) + isDualVertical * scaleHeight);
|
|
1597
|
+
const centerR = canvasR.width >> 1,
|
|
1598
|
+
radialY = centerR - scaleHeight * .7; // vertical position of text labels in the circular scale
|
|
1599
|
+
|
|
1600
|
+
// helper function
|
|
1601
|
+
const radialLabel = (x, label) => {
|
|
1602
|
+
const angle = TAU * (x / canvas.width),
|
|
1603
|
+
adjAng = angle - HALF_PI,
|
|
1604
|
+
// rotate angles so 0 is at the top
|
|
1605
|
+
posX = radialY * Math.cos(adjAng),
|
|
1606
|
+
posY = radialY * Math.sin(adjAng);
|
|
1607
|
+
_scaleR.save();
|
|
1608
|
+
_scaleR.translate(centerR + posX, centerR + posY);
|
|
1609
|
+
_scaleR.rotate(angle);
|
|
1610
|
+
_scaleR.fillText(label, 0, 0);
|
|
1611
|
+
_scaleR.restore();
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
// clear scale canvas
|
|
1615
|
+
canvasX.width |= 0;
|
|
1616
|
+
_scaleX.fillStyle = _scaleR.strokeStyle = SCALEX_BACKGROUND_COLOR;
|
|
1617
|
+
_scaleX.fillRect(0, 0, canvasX.width, canvasX.height);
|
|
1618
|
+
_scaleR.arc(centerR, centerR, centerR - scaleHeight / 2, 0, TAU);
|
|
1619
|
+
_scaleR.lineWidth = scaleHeight;
|
|
1620
|
+
_scaleR.stroke();
|
|
1621
|
+
_scaleX.fillStyle = _scaleR.fillStyle = SCALEX_LABEL_COLOR;
|
|
1622
|
+
_scaleX.font = `${fontSizeX}px ${FONT_FAMILY}`;
|
|
1623
|
+
_scaleR.font = `${fontSizeR}px ${FONT_FAMILY}`;
|
|
1624
|
+
_scaleX.textAlign = _scaleR.textAlign = 'center';
|
|
1625
|
+
let prevX = -labelWidthX / 4,
|
|
1626
|
+
prevR = -labelWidthR;
|
|
1627
|
+
for (const item of freqLabels) {
|
|
1628
|
+
const [freq, label] = Array.isArray(item) ? item : [item, item < 1e3 ? item | 0 : `${(item / 100 | 0) / 10}k`],
|
|
1629
|
+
x = unitWidth * (this._freqScaling(freq) - scaleMin),
|
|
1630
|
+
y = canvasX.height * .75,
|
|
1631
|
+
isC = label[0] == 'C',
|
|
1632
|
+
maxW = fontSizeX * (_noteLabels && !_mirror && !isDualHorizontal ? isC ? 1.2 : .6 : 3);
|
|
1633
|
+
|
|
1634
|
+
// set label color - no highlight when mirror effect is active (only Cs displayed)
|
|
1635
|
+
_scaleX.fillStyle = _scaleR.fillStyle = isC && !_mirror && !isDualHorizontal ? SCALEX_HIGHLIGHT_COLOR : SCALEX_LABEL_COLOR;
|
|
1636
|
+
|
|
1637
|
+
// prioritizes which note labels are displayed, due to the restricted space on some ranges/scales
|
|
1638
|
+
if (_noteLabels) {
|
|
1639
|
+
const isLog = _frequencyScale == SCALE_LOG,
|
|
1640
|
+
isLinear = _frequencyScale == SCALE_LINEAR;
|
|
1641
|
+
let allowedLabels = ['C'];
|
|
1642
|
+
if (isLog || freq > 2e3 || !isLinear && freq > 250 || (!_radial || isDualVertical) && (!isLinear && freq > 125 || freq > 1e3)) allowedLabels.push('G');
|
|
1643
|
+
if (isLog || freq > 4e3 || !isLinear && freq > 500 || (!_radial || isDualVertical) && (!isLinear && freq > 250 || freq > 2e3)) allowedLabels.push('E');
|
|
1644
|
+
if (isLinear && freq > 4e3 || (!_radial || isDualVertical) && (isLog || freq > 2e3 || !isLinear && freq > 500)) allowedLabels.push('D', 'F', 'A', 'B');
|
|
1645
|
+
if (!allowedLabels.includes(label[0])) continue; // skip this label
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// linear scale
|
|
1649
|
+
if (x >= prevX + labelWidthX / 2 && x <= analyzerWidth) {
|
|
1650
|
+
_scaleX.fillText(label, isDualHorizontal && _mirror == -1 ? analyzerWidth - x : initialX + x, y, maxW);
|
|
1651
|
+
if (isDualHorizontal || _mirror && (x > labelWidthX || _mirror == 1)) _scaleX.fillText(label, isDualHorizontal && _mirror != 1 ? analyzerWidth + x : (initialX || canvas.width) - x, y, maxW);
|
|
1652
|
+
prevX = x + Math.min(maxW, _scaleX.measureText(label).width) / 2;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// radial scale
|
|
1656
|
+
if (x >= prevR + labelWidthR && x < analyzerWidth - labelWidthR) {
|
|
1657
|
+
// avoid overlapping the last label over the first one
|
|
1658
|
+
radialLabel(isDualHorizontal && _mirror == 1 ? analyzerWidth - x : x, label);
|
|
1659
|
+
if (isDualHorizontal || _mirror && (x > labelWidthR || _mirror == 1))
|
|
1660
|
+
// avoid overlapping of first labels on mirror mode
|
|
1661
|
+
radialLabel(isDualHorizontal && _mirror != -1 ? analyzerWidth + x : -x, label);
|
|
1662
|
+
prevR = x;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Redraw the canvas
|
|
1669
|
+
* this is called 60 times per second by requestAnimationFrame()
|
|
1670
|
+
*/
|
|
1671
|
+
_draw(timestamp) {
|
|
1672
|
+
// schedule next canvas update
|
|
1673
|
+
this._runId = requestAnimationFrame(timestamp => this._draw(timestamp));
|
|
1674
|
+
|
|
1675
|
+
// frame rate control
|
|
1676
|
+
const elapsed = timestamp - this._time,
|
|
1677
|
+
// time since last FPS computation
|
|
1678
|
+
frameTime = timestamp - this._last,
|
|
1679
|
+
// time since last rendered frame
|
|
1680
|
+
targetInterval = this._maxFPS ? 975 / this._maxFPS : 0; // small tolerance for best results
|
|
1681
|
+
|
|
1682
|
+
if (frameTime < targetInterval) return;
|
|
1683
|
+
this._last = timestamp - (targetInterval ? frameTime % targetInterval : 0); // thanks https://stackoverflow.com/a/19772220/2370385
|
|
1684
|
+
this._frames++;
|
|
1685
|
+
if (elapsed >= 1000) {
|
|
1686
|
+
// update FPS every second
|
|
1687
|
+
this._fps = this._frames / elapsed * 1000;
|
|
1688
|
+
this._frames = 0;
|
|
1689
|
+
this._time = timestamp;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// initialize local constants
|
|
1693
|
+
|
|
1694
|
+
const {
|
|
1695
|
+
isAlpha,
|
|
1696
|
+
isBands,
|
|
1697
|
+
isLeds,
|
|
1698
|
+
isLumi,
|
|
1699
|
+
isOctaves,
|
|
1700
|
+
isOutline,
|
|
1701
|
+
isRound,
|
|
1702
|
+
noLedGap
|
|
1703
|
+
} = this._flg,
|
|
1704
|
+
{
|
|
1705
|
+
analyzerHeight,
|
|
1706
|
+
centerX,
|
|
1707
|
+
centerY,
|
|
1708
|
+
channelCoords,
|
|
1709
|
+
channelHeight,
|
|
1710
|
+
channelGap,
|
|
1711
|
+
initialX,
|
|
1712
|
+
innerRadius,
|
|
1713
|
+
outerRadius
|
|
1714
|
+
} = this._aux,
|
|
1715
|
+
{
|
|
1716
|
+
_bars,
|
|
1717
|
+
canvas,
|
|
1718
|
+
_canvasGradients,
|
|
1719
|
+
_chLayout,
|
|
1720
|
+
_colorMode,
|
|
1721
|
+
_ctx,
|
|
1722
|
+
_energy,
|
|
1723
|
+
fillAlpha,
|
|
1724
|
+
_fps,
|
|
1725
|
+
_gravity,
|
|
1726
|
+
_linearAmplitude,
|
|
1727
|
+
_lineWidth,
|
|
1728
|
+
maxDecibels,
|
|
1729
|
+
minDecibels,
|
|
1730
|
+
_mirror,
|
|
1731
|
+
_mode,
|
|
1732
|
+
overlay,
|
|
1733
|
+
_radial,
|
|
1734
|
+
showBgColor,
|
|
1735
|
+
showPeaks,
|
|
1736
|
+
useCanvas,
|
|
1737
|
+
_weightingFilter
|
|
1738
|
+
} = this,
|
|
1739
|
+
canvasX = this._scaleX.canvas,
|
|
1740
|
+
canvasR = this._scaleR.canvas,
|
|
1741
|
+
holdFrames = _fps >> 1,
|
|
1742
|
+
// number of frames in half a second
|
|
1743
|
+
isDualCombined = _chLayout == CHANNEL_COMBINED,
|
|
1744
|
+
isDualHorizontal = _chLayout == CHANNEL_HORIZONTAL,
|
|
1745
|
+
isDualVertical = _chLayout == CHANNEL_VERTICAL,
|
|
1746
|
+
isSingle = _chLayout == CHANNEL_SINGLE,
|
|
1747
|
+
isTrueLeds = isLeds && this._trueLeds && _colorMode == COLOR_GRADIENT,
|
|
1748
|
+
analyzerWidth = _radial ? canvas.width : this._aux.analyzerWidth,
|
|
1749
|
+
finalX = initialX + analyzerWidth,
|
|
1750
|
+
showPeakLine = showPeaks && this._peakLine && _mode == MODE_GRAPH,
|
|
1751
|
+
maxBarHeight = _radial ? outerRadius - innerRadius : analyzerHeight,
|
|
1752
|
+
dbRange = maxDecibels - minDecibels,
|
|
1753
|
+
[ledCount, ledSpaceH, ledSpaceV, ledHeight] = this._leds || [];
|
|
1754
|
+
if (_energy.val > 0 && _fps > 0) this._spinAngle += this._spinSpeed * TAU / 60 / _fps; // spinSpeed * angle increment per frame for 1 RPM
|
|
1755
|
+
|
|
1756
|
+
/* HELPER FUNCTIONS */
|
|
1757
|
+
|
|
1758
|
+
// create Reflex effect
|
|
1759
|
+
const doReflex = channel => {
|
|
1760
|
+
if (this._reflexRatio > 0 && !isLumi && !_radial) {
|
|
1761
|
+
let posY, height;
|
|
1762
|
+
if (this.reflexFit || isDualVertical) {
|
|
1763
|
+
// always fit reflex in vertical stereo mode
|
|
1764
|
+
posY = isDualVertical && channel == 0 ? channelHeight + channelGap : 0;
|
|
1765
|
+
height = channelHeight - analyzerHeight;
|
|
1766
|
+
} else {
|
|
1767
|
+
posY = canvas.height - analyzerHeight * 2;
|
|
1768
|
+
height = analyzerHeight;
|
|
1769
|
+
}
|
|
1770
|
+
_ctx.save();
|
|
1771
|
+
|
|
1772
|
+
// set alpha and brightness for the reflection
|
|
1773
|
+
_ctx.globalAlpha = this.reflexAlpha;
|
|
1774
|
+
if (this.reflexBright != 1) _ctx.filter = `brightness(${this.reflexBright})`;
|
|
1775
|
+
|
|
1776
|
+
// create the reflection
|
|
1777
|
+
_ctx.setTransform(1, 0, 0, -1, 0, canvas.height);
|
|
1778
|
+
_ctx.drawImage(canvas, 0, channelCoords[channel].channelTop, canvas.width, analyzerHeight, 0, posY, canvas.width, height);
|
|
1779
|
+
_ctx.restore();
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1783
|
+
// draw scale on X-axis
|
|
1784
|
+
const drawScaleX = () => {
|
|
1785
|
+
if (this.showScaleX) {
|
|
1786
|
+
if (_radial) {
|
|
1787
|
+
_ctx.save();
|
|
1788
|
+
_ctx.translate(centerX, centerY);
|
|
1789
|
+
if (this._spinSpeed) _ctx.rotate(this._spinAngle + HALF_PI);
|
|
1790
|
+
_ctx.drawImage(canvasR, -canvasR.width >> 1, -canvasR.width >> 1);
|
|
1791
|
+
_ctx.restore();
|
|
1792
|
+
} else _ctx.drawImage(canvasX, 0, canvas.height - canvasX.height);
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
// returns the gain (in dB) for a given frequency, considering the currently selected weighting filter
|
|
1797
|
+
const weightingdB = freq => {
|
|
1798
|
+
const f2 = freq ** 2,
|
|
1799
|
+
SQ20_6 = 424.36,
|
|
1800
|
+
SQ107_7 = 11599.29,
|
|
1801
|
+
SQ158_5 = 25122.25,
|
|
1802
|
+
SQ737_9 = 544496.41,
|
|
1803
|
+
SQ12194 = 148693636,
|
|
1804
|
+
linearTodB = value => 20 * Math.log10(value);
|
|
1805
|
+
switch (_weightingFilter) {
|
|
1806
|
+
case FILTER_A:
|
|
1807
|
+
// A-weighting https://en.wikipedia.org/wiki/A-weighting
|
|
1808
|
+
const rA = SQ12194 * f2 ** 2 / ((f2 + SQ20_6) * Math.sqrt((f2 + SQ107_7) * (f2 + SQ737_9)) * (f2 + SQ12194));
|
|
1809
|
+
return 2 + linearTodB(rA);
|
|
1810
|
+
case FILTER_B:
|
|
1811
|
+
const rB = SQ12194 * f2 * freq / ((f2 + SQ20_6) * Math.sqrt(f2 + SQ158_5) * (f2 + SQ12194));
|
|
1812
|
+
return .17 + linearTodB(rB);
|
|
1813
|
+
case FILTER_C:
|
|
1814
|
+
const rC = SQ12194 * f2 / ((f2 + SQ20_6) * (f2 + SQ12194));
|
|
1815
|
+
return .06 + linearTodB(rC);
|
|
1816
|
+
case FILTER_D:
|
|
1817
|
+
const h = ((1037918.48 - f2) ** 2 + 1080768.16 * f2) / ((9837328 - f2) ** 2 + 11723776 * f2),
|
|
1818
|
+
rD = freq / 6.8966888496476e-5 * Math.sqrt(h / ((f2 + 79919.29) * (f2 + 1345600)));
|
|
1819
|
+
return linearTodB(rD);
|
|
1820
|
+
case FILTER_468:
|
|
1821
|
+
// ITU-R 468 https://en.wikipedia.org/wiki/ITU-R_468_noise_weighting
|
|
1822
|
+
const h1 = -4.737338981378384e-24 * freq ** 6 + 2.043828333606125e-15 * freq ** 4 - 1.363894795463638e-7 * f2 + 1,
|
|
1823
|
+
h2 = 1.306612257412824e-19 * freq ** 5 - 2.118150887518656e-11 * freq ** 3 + 5.559488023498642e-4 * freq,
|
|
1824
|
+
rI = 1.246332637532143e-4 * freq / Math.hypot(h1, h2);
|
|
1825
|
+
return 18.2 + linearTodB(rI);
|
|
1826
|
+
}
|
|
1827
|
+
return 0; // unknown filter
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
// draws (stroke) a bar from x,y1 to x,y2
|
|
1831
|
+
const strokeBar = (x, y1, y2) => {
|
|
1832
|
+
_ctx.beginPath();
|
|
1833
|
+
_ctx.moveTo(x, y1);
|
|
1834
|
+
_ctx.lineTo(x, y2);
|
|
1835
|
+
_ctx.stroke();
|
|
1836
|
+
};
|
|
1837
|
+
|
|
1838
|
+
// conditionally strokes current path on canvas
|
|
1839
|
+
const strokeIf = flag => {
|
|
1840
|
+
if (flag && _lineWidth) {
|
|
1841
|
+
const alpha = _ctx.globalAlpha;
|
|
1842
|
+
_ctx.globalAlpha = 1;
|
|
1843
|
+
_ctx.stroke();
|
|
1844
|
+
_ctx.globalAlpha = alpha;
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
// converts a value in [0;1] range to a height in pixels that fits into the current LED elements
|
|
1849
|
+
const ledPosY = value => Math.max(0, (value * ledCount | 0) * (ledHeight + ledSpaceV) - ledSpaceV);
|
|
1850
|
+
|
|
1851
|
+
// update energy information
|
|
1852
|
+
const updateEnergy = newVal => {
|
|
1853
|
+
_energy.val = newVal;
|
|
1854
|
+
if (_energy.peak > 0) {
|
|
1855
|
+
_energy.hold--;
|
|
1856
|
+
if (_energy.hold < 0) _energy.peak += _energy.hold / (holdFrames * holdFrames / _gravity);
|
|
1857
|
+
}
|
|
1858
|
+
if (newVal >= _energy.peak) {
|
|
1859
|
+
_energy.peak = newVal;
|
|
1860
|
+
_energy.hold = holdFrames;
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
|
|
1864
|
+
/* MAIN FUNCTION */
|
|
1865
|
+
|
|
1866
|
+
if (overlay) _ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1867
|
+
let currentEnergy = 0;
|
|
1868
|
+
const nBars = _bars.length,
|
|
1869
|
+
nChannels = isSingle ? 1 : 2;
|
|
1870
|
+
for (let channel = 0; channel < nChannels; channel++) {
|
|
1871
|
+
const {
|
|
1872
|
+
channelTop,
|
|
1873
|
+
channelBottom,
|
|
1874
|
+
analyzerBottom
|
|
1875
|
+
} = channelCoords[channel],
|
|
1876
|
+
channelGradient = this._gradients[this._selectedGrads[channel]],
|
|
1877
|
+
colorStops = channelGradient.colorStops,
|
|
1878
|
+
colorCount = colorStops.length,
|
|
1879
|
+
bgColor = !showBgColor || isLeds && !overlay ? '#000' : channelGradient.bgColor,
|
|
1880
|
+
radialDirection = isDualVertical && _radial && channel ? -1 : 1,
|
|
1881
|
+
// 1 = outwards, -1 = inwards
|
|
1882
|
+
invertedChannel = !channel && _mirror == -1 || channel && _mirror == 1,
|
|
1883
|
+
radialOffsetX = !isDualHorizontal || channel && _mirror != 1 ? 0 : analyzerWidth >> (channel || !invertedChannel),
|
|
1884
|
+
angularDirection = isDualHorizontal && invertedChannel ? -1 : 1; // 1 = clockwise, -1 = counterclockwise
|
|
1885
|
+
/*
|
|
1886
|
+
Expanded logic for radialOffsetX and angularDirection:
|
|
1887
|
+
|
|
1888
|
+
let radialOffsetX = 0,
|
|
1889
|
+
angularDirection = 1;
|
|
1890
|
+
|
|
1891
|
+
if ( isDualHorizontal ) {
|
|
1892
|
+
if ( channel == 0 ) { // LEFT channel
|
|
1893
|
+
if ( _mirror == -1 ) {
|
|
1894
|
+
radialOffsetX = analyzerWidth;
|
|
1895
|
+
angularDirection = -1;
|
|
1896
|
+
}
|
|
1897
|
+
else
|
|
1898
|
+
radialOffsetX = analyzerWidth >> 1;
|
|
1899
|
+
}
|
|
1900
|
+
else { // RIGHT channel
|
|
1901
|
+
if ( _mirror == 1 ) {
|
|
1902
|
+
radialOffsetX = analyzerWidth >> 1;
|
|
1903
|
+
angularDirection = -1;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
*/
|
|
1908
|
+
// draw scale on Y-axis (uses: channel, channelTop)
|
|
1909
|
+
const drawScaleY = () => {
|
|
1910
|
+
const scaleWidth = canvasX.height,
|
|
1911
|
+
fontSize = scaleWidth >> 1,
|
|
1912
|
+
max = _linearAmplitude ? 100 : maxDecibels,
|
|
1913
|
+
min = _linearAmplitude ? 0 : minDecibels,
|
|
1914
|
+
incr = _linearAmplitude ? 20 : 5,
|
|
1915
|
+
interval = analyzerHeight / (max - min),
|
|
1916
|
+
atStart = _mirror != -1 && (!isDualHorizontal || channel == 0 || _mirror == 1),
|
|
1917
|
+
atEnd = _mirror != 1 && (!isDualHorizontal || channel != _mirror);
|
|
1918
|
+
_ctx.save();
|
|
1919
|
+
_ctx.fillStyle = SCALEY_LABEL_COLOR;
|
|
1920
|
+
_ctx.font = `${fontSize}px ${FONT_FAMILY}`;
|
|
1921
|
+
_ctx.textAlign = 'right';
|
|
1922
|
+
_ctx.lineWidth = 1;
|
|
1923
|
+
for (let val = max; val > min; val -= incr) {
|
|
1924
|
+
const posY = channelTop + (max - val) * interval,
|
|
1925
|
+
even = val % 2 == 0 | 0;
|
|
1926
|
+
if (even) {
|
|
1927
|
+
const labelY = posY + fontSize * (posY == channelTop ? .8 : .35);
|
|
1928
|
+
if (atStart) _ctx.fillText(val, scaleWidth * .85, labelY);
|
|
1929
|
+
if (atEnd) _ctx.fillText(val, (isDualHorizontal ? analyzerWidth : canvas.width) - scaleWidth * .1, labelY);
|
|
1930
|
+
_ctx.strokeStyle = SCALEY_LABEL_COLOR;
|
|
1931
|
+
_ctx.setLineDash([2, 4]);
|
|
1932
|
+
_ctx.lineDashOffset = 0;
|
|
1933
|
+
} else {
|
|
1934
|
+
_ctx.strokeStyle = SCALEY_MIDLINE_COLOR;
|
|
1935
|
+
_ctx.setLineDash([2, 8]);
|
|
1936
|
+
_ctx.lineDashOffset = 1;
|
|
1937
|
+
}
|
|
1938
|
+
_ctx.beginPath();
|
|
1939
|
+
_ctx.moveTo(initialX + scaleWidth * even * atStart, ~~posY + .5); // for sharp 1px line (https://stackoverflow.com/a/13879402/2370385)
|
|
1940
|
+
_ctx.lineTo(finalX - scaleWidth * even * atEnd, ~~posY + .5);
|
|
1941
|
+
_ctx.stroke();
|
|
1942
|
+
}
|
|
1943
|
+
_ctx.restore();
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
// FFT bin data interpolation (uses fftData)
|
|
1947
|
+
const interpolate = (bin, ratio) => {
|
|
1948
|
+
const value = fftData[bin] + (bin < fftData.length - 1 ? (fftData[bin + 1] - fftData[bin]) * ratio : 0);
|
|
1949
|
+
return isNaN(value) ? -Infinity : value;
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
// converts a given X-coordinate to its corresponding angle in radial mode (uses angularDirection)
|
|
1953
|
+
const getAngle = (x, dir = angularDirection) => dir * TAU * ((x + radialOffsetX) / canvas.width) + this._spinAngle;
|
|
1954
|
+
|
|
1955
|
+
// converts planar X,Y coordinates to radial coordinates (uses: getAngle(), radialDirection)
|
|
1956
|
+
const radialXY = (x, y, dir) => {
|
|
1957
|
+
const height = innerRadius + y * radialDirection,
|
|
1958
|
+
angle = getAngle(x, dir);
|
|
1959
|
+
return [centerX + height * Math.cos(angle), centerY + height * Math.sin(angle)];
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
// draws a polygon of width `w` and height `h` at (x,y) in radial mode (uses: angularDirection, radialDirection)
|
|
1963
|
+
const radialPoly = (x, y, w, h, stroke) => {
|
|
1964
|
+
_ctx.beginPath();
|
|
1965
|
+
for (const dir of _mirror && !isDualHorizontal ? [1, -1] : [angularDirection]) {
|
|
1966
|
+
const [startAngle, endAngle] = isRound ? [getAngle(x, dir), getAngle(x + w, dir)] : [];
|
|
1967
|
+
_ctx.moveTo(...radialXY(x, y, dir));
|
|
1968
|
+
_ctx.lineTo(...radialXY(x, y + h, dir));
|
|
1969
|
+
if (isRound) _ctx.arc(centerX, centerY, innerRadius + (y + h) * radialDirection, startAngle, endAngle, dir != 1);else _ctx.lineTo(...radialXY(x + w, y + h, dir));
|
|
1970
|
+
_ctx.lineTo(...radialXY(x + w, y, dir));
|
|
1971
|
+
if (isRound && !stroke)
|
|
1972
|
+
// close the bottom line only when not in outline mode
|
|
1973
|
+
_ctx.arc(centerX, centerY, innerRadius + y * radialDirection, endAngle, startAngle, dir == 1);
|
|
1974
|
+
}
|
|
1975
|
+
strokeIf(stroke);
|
|
1976
|
+
_ctx.fill();
|
|
1977
|
+
};
|
|
1978
|
+
|
|
1979
|
+
// set fillStyle and strokeStyle according to current colorMode (uses: channel, colorStops, colorCount)
|
|
1980
|
+
const setBarColor = (value = 0, barIndex = 0) => {
|
|
1981
|
+
let color;
|
|
1982
|
+
// for graph mode, always use the channel gradient (ignore colorMode)
|
|
1983
|
+
if (_colorMode == COLOR_GRADIENT && !isTrueLeds || _mode == MODE_GRAPH) color = _canvasGradients[channel];else {
|
|
1984
|
+
const selectedIndex = _colorMode == COLOR_BAR_INDEX ? barIndex % colorCount : colorStops.findLastIndex(item => isLeds ? ledPosY(value) <= ledPosY(item.level) : value <= item.level);
|
|
1985
|
+
color = colorStops[selectedIndex].color;
|
|
1986
|
+
}
|
|
1987
|
+
_ctx.fillStyle = _ctx.strokeStyle = color;
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
// CHANNEL START
|
|
1991
|
+
|
|
1992
|
+
if (useCanvas) {
|
|
1993
|
+
// set transform (horizontal flip and translation) for dual-horizontal layout
|
|
1994
|
+
if (isDualHorizontal && !_radial) {
|
|
1995
|
+
const translateX = analyzerWidth * (channel + invertedChannel),
|
|
1996
|
+
flipX = invertedChannel ? -1 : 1;
|
|
1997
|
+
_ctx.setTransform(flipX, 0, 0, 1, translateX, 0);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// fill the analyzer background if needed (not overlay or overlay + showBgColor)
|
|
2001
|
+
if (!overlay || showBgColor) {
|
|
2002
|
+
if (overlay) _ctx.globalAlpha = this.bgAlpha;
|
|
2003
|
+
_ctx.fillStyle = bgColor;
|
|
2004
|
+
|
|
2005
|
+
// exclude the reflection area when overlay is true and reflexAlpha == 1 (avoids alpha over alpha difference, in case bgAlpha < 1)
|
|
2006
|
+
if (channel == 0 || !_radial && !isDualCombined) _ctx.fillRect(initialX, channelTop - channelGap, analyzerWidth, (overlay && this.reflexAlpha == 1 ? analyzerHeight : channelHeight) + channelGap);
|
|
2007
|
+
_ctx.globalAlpha = 1;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// draw dB scale (Y-axis) - avoid drawing it twice on 'dual-combined' channel layout
|
|
2011
|
+
if (this.showScaleY && !isLumi && !_radial && (channel == 0 || !isDualCombined)) drawScaleY();
|
|
2012
|
+
|
|
2013
|
+
// set line width and dash for LEDs effect
|
|
2014
|
+
if (isLeds) {
|
|
2015
|
+
_ctx.setLineDash([ledHeight, ledSpaceV]);
|
|
2016
|
+
_ctx.lineWidth = _bars[0].width;
|
|
2017
|
+
} else
|
|
2018
|
+
// for outline effect ensure linewidth is not greater than half the bar width
|
|
2019
|
+
_ctx.lineWidth = isOutline ? Math.min(_lineWidth, _bars[0].width / 2) : _lineWidth;
|
|
2020
|
+
|
|
2021
|
+
// set clipping region
|
|
2022
|
+
_ctx.save();
|
|
2023
|
+
if (!_radial) {
|
|
2024
|
+
const region = new Path2D();
|
|
2025
|
+
region.rect(0, channelTop, canvas.width, analyzerHeight);
|
|
2026
|
+
_ctx.clip(region);
|
|
2027
|
+
}
|
|
2028
|
+
} // if ( useCanvas )
|
|
2029
|
+
|
|
2030
|
+
// get a new array of data from the FFT
|
|
2031
|
+
let fftData = this._fftData[channel];
|
|
2032
|
+
this._analyzer[channel].getFloatFrequencyData(fftData);
|
|
2033
|
+
|
|
2034
|
+
// apply weighting
|
|
2035
|
+
if (_weightingFilter) fftData = fftData.map((val, idx) => val + weightingdB(this._binToFreq(idx)));
|
|
2036
|
+
|
|
2037
|
+
// start drawing path (for graph mode)
|
|
2038
|
+
_ctx.beginPath();
|
|
2039
|
+
|
|
2040
|
+
// store line graph points to create mirror effect in radial mode
|
|
2041
|
+
let points = [];
|
|
2042
|
+
|
|
2043
|
+
// draw bars / lines
|
|
2044
|
+
|
|
2045
|
+
for (let barIndex = 0; barIndex < nBars; barIndex++) {
|
|
2046
|
+
const bar = _bars[barIndex],
|
|
2047
|
+
{
|
|
2048
|
+
posX,
|
|
2049
|
+
barCenter,
|
|
2050
|
+
width,
|
|
2051
|
+
freq,
|
|
2052
|
+
binLo,
|
|
2053
|
+
binHi,
|
|
2054
|
+
ratioLo,
|
|
2055
|
+
ratioHi
|
|
2056
|
+
} = bar;
|
|
2057
|
+
let barValue = Math.max(interpolate(binLo, ratioLo), interpolate(binHi, ratioHi));
|
|
2058
|
+
|
|
2059
|
+
// check additional bins (if any) for this bar and keep the highest value
|
|
2060
|
+
for (let j = binLo + 1; j < binHi; j++) {
|
|
2061
|
+
if (fftData[j] > barValue) barValue = fftData[j];
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// normalize bar amplitude in [0;1] range
|
|
2065
|
+
barValue = this._normalizedB(barValue);
|
|
2066
|
+
bar.value[channel] = barValue;
|
|
2067
|
+
currentEnergy += barValue;
|
|
2068
|
+
|
|
2069
|
+
// update bar peak
|
|
2070
|
+
if (bar.peak[channel] > 0) {
|
|
2071
|
+
bar.hold[channel]--;
|
|
2072
|
+
// if hold is negative, it becomes the "acceleration" for peak drop
|
|
2073
|
+
if (bar.hold[channel] < 0) bar.peak[channel] += bar.hold[channel] / (holdFrames * holdFrames / _gravity);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// check if it's a new peak for this bar
|
|
2077
|
+
if (barValue >= bar.peak[channel]) {
|
|
2078
|
+
bar.peak[channel] = barValue;
|
|
2079
|
+
bar.hold[channel] = holdFrames;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// if not using the canvas, move earlier to the next bar
|
|
2083
|
+
if (!useCanvas) continue;
|
|
2084
|
+
|
|
2085
|
+
// set opacity for bar effects
|
|
2086
|
+
if (isLumi || isAlpha) _ctx.globalAlpha = barValue;else if (isOutline) _ctx.globalAlpha = fillAlpha;
|
|
2087
|
+
|
|
2088
|
+
// set fillStyle and strokeStyle for the current bar
|
|
2089
|
+
setBarColor(barValue, barIndex);
|
|
2090
|
+
|
|
2091
|
+
// compute actual bar height on screen
|
|
2092
|
+
const barHeight = isLumi ? maxBarHeight : isLeds ? ledPosY(barValue) : barValue * maxBarHeight | 0;
|
|
2093
|
+
|
|
2094
|
+
// Draw current bar or line segment
|
|
2095
|
+
|
|
2096
|
+
if (_mode == MODE_GRAPH) {
|
|
2097
|
+
// compute the average between the initial bar (barIndex==0) and the next one
|
|
2098
|
+
// used to smooth the curve when the initial posX is off the screen, in mirror and radial modes
|
|
2099
|
+
const nextBarAvg = barIndex ? 0 : (this._normalizedB(fftData[_bars[1].binLo]) * maxBarHeight + barHeight) / 2;
|
|
2100
|
+
if (_radial) {
|
|
2101
|
+
if (barIndex == 0) {
|
|
2102
|
+
if (isDualHorizontal) _ctx.moveTo(...radialXY(0, 0));
|
|
2103
|
+
_ctx.lineTo(...radialXY(0, posX < 0 ? nextBarAvg : barHeight));
|
|
2104
|
+
}
|
|
2105
|
+
// draw line to the current point, avoiding overlapping wrap-around frequencies
|
|
2106
|
+
if (posX >= 0) {
|
|
2107
|
+
const point = [posX, barHeight];
|
|
2108
|
+
_ctx.lineTo(...radialXY(...point));
|
|
2109
|
+
points.push(point);
|
|
2110
|
+
}
|
|
2111
|
+
} else {
|
|
2112
|
+
// Linear
|
|
2113
|
+
if (barIndex == 0) {
|
|
2114
|
+
// start the line off-screen using the previous FFT bin value as the initial amplitude
|
|
2115
|
+
if (_mirror == -1 && !isDualHorizontal) _ctx.moveTo(initialX, analyzerBottom - (posX < initialX ? nextBarAvg : barHeight));else {
|
|
2116
|
+
const prevFFTData = binLo ? this._normalizedB(fftData[binLo - 1]) * maxBarHeight : barHeight; // use previous FFT bin value, when available
|
|
2117
|
+
_ctx.moveTo(initialX - _lineWidth, analyzerBottom - prevFFTData);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
// draw line to the current point
|
|
2121
|
+
// avoid X values lower than the origin when mirroring left, otherwise draw them for best graph accuracy
|
|
2122
|
+
if (isDualHorizontal || _mirror != -1 || posX >= initialX) _ctx.lineTo(posX, analyzerBottom - barHeight);
|
|
2123
|
+
}
|
|
2124
|
+
} else {
|
|
2125
|
+
if (isLeds) {
|
|
2126
|
+
// draw "unlit" leds - avoid drawing it twice on 'dual-combined' channel layout
|
|
2127
|
+
if (showBgColor && !overlay && (channel == 0 || !isDualCombined)) {
|
|
2128
|
+
const alpha = _ctx.globalAlpha;
|
|
2129
|
+
_ctx.strokeStyle = LEDS_UNLIT_COLOR;
|
|
2130
|
+
_ctx.globalAlpha = 1;
|
|
2131
|
+
strokeBar(barCenter, channelTop, analyzerBottom);
|
|
2132
|
+
// restore properties
|
|
2133
|
+
_ctx.strokeStyle = _ctx.fillStyle;
|
|
2134
|
+
_ctx.globalAlpha = alpha;
|
|
2135
|
+
}
|
|
2136
|
+
if (isTrueLeds) {
|
|
2137
|
+
// ledPosY() is used below to fit one entire led height into the selected range
|
|
2138
|
+
const colorIndex = isLumi ? 0 : colorStops.findLastIndex(item => ledPosY(barValue) <= ledPosY(item.level));
|
|
2139
|
+
let last = analyzerBottom;
|
|
2140
|
+
for (let i = colorCount - 1; i >= colorIndex; i--) {
|
|
2141
|
+
_ctx.strokeStyle = colorStops[i].color;
|
|
2142
|
+
let y = analyzerBottom - (i == colorIndex ? barHeight : ledPosY(colorStops[i].level));
|
|
2143
|
+
strokeBar(barCenter, last, y);
|
|
2144
|
+
last = y - ledSpaceV;
|
|
2145
|
+
}
|
|
2146
|
+
} else strokeBar(barCenter, analyzerBottom, analyzerBottom - barHeight);
|
|
2147
|
+
} else if (posX >= initialX) {
|
|
2148
|
+
if (_radial) radialPoly(posX, 0, width, barHeight, isOutline);else if (isRound) {
|
|
2149
|
+
const halfWidth = width / 2,
|
|
2150
|
+
y = analyzerBottom + halfWidth; // round caps have an additional height of half bar width
|
|
2151
|
+
|
|
2152
|
+
_ctx.beginPath();
|
|
2153
|
+
_ctx.moveTo(posX, y);
|
|
2154
|
+
_ctx.lineTo(posX, y - barHeight);
|
|
2155
|
+
_ctx.arc(barCenter, y - barHeight, halfWidth, PI, TAU);
|
|
2156
|
+
_ctx.lineTo(posX + width, y);
|
|
2157
|
+
strokeIf(isOutline);
|
|
2158
|
+
_ctx.fill();
|
|
2159
|
+
} else {
|
|
2160
|
+
const offset = isOutline ? _ctx.lineWidth : 0;
|
|
2161
|
+
_ctx.beginPath();
|
|
2162
|
+
_ctx.rect(posX, analyzerBottom + offset, width, -barHeight - offset);
|
|
2163
|
+
strokeIf(isOutline);
|
|
2164
|
+
_ctx.fill();
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// Draw peak
|
|
2170
|
+
const peak = bar.peak[channel];
|
|
2171
|
+
if (peak > 0 && showPeaks && !showPeakLine && !isLumi && posX >= initialX && posX < finalX) {
|
|
2172
|
+
// set opacity
|
|
2173
|
+
if (isOutline && _lineWidth > 0) _ctx.globalAlpha = 1;else if (isAlpha) _ctx.globalAlpha = peak;
|
|
2174
|
+
|
|
2175
|
+
// select the peak color for 'bar-level' colorMode or 'trueLeds'
|
|
2176
|
+
if (_colorMode == COLOR_BAR_LEVEL || isTrueLeds) setBarColor(peak);
|
|
2177
|
+
|
|
2178
|
+
// render peak according to current mode / effect
|
|
2179
|
+
if (isLeds) {
|
|
2180
|
+
const ledPeak = ledPosY(peak);
|
|
2181
|
+
if (ledPeak >= ledSpaceV)
|
|
2182
|
+
// avoid peak below first led
|
|
2183
|
+
_ctx.fillRect(posX, analyzerBottom - ledPeak, width, ledHeight);
|
|
2184
|
+
} else if (!_radial) _ctx.fillRect(posX, analyzerBottom - peak * maxBarHeight, width, 2);else if (_mode != MODE_GRAPH) {
|
|
2185
|
+
// radial (peaks for graph mode are done by the peakLine code)
|
|
2186
|
+
const y = peak * maxBarHeight;
|
|
2187
|
+
radialPoly(posX, y, width, !this._radialInvert || isDualVertical || y + innerRadius >= 2 ? -2 : 2);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
} // for ( let barIndex = 0; barIndex < nBars; barIndex++ )
|
|
2191
|
+
|
|
2192
|
+
// if not using the canvas, move earlier to the next channel
|
|
2193
|
+
if (!useCanvas) continue;
|
|
2194
|
+
|
|
2195
|
+
// restore global alpha
|
|
2196
|
+
_ctx.globalAlpha = 1;
|
|
2197
|
+
|
|
2198
|
+
// Fill/stroke drawing path for graph mode
|
|
2199
|
+
if (_mode == MODE_GRAPH) {
|
|
2200
|
+
setBarColor(); // select channel gradient
|
|
2201
|
+
|
|
2202
|
+
if (_radial && !isDualHorizontal) {
|
|
2203
|
+
if (_mirror) {
|
|
2204
|
+
let p;
|
|
2205
|
+
while (p = points.pop()) _ctx.lineTo(...radialXY(...p, -1));
|
|
2206
|
+
}
|
|
2207
|
+
_ctx.closePath();
|
|
2208
|
+
}
|
|
2209
|
+
if (_lineWidth > 0) _ctx.stroke();
|
|
2210
|
+
if (fillAlpha > 0) {
|
|
2211
|
+
if (_radial) {
|
|
2212
|
+
// exclude the center circle from the fill area
|
|
2213
|
+
const start = isDualHorizontal ? getAngle(analyzerWidth >> 1) : 0,
|
|
2214
|
+
end = isDualHorizontal ? getAngle(analyzerWidth) : TAU;
|
|
2215
|
+
_ctx.moveTo(...radialXY(isDualHorizontal ? analyzerWidth >> 1 : 0, 0));
|
|
2216
|
+
_ctx.arc(centerX, centerY, innerRadius, start, end, isDualHorizontal ? !invertedChannel : true);
|
|
2217
|
+
} else {
|
|
2218
|
+
// close the fill area
|
|
2219
|
+
_ctx.lineTo(finalX, analyzerBottom);
|
|
2220
|
+
_ctx.lineTo(initialX, analyzerBottom);
|
|
2221
|
+
}
|
|
2222
|
+
_ctx.globalAlpha = fillAlpha;
|
|
2223
|
+
_ctx.fill();
|
|
2224
|
+
_ctx.globalAlpha = 1;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// draw peak line (and standard peaks on radial)
|
|
2228
|
+
if (showPeakLine || _radial && showPeaks) {
|
|
2229
|
+
points = []; // for mirror line on radial
|
|
2230
|
+
_ctx.beginPath();
|
|
2231
|
+
_bars.forEach((b, i) => {
|
|
2232
|
+
let x = b.posX,
|
|
2233
|
+
h = b.peak[channel],
|
|
2234
|
+
m = i ? 'lineTo' : 'moveTo';
|
|
2235
|
+
if (_radial && x < 0) {
|
|
2236
|
+
const nextBar = _bars[i + 1];
|
|
2237
|
+
h = findY(x, h, nextBar.posX, nextBar.peak[channel], 0);
|
|
2238
|
+
x = 0;
|
|
2239
|
+
}
|
|
2240
|
+
h *= maxBarHeight;
|
|
2241
|
+
if (showPeakLine) {
|
|
2242
|
+
_ctx[m](...(_radial ? radialXY(x, h) : [x, analyzerBottom - h]));
|
|
2243
|
+
if (_radial && _mirror && !isDualHorizontal) points.push([x, h]);
|
|
2244
|
+
} else if (h > 0) radialPoly(x, h, 1, -2); // standard peaks (also does mirror)
|
|
2245
|
+
});
|
|
2246
|
+
if (showPeakLine) {
|
|
2247
|
+
let p;
|
|
2248
|
+
while (p = points.pop()) _ctx.lineTo(...radialXY(...p, -1)); // mirror line points
|
|
2249
|
+
_ctx.lineWidth = 1;
|
|
2250
|
+
_ctx.stroke(); // stroke peak line
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
_ctx.restore(); // restore clip region
|
|
2255
|
+
|
|
2256
|
+
if (isDualHorizontal && !_radial) _ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2257
|
+
|
|
2258
|
+
// create Reflex effect - for dual-combined and dual-horizontal do it only once, after channel 1
|
|
2259
|
+
if (!isDualHorizontal && !isDualCombined || channel) doReflex(channel);
|
|
2260
|
+
} // for ( let channel = 0; channel < nChannels; channel++ ) {
|
|
2261
|
+
|
|
2262
|
+
updateEnergy(currentEnergy / (nBars << nChannels - 1));
|
|
2263
|
+
if (useCanvas) {
|
|
2264
|
+
// Mirror effect
|
|
2265
|
+
if (_mirror && !_radial && !isDualHorizontal) {
|
|
2266
|
+
_ctx.setTransform(-1, 0, 0, 1, canvas.width - initialX, 0);
|
|
2267
|
+
_ctx.drawImage(canvas, initialX, 0, centerX, canvas.height, 0, 0, centerX, canvas.height);
|
|
2268
|
+
_ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// restore solid lines
|
|
2272
|
+
_ctx.setLineDash([]);
|
|
2273
|
+
|
|
2274
|
+
// draw frequency scale (X-axis)
|
|
2275
|
+
drawScaleX();
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// display current frame rate
|
|
2279
|
+
if (this.showFPS) {
|
|
2280
|
+
const size = canvasX.height;
|
|
2281
|
+
_ctx.font = `bold ${size}px ${FONT_FAMILY}`;
|
|
2282
|
+
_ctx.fillStyle = FPS_COLOR;
|
|
2283
|
+
_ctx.textAlign = 'right';
|
|
2284
|
+
_ctx.fillText(Math.round(_fps), canvas.width - size, size * 2);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// call callback function, if defined
|
|
2288
|
+
if (this.onCanvasDraw) {
|
|
2289
|
+
_ctx.save();
|
|
2290
|
+
_ctx.fillStyle = _ctx.strokeStyle = _canvasGradients[0];
|
|
2291
|
+
this.onCanvasDraw(this, {
|
|
2292
|
+
timestamp,
|
|
2293
|
+
canvasGradients: _canvasGradients
|
|
2294
|
+
});
|
|
2295
|
+
_ctx.restore();
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
/**
|
|
2300
|
+
* Return scaled frequency according to the selected scale
|
|
2301
|
+
*/
|
|
2302
|
+
_freqScaling(freq) {
|
|
2303
|
+
switch (this._frequencyScale) {
|
|
2304
|
+
case SCALE_LOG:
|
|
2305
|
+
return Math.log2(freq);
|
|
2306
|
+
case SCALE_BARK:
|
|
2307
|
+
return 26.81 * freq / (1960 + freq) - .53;
|
|
2308
|
+
case SCALE_MEL:
|
|
2309
|
+
return Math.log2(1 + freq / 700);
|
|
2310
|
+
case SCALE_LINEAR:
|
|
2311
|
+
return freq;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/**
|
|
2316
|
+
* Return the FFT data bin (array index) which represents a given frequency
|
|
2317
|
+
*/
|
|
2318
|
+
_freqToBin(freq, method = 'round') {
|
|
2319
|
+
const max = this._analyzer[0].frequencyBinCount - 1,
|
|
2320
|
+
bin = Math[method](freq * this.fftSize / this.audioCtx.sampleRate);
|
|
2321
|
+
return bin < max ? bin : max;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
/**
|
|
2325
|
+
* Generate currently selected gradient
|
|
2326
|
+
*/
|
|
2327
|
+
_makeGrad() {
|
|
2328
|
+
if (!this._ready) return;
|
|
2329
|
+
const {
|
|
2330
|
+
canvas,
|
|
2331
|
+
_ctx,
|
|
2332
|
+
_radial,
|
|
2333
|
+
_reflexRatio
|
|
2334
|
+
} = this,
|
|
2335
|
+
{
|
|
2336
|
+
analyzerWidth,
|
|
2337
|
+
centerX,
|
|
2338
|
+
centerY,
|
|
2339
|
+
initialX,
|
|
2340
|
+
innerRadius,
|
|
2341
|
+
outerRadius
|
|
2342
|
+
} = this._aux,
|
|
2343
|
+
{
|
|
2344
|
+
isLumi
|
|
2345
|
+
} = this._flg,
|
|
2346
|
+
isDualVertical = this._chLayout == CHANNEL_VERTICAL,
|
|
2347
|
+
analyzerRatio = 1 - _reflexRatio,
|
|
2348
|
+
gradientHeight = isLumi ? canvas.height : canvas.height * (1 - _reflexRatio * !isDualVertical) | 0;
|
|
2349
|
+
// for vertical stereo we keep the full canvas height and handle the reflex areas while generating the color stops
|
|
2350
|
+
|
|
2351
|
+
for (const channel of [0, 1]) {
|
|
2352
|
+
const currGradient = this._gradients[this._selectedGrads[channel]],
|
|
2353
|
+
colorStops = currGradient.colorStops,
|
|
2354
|
+
isHorizontal = currGradient.dir == 'h';
|
|
2355
|
+
let grad;
|
|
2356
|
+
if (_radial) grad = _ctx.createRadialGradient(centerX, centerY, outerRadius, centerX, centerY, innerRadius - (outerRadius - innerRadius) * isDualVertical);else grad = _ctx.createLinearGradient(...(isHorizontal ? [initialX, 0, initialX + analyzerWidth, 0] : [0, 0, 0, gradientHeight]));
|
|
2357
|
+
if (colorStops) {
|
|
2358
|
+
const dual = isDualVertical && !this._splitGradient && (!isHorizontal || _radial);
|
|
2359
|
+
for (let channelArea = 0; channelArea < 1 + dual; channelArea++) {
|
|
2360
|
+
const maxIndex = colorStops.length - 1;
|
|
2361
|
+
colorStops.forEach((colorStop, index) => {
|
|
2362
|
+
let offset = colorStop.pos;
|
|
2363
|
+
|
|
2364
|
+
// in dual mode (not split), use half the original offset for each channel
|
|
2365
|
+
if (dual) offset /= 2;
|
|
2366
|
+
|
|
2367
|
+
// constrain the offset within the useful analyzer areas (avoid reflex areas)
|
|
2368
|
+
if (isDualVertical && !isLumi && !_radial && !isHorizontal) {
|
|
2369
|
+
offset *= analyzerRatio;
|
|
2370
|
+
// skip the first reflex area in split mode
|
|
2371
|
+
if (!dual && offset > .5 * analyzerRatio) offset += .5 * _reflexRatio;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// only for dual-vertical non-split gradient (creates full gradient on both halves of the canvas)
|
|
2375
|
+
if (channelArea == 1) {
|
|
2376
|
+
// add colors in reverse order if radial or lumi are active
|
|
2377
|
+
if (_radial || isLumi) {
|
|
2378
|
+
const revIndex = maxIndex - index;
|
|
2379
|
+
colorStop = colorStops[revIndex];
|
|
2380
|
+
offset = 1 - colorStop.pos / 2;
|
|
2381
|
+
} else {
|
|
2382
|
+
// if the first offset is not 0, create an additional color stop to prevent bleeding from the first channel
|
|
2383
|
+
if (index == 0 && offset > 0) grad.addColorStop(.5, colorStop.color);
|
|
2384
|
+
// bump the offset to the second half of the gradient
|
|
2385
|
+
offset += .5;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// add gradient color stop
|
|
2390
|
+
grad.addColorStop(offset, colorStop.color);
|
|
2391
|
+
|
|
2392
|
+
// create additional color stop at the end of first channel to prevent bleeding
|
|
2393
|
+
if (isDualVertical && index == maxIndex && offset < .5) grad.addColorStop(.5, colorStop.color);
|
|
2394
|
+
});
|
|
2395
|
+
} // for ( let channelArea = 0; channelArea < 1 + dual; channelArea++ )
|
|
2396
|
+
}
|
|
2397
|
+
this._canvasGradients[channel] = grad;
|
|
2398
|
+
} // for ( const channel of [0,1] )
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* Normalize a dB value in the [0;1] range
|
|
2403
|
+
*/
|
|
2404
|
+
_normalizedB(value) {
|
|
2405
|
+
const isLinear = this._linearAmplitude,
|
|
2406
|
+
boost = isLinear ? 1 / this._linearBoost : 1,
|
|
2407
|
+
clamp = (val, min, max) => val <= min ? min : val >= max ? max : val,
|
|
2408
|
+
dBToLinear = val => 10 ** (val / 20);
|
|
2409
|
+
let maxValue = this.maxDecibels,
|
|
2410
|
+
minValue = this.minDecibels;
|
|
2411
|
+
if (isLinear) {
|
|
2412
|
+
maxValue = dBToLinear(maxValue);
|
|
2413
|
+
minValue = dBToLinear(minValue);
|
|
2414
|
+
value = dBToLinear(value) ** boost;
|
|
2415
|
+
}
|
|
2416
|
+
return clamp((value - minValue) / (maxValue - minValue) ** boost, 0, 1);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
/**
|
|
2420
|
+
* Internal function to change canvas dimensions on demand
|
|
2421
|
+
*/
|
|
2422
|
+
_setCanvas(reason) {
|
|
2423
|
+
if (!this._ready) return;
|
|
2424
|
+
const {
|
|
2425
|
+
canvas,
|
|
2426
|
+
_ctx
|
|
2427
|
+
} = this,
|
|
2428
|
+
canvasX = this._scaleX.canvas,
|
|
2429
|
+
pixelRatio = window.devicePixelRatio / (this._loRes + 1);
|
|
2430
|
+
let screenWidth = window.screen.width * pixelRatio,
|
|
2431
|
+
screenHeight = window.screen.height * pixelRatio;
|
|
2432
|
+
|
|
2433
|
+
// Fix for iOS Safari - swap width and height when in landscape
|
|
2434
|
+
if (Math.abs(window.orientation) == 90 && screenWidth < screenHeight) [screenWidth, screenHeight] = [screenHeight, screenWidth];
|
|
2435
|
+
const isFullscreen = this.isFullscreen,
|
|
2436
|
+
isCanvasFs = isFullscreen && this._fsEl == canvas,
|
|
2437
|
+
newWidth = isCanvasFs ? screenWidth : (this._width || this._container.clientWidth || this._defaultWidth) * pixelRatio | 0,
|
|
2438
|
+
newHeight = isCanvasFs ? screenHeight : (this._height || this._container.clientHeight || this._defaultHeight) * pixelRatio | 0;
|
|
2439
|
+
|
|
2440
|
+
// set/update object properties
|
|
2441
|
+
this._pixelRatio = pixelRatio;
|
|
2442
|
+
this._fsWidth = screenWidth;
|
|
2443
|
+
this._fsHeight = screenHeight;
|
|
2444
|
+
|
|
2445
|
+
// if this is not the constructor call and canvas dimensions haven't changed, quit
|
|
2446
|
+
if (reason != REASON_CREATE && canvas.width == newWidth && canvas.height == newHeight) return;
|
|
2447
|
+
|
|
2448
|
+
// apply new dimensions
|
|
2449
|
+
canvas.width = newWidth;
|
|
2450
|
+
canvas.height = newHeight;
|
|
2451
|
+
|
|
2452
|
+
// if not in overlay mode, paint the canvas black
|
|
2453
|
+
if (!this.overlay) {
|
|
2454
|
+
_ctx.fillStyle = '#000';
|
|
2455
|
+
_ctx.fillRect(0, 0, newWidth, newHeight);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// set lineJoin property for area fill mode (this is reset whenever the canvas size changes)
|
|
2459
|
+
_ctx.lineJoin = 'bevel';
|
|
2460
|
+
|
|
2461
|
+
// update dimensions of the scale canvas
|
|
2462
|
+
canvasX.width = newWidth;
|
|
2463
|
+
canvasX.height = Math.max(20 * pixelRatio, Math.min(newWidth, newHeight) / 32 | 0);
|
|
2464
|
+
|
|
2465
|
+
// calculate bar positions and led options
|
|
2466
|
+
this._calcBars();
|
|
2467
|
+
|
|
2468
|
+
// (re)generate gradient
|
|
2469
|
+
this._makeGrad();
|
|
2470
|
+
|
|
2471
|
+
// detect fullscreen changes (for Safari)
|
|
2472
|
+
if (this._fsStatus !== undefined && this._fsStatus !== isFullscreen) reason = REASON_FSCHANGE;
|
|
2473
|
+
this._fsStatus = isFullscreen;
|
|
2474
|
+
|
|
2475
|
+
// call the callback function, if defined
|
|
2476
|
+
if (this.onCanvasResize) this.onCanvasResize(reason, this);
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/**
|
|
2480
|
+
* Select a gradient for one or both channels
|
|
2481
|
+
*
|
|
2482
|
+
* @param {string} name gradient name
|
|
2483
|
+
* @param [{number}] desired channel (0 or 1) - if empty or invalid, sets both channels
|
|
2484
|
+
*/
|
|
2485
|
+
_setGradient(name, channel) {
|
|
2486
|
+
if (!this._gradients.hasOwnProperty(name)) throw new AudioMotionError(ERR_UNKNOWN_GRADIENT, name);
|
|
2487
|
+
if (![0, 1].includes(channel)) {
|
|
2488
|
+
this._selectedGrads[1] = name;
|
|
2489
|
+
channel = 0;
|
|
2490
|
+
}
|
|
2491
|
+
this._selectedGrads[channel] = name;
|
|
2492
|
+
this._makeGrad();
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
/**
|
|
2496
|
+
* Set object properties
|
|
2497
|
+
*/
|
|
2498
|
+
_setProps(options, useDefaults) {
|
|
2499
|
+
// callback functions properties
|
|
2500
|
+
const callbacks = ['onCanvasDraw', 'onCanvasResize'];
|
|
2501
|
+
|
|
2502
|
+
// properties not in the defaults (`stereo` is deprecated)
|
|
2503
|
+
const extraProps = ['gradientLeft', 'gradientRight', 'stereo'];
|
|
2504
|
+
|
|
2505
|
+
// build an array of valid properties; `start` is not an actual property and is handled after setting everything else
|
|
2506
|
+
const validProps = Object.keys(DEFAULT_SETTINGS).filter(e => e != 'start').concat(callbacks, extraProps);
|
|
2507
|
+
if (useDefaults || options === undefined) options = {
|
|
2508
|
+
...DEFAULT_SETTINGS,
|
|
2509
|
+
...options
|
|
2510
|
+
}; // merge options with defaults
|
|
2511
|
+
|
|
2512
|
+
for (const prop of Object.keys(options)) {
|
|
2513
|
+
if (callbacks.includes(prop) && typeof options[prop] !== 'function')
|
|
2514
|
+
// check invalid callback
|
|
2515
|
+
this[prop] = undefined;else if (validProps.includes(prop))
|
|
2516
|
+
// set only valid properties
|
|
2517
|
+
this[prop] = options[prop];
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// deprecated - move this to the constructor in the next major release (`start` should be constructor-specific)
|
|
2521
|
+
if (options.start !== undefined) this.toggleAnalyzer(options.start);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
_exports.AudioMotionAnalyzer = AudioMotionAnalyzer;
|
|
2525
|
+
var _default = _exports.default = AudioMotionAnalyzer;
|
|
2526
|
+
});
|