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 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
+ });