audiomotion-analyzer 4.0.0 → 4.1.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.
@@ -2,12 +2,12 @@
2
2
  * audioMotion-analyzer
3
3
  * High-resolution real-time graphic audio spectrum analyzer JS module
4
4
  *
5
- * @version 4.0.0
5
+ * @version 4.1.1
6
6
  * @author Henrique Avila Vianna <hvianna@gmail.com> <https://henriquevianna.com>
7
7
  * @license AGPL-3.0-or-later
8
8
  */
9
9
 
10
- const VERSION = '4.0.0';
10
+ const VERSION = '4.1.1';
11
11
 
12
12
  // internal constants
13
13
  const TAU = 2 * Math.PI,
@@ -19,6 +19,9 @@ const CANVAS_BACKGROUND_COLOR = '#000',
19
19
  CHANNEL_COMBINED = 'dual-combined',
20
20
  CHANNEL_SINGLE = 'single',
21
21
  CHANNEL_VERTICAL = 'dual-vertical',
22
+ COLOR_BAR_INDEX = 'bar-index',
23
+ COLOR_BAR_LEVEL = 'bar-level',
24
+ COLOR_GRADIENT = 'gradient',
22
25
  GRADIENT_DEFAULT_BGCOLOR = '#111',
23
26
  FILTER_NONE = '',
24
27
  FILTER_A = 'A',
@@ -49,9 +52,9 @@ const PRISM = [ '#a35', '#c66', '#e94', '#ed0', '#9d5', '#4d8', '#2cb', '#0bc',
49
52
  GRADIENTS = [
50
53
  [ 'classic', {
51
54
  colorStops: [
52
- 'hsl( 0, 100%, 50% )',
53
- { pos: .6, color: 'hsl( 60, 100%, 50% )' },
54
- 'hsl( 120, 100%, 50% )'
55
+ 'red',
56
+ { color: 'yellow', level: .85, pos: .6 },
57
+ { color: 'lime', level: .475 }
55
58
  ]
56
59
  }],
57
60
  [ 'prism', {
@@ -92,9 +95,25 @@ class AudioMotionError extends Error {
92
95
  }
93
96
  }
94
97
 
95
- // helper function
98
+ // helper function - output deprecation warning message on console
96
99
  const deprecate = ( name, alternative ) => console.warn( `${name} is deprecated. Use ${alternative} instead.` );
97
100
 
101
+ // helper function - validate a given value with an array of strings (by default, all lowercase)
102
+ // returns the validated value, or the first element of `list` if `value` is not found in the array
103
+ const validateFromList = ( value, list, modifier = 'toLowerCase' ) => list[ Math.max( 0, list.indexOf( ( '' + value )[ modifier ]() ) ) ];
104
+
105
+ // Polyfill for Array.findLastIndex()
106
+ if ( ! Array.prototype.findLastIndex ) {
107
+ Array.prototype.findLastIndex = function( callback ) {
108
+ let index = this.length;
109
+ while ( index-- > 0 ) {
110
+ if ( callback( this[ index ] ) )
111
+ return index;
112
+ }
113
+ return -1;
114
+ }
115
+ }
116
+
98
117
  // AudioMotionAnalyzer class
99
118
 
100
119
  export default class AudioMotionAnalyzer {
@@ -110,10 +129,12 @@ export default class AudioMotionAnalyzer {
110
129
 
111
130
  this._ready = false;
112
131
 
113
- // Initialize internal gradient objects
132
+ // Initialize internal objects
133
+ this._aux = {};
134
+ this._flg = {};
114
135
  this._gradients = {}; // registered gradients
115
136
  this._selectedGrads = []; // names of the currently selected gradients for channels 0 and 1
116
- this._canvasGradients = []; // actual CanvasGradient objects for channels 0 and 1
137
+ this._canvasGradients = []; // CanvasGradient objects for channels 0 and 1
117
138
 
118
139
  // Register built-in gradients
119
140
  for ( const [ name, options ] of GRADIENTS )
@@ -262,9 +283,6 @@ export default class AudioMotionAnalyzer {
262
283
  }
263
284
  window.addEventListener( 'click', unlockContext );
264
285
 
265
- // initialize internal variables
266
- this._calcAux();
267
-
268
286
  // Set configuration options and use defaults for any missing properties
269
287
  this._setProps( options, true );
270
288
 
@@ -290,7 +308,7 @@ export default class AudioMotionAnalyzer {
290
308
  }
291
309
  set alphaBars( value ) {
292
310
  this._alphaBars = !! value;
293
- this._calcAux();
311
+ this._calcBars();
294
312
  }
295
313
 
296
314
  get ansiBands() {
@@ -306,15 +324,14 @@ export default class AudioMotionAnalyzer {
306
324
  }
307
325
  set barSpace( value ) {
308
326
  this._barSpace = +value || 0;
309
- this._calcAux();
327
+ this._calcBars();
310
328
  }
311
329
 
312
330
  get channelLayout() {
313
331
  return this._chLayout;
314
332
  }
315
333
  set channelLayout( value ) {
316
- const LAYOUTS = [ CHANNEL_SINGLE, CHANNEL_VERTICAL, CHANNEL_COMBINED ];
317
- this._chLayout = LAYOUTS[ Math.max( 0, LAYOUTS.indexOf( ( '' + value ).toLowerCase() ) ) ];
334
+ this._chLayout = validateFromList( value, [ CHANNEL_SINGLE, CHANNEL_VERTICAL, CHANNEL_COMBINED ] );
318
335
 
319
336
  // update node connections
320
337
  this._input.disconnect();
@@ -323,13 +340,17 @@ export default class AudioMotionAnalyzer {
323
340
  if ( this._outNodes.length ) // connect analyzer only if the output is connected to other nodes
324
341
  this._analyzer[0].connect( this._chLayout != CHANNEL_SINGLE ? this._merger : this._output );
325
342
 
326
- // update properties affected by channel layout
327
- this._calcAux();
328
- this._createScales();
329
- this._calcLeds();
343
+ this._calcBars();
330
344
  this._makeGrad();
331
345
  }
332
346
 
347
+ get colorMode() {
348
+ return this._colorMode;
349
+ }
350
+ set colorMode( value ) {
351
+ this._colorMode = validateFromList( value, [ COLOR_GRADIENT, COLOR_BAR_INDEX, COLOR_BAR_LEVEL ] );
352
+ }
353
+
333
354
  get fftSize() {
334
355
  return this._analyzer[0].fftSize;
335
356
  }
@@ -345,9 +366,7 @@ export default class AudioMotionAnalyzer {
345
366
  return this._frequencyScale;
346
367
  }
347
368
  set frequencyScale( value ) {
348
- const FREQUENCY_SCALES = [ SCALE_LOG, SCALE_BARK, SCALE_MEL, SCALE_LINEAR ];
349
- this._frequencyScale = FREQUENCY_SCALES[ Math.max( 0, FREQUENCY_SCALES.indexOf( ( '' + value ).toLowerCase() ) ) ];
350
- this._calcAux();
369
+ this._frequencyScale = validateFromList( value, [ SCALE_LOG, SCALE_BARK, SCALE_MEL, SCALE_LINEAR ] );
351
370
  this._calcBars();
352
371
  }
353
372
 
@@ -385,7 +404,7 @@ export default class AudioMotionAnalyzer {
385
404
  }
386
405
  set ledBars( value ) {
387
406
  this._showLeds = !! value;
388
- this._calcAux();
407
+ this._calcBars();
389
408
  }
390
409
 
391
410
  get linearAmplitude() {
@@ -402,6 +421,13 @@ export default class AudioMotionAnalyzer {
402
421
  this._linearBoost = value >= 1 ? +value : 1;
403
422
  }
404
423
 
424
+ get lineWidth() {
425
+ return this._lineWidth;
426
+ }
427
+ set lineWidth( value ) {
428
+ this._lineWidth = +value || 0;
429
+ }
430
+
405
431
  get loRes() {
406
432
  return this._loRes;
407
433
  }
@@ -415,8 +441,7 @@ export default class AudioMotionAnalyzer {
415
441
  }
416
442
  set lumiBars( value ) {
417
443
  this._lumiBars = !! value;
418
- this._calcAux();
419
- this._calcLeds();
444
+ this._calcBars();
420
445
  this._makeGrad();
421
446
  }
422
447
 
@@ -465,7 +490,6 @@ export default class AudioMotionAnalyzer {
465
490
  }
466
491
  set mirror( value ) {
467
492
  this._mirror = Math.sign( value ) | 0; // ensure only -1, 0 or 1
468
- this._calcAux();
469
493
  this._calcBars();
470
494
  this._makeGrad();
471
495
  }
@@ -477,7 +501,6 @@ export default class AudioMotionAnalyzer {
477
501
  const mode = value | 0;
478
502
  if ( mode >= 0 && mode <= 10 && mode != 9 ) {
479
503
  this._mode = mode;
480
- this._calcAux();
481
504
  this._calcBars();
482
505
  this._makeGrad();
483
506
  }
@@ -498,7 +521,7 @@ export default class AudioMotionAnalyzer {
498
521
  }
499
522
  set outlineBars( value ) {
500
523
  this._outlineBars = !! value;
501
- this._calcAux();
524
+ this._calcBars();
502
525
  }
503
526
 
504
527
  get radial() {
@@ -506,7 +529,6 @@ export default class AudioMotionAnalyzer {
506
529
  }
507
530
  set radial( value ) {
508
531
  this._radial = !! value;
509
- this._calcAux();
510
532
  this._calcBars();
511
533
  this._makeGrad();
512
534
  }
@@ -520,12 +542,19 @@ export default class AudioMotionAnalyzer {
520
542
  throw new AudioMotionError( ERR_REFLEX_OUT_OF_RANGE );
521
543
  else {
522
544
  this._reflexRatio = value;
523
- this._calcAux();
545
+ this._calcBars();
524
546
  this._makeGrad();
525
- this._calcLeds();
526
547
  }
527
548
  }
528
549
 
550
+ get roundBars() {
551
+ return this._roundBars;
552
+ }
553
+ set roundBars( value ) {
554
+ this._roundBars = !! value;
555
+ this._calcBars();
556
+ }
557
+
529
558
  get smoothing() {
530
559
  return this._analyzer[0].smoothingTimeConstant;
531
560
  }
@@ -561,6 +590,13 @@ export default class AudioMotionAnalyzer {
561
590
  this.channelLayout = value ? CHANNEL_VERTICAL : CHANNEL_SINGLE;
562
591
  }
563
592
 
593
+ get trueLeds() {
594
+ return this._trueLeds;
595
+ }
596
+ set trueLeds( value ) {
597
+ this._trueLeds = !! value;
598
+ }
599
+
564
600
  get volume() {
565
601
  return this._output.gain.value;
566
602
  }
@@ -572,8 +608,7 @@ export default class AudioMotionAnalyzer {
572
608
  return this._weightingFilter;
573
609
  }
574
610
  set weightingFilter( value ) {
575
- const WEIGHTING_FILTERS = [ FILTER_NONE, FILTER_A, FILTER_B, FILTER_C, FILTER_D, FILTER_468 ];
576
- this._weightingFilter = WEIGHTING_FILTERS[ Math.max( 0, WEIGHTING_FILTERS.indexOf( ( '' + value ).toUpperCase() ) ) ];
611
+ this._weightingFilter = validateFromList( value, [ FILTER_NONE, FILTER_A, FILTER_B, FILTER_C, FILTER_D, FILTER_468 ], 'toUpperCase' );
577
612
  }
578
613
 
579
614
  get width() {
@@ -611,32 +646,35 @@ export default class AudioMotionAnalyzer {
611
646
  return this._fsWidth;
612
647
  }
613
648
  get isAlphaBars() {
614
- return this._isAlphaBars;
649
+ return this._flg.isAlpha;
615
650
  }
616
651
  get isBandsMode() {
617
- return this._isBandsMode;
652
+ return this._flg.isBands;
618
653
  }
619
654
  get isFullscreen() {
620
655
  return ( document.fullscreenElement || document.webkitFullscreenElement ) === this._fsEl;
621
656
  }
622
657
  get isLedBars() {
623
- return this._isLedDisplay;
658
+ return this._flg.isLeds;
624
659
  }
625
660
  get isLumiBars() {
626
- return this._isLumiBars;
661
+ return this._flg.isLumi;
627
662
  }
628
663
  get isOctaveBands() {
629
- return this._isOctaveBands;
664
+ return this._flg.isOctaves;
630
665
  }
631
666
  get isOn() {
632
667
  return this._runId !== undefined;
633
668
  }
634
669
  get isOutlineBars() {
635
- return this._isOutline;
670
+ return this._flg.isOutline;
636
671
  }
637
672
  get pixelRatio() {
638
673
  return this._pixelRatio;
639
674
  }
675
+ get isRoundBars() {
676
+ return this._flg.isRound;
677
+ }
640
678
  static get version() {
641
679
  return VERSION;
642
680
  }
@@ -790,19 +828,41 @@ export default class AudioMotionAnalyzer {
790
828
  * @param {object} options
791
829
  */
792
830
  registerGradient( name, options ) {
793
- if ( typeof name !== 'string' || name.trim().length == 0 )
831
+ if ( typeof name != 'string' || name.trim().length == 0 )
794
832
  throw new AudioMotionError( ERR_GRADIENT_INVALID_NAME );
795
833
 
796
- if ( typeof options !== 'object' )
834
+ if ( typeof options != 'object' )
797
835
  throw new AudioMotionError( ERR_GRADIENT_NOT_AN_OBJECT );
798
836
 
799
- if ( ! Array.isArray( options.colorStops ) || ! options.colorStops.length )
837
+ const { colorStops } = options;
838
+
839
+ if ( ! Array.isArray( colorStops ) || ! colorStops.length )
800
840
  throw new AudioMotionError( ERR_GRADIENT_MISSING_COLOR );
801
841
 
842
+ const count = colorStops.length,
843
+ isInvalid = val => +val != val || val < 0 || val > 1;
844
+
845
+ // normalize all colorStops as objects with `pos`, `color` and `level` properties
846
+ colorStops.forEach( ( colorStop, index ) => {
847
+ const pos = index / Math.max( 1, count - 1 );
848
+ if ( typeof colorStop != 'object' ) // only color string was defined
849
+ colorStops[ index ] = { pos, color: colorStop };
850
+ else if ( isInvalid( colorStop.pos ) )
851
+ colorStop.pos = pos;
852
+
853
+ if ( isInvalid( colorStop.level ) )
854
+ colorStops[ index ].level = 1 - index / count;
855
+ });
856
+
857
+ // make sure colorStops is in descending `level` order and that the first one has `level == 1`
858
+ // this is crucial for proper operation of 'bar-level' colorMode!
859
+ colorStops.sort( ( a, b ) => a.level < b.level ? 1 : a.level > b.level ? -1 : 0 );
860
+ colorStops[0].level = 1;
861
+
802
862
  this._gradients[ name ] = {
803
863
  bgColor: options.bgColor || GRADIENT_DEFAULT_BGCOLOR,
804
864
  dir: options.dir,
805
- colorStops: options.colorStops
865
+ colorStops: colorStops
806
866
  };
807
867
 
808
868
  // if the registered gradient is one of the currently selected gradients, regenerate them
@@ -854,7 +914,7 @@ export default class AudioMotionAnalyzer {
854
914
  }
855
915
 
856
916
  this._ledParams = maxLeds > 0 && spaceV > 0 && spaceH >= 0 ? [ maxLeds, spaceV, spaceH ] : undefined;
857
- this._calcLeds();
917
+ this._calcBars();
858
918
  }
859
919
 
860
920
  /**
@@ -939,37 +999,7 @@ export default class AudioMotionAnalyzer {
939
999
  }
940
1000
 
941
1001
  /**
942
- * Calculate auxiliary values and flags
943
- */
944
- _calcAux() {
945
- const canvas = this.canvas,
946
- isRadial = this._radial,
947
- isDual = this._chLayout == CHANNEL_VERTICAL && ! isRadial,
948
- centerX = canvas.width >> 1;
949
-
950
- this._radius = Math.min( canvas.width, canvas.height ) * ( this._chLayout == CHANNEL_VERTICAL ? .375 : .125 ) | 0;
951
- this._barSpacePx = Math.min( this._barWidth - 1, ( this._barSpace > 0 && this._barSpace < 1 ) ? this._barWidth * this._barSpace : this._barSpace );
952
- this._isBandsMode = this._mode % 10 != 0;
953
- this._isOctaveBands = this._isBandsMode && this._frequencyScale == SCALE_LOG;
954
- this._isLedDisplay = this._showLeds && this._isBandsMode && ! isRadial;
955
- this._isLumiBars = this._lumiBars && this._isBandsMode && ! isRadial;
956
- this._isAlphaBars = this._alphaBars && ! this._isLumiBars && this._mode != 10;
957
- this._isOutline = this._outlineBars && this._isBandsMode && ! this._isLumiBars && ! this._isLedDisplay;
958
- this._maximizeLeds = this._chLayout != CHANNEL_VERTICAL || this._reflexRatio > 0 && ! this._isLumiBars;
959
-
960
- this._channelHeight = canvas.height - ( isDual && ! this._isLedDisplay ? .5 : 0 ) >> isDual;
961
- this._analyzerHeight = this._channelHeight * ( this._isLumiBars || isRadial ? 1 : 1 - this._reflexRatio ) | 0;
962
-
963
- // channelGap is **0** if isLedDisplay == true (LEDs already have spacing); **1** if canvas height is odd (windowed); **2** if it's even
964
- // TODO: improve this, make it configurable?
965
- this._channelGap = isDual ? canvas.height - this._channelHeight * 2 : 0;
966
-
967
- this._analyzerWidth = canvas.width - centerX * ( this._mirror != 0 );
968
- this._initialX = centerX * ( this._mirror == -1 && ! isRadial );
969
- }
970
-
971
- /**
972
- * Calculate the X-coordinate on canvas for each analyzer bar
1002
+ * Compute all internal data required for the analyzer, based on its current settings
973
1003
  */
974
1004
  _calcBars() {
975
1005
  const bars = this._bars = []; // initialize object property
@@ -977,9 +1007,63 @@ export default class AudioMotionAnalyzer {
977
1007
  if ( ! this._ready )
978
1008
  return;
979
1009
 
1010
+ const barSpace = this._barSpace,
1011
+ canvas = this.canvas,
1012
+ centerX = canvas.width >> 1,
1013
+ chLayout = this._chLayout,
1014
+ isAnsiBands = this._ansiBands,
1015
+ isRadial = this._radial,
1016
+ isDual = chLayout == CHANNEL_VERTICAL && ! isRadial,
1017
+ maxFreq = this._maxFreq,
1018
+ minFreq = this._minFreq,
1019
+ mode = this._mode,
1020
+
1021
+ // COMPUTE FLAGS
1022
+
1023
+ isBands = mode % 10 != 0,
1024
+ isOctaves = isBands && this._frequencyScale == SCALE_LOG,
1025
+ isLeds = this._showLeds && isBands && ! isRadial,
1026
+ isLumi = this._lumiBars && isBands && ! isRadial,
1027
+ isAlpha = this._alphaBars && ! isLumi && mode != 10,
1028
+ isOutline = this._outlineBars && isBands && ! isLumi && ! isLeds,
1029
+ isRound = this._roundBars && isBands && ! isLumi && ! isLeds,
1030
+ noLedGap = chLayout != CHANNEL_VERTICAL || this._reflexRatio > 0 && ! isLumi,
1031
+
1032
+ // COMPUTE AUXILIARY VALUES
1033
+
1034
+ // channelHeight is the total canvas height dedicated to each channel, including the reflex area, if any)
1035
+ channelHeight = canvas.height - ( isDual && ! isLeds ? .5 : 0 ) >> isDual,
1036
+ // analyzerHeight is the effective height used to render the analyzer, excluding the reflex area
1037
+ analyzerHeight = channelHeight * ( isLumi || isRadial ? 1 : 1 - this._reflexRatio ) | 0,
1038
+
1039
+ analyzerWidth = canvas.width - centerX * ( this._mirror != 0 ),
1040
+
1041
+ // channelGap is **0** if isLedDisplay == true (LEDs already have spacing); **1** if canvas height is odd (windowed); **2** if it's even
1042
+ // TODO: improve this, make it configurable?
1043
+ channelGap = isDual ? canvas.height - channelHeight * 2 : 0,
1044
+
1045
+ initialX = centerX * ( this._mirror == -1 && ! isRadial ),
1046
+ radius = Math.min( canvas.width, canvas.height ) * ( chLayout == CHANNEL_VERTICAL ? .375 : .125 ) | 0;
1047
+
1048
+ /**
1049
+ * CREATE ANALYZER BANDS
1050
+ *
1051
+ * USES:
1052
+ * analyzerWidth
1053
+ * initialX
1054
+ * isBands
1055
+ * isOctaves
1056
+ *
1057
+ * GENERATES:
1058
+ * bars (populates this._bars)
1059
+ * bardWidth
1060
+ * scaleMin
1061
+ * unitWidth
1062
+ */
1063
+
980
1064
  // helper function
981
1065
  // bar object: { posX, freq, freqLo, freqHi, binLo, binHi, ratioLo, ratioHi, peak, hold, value }
982
- const barsPush = args => bars.push( { ...args, peak: [0,0], hold: [0], value: [0] } );
1066
+ const barsPush = args => bars.push( { ...args, peak: [0,0], hold: [0], value: [0] } );
983
1067
 
984
1068
  /*
985
1069
  A simple interpolation is used to obtain an approximate amplitude value for any given frequency,
@@ -1012,15 +1096,9 @@ export default class AudioMotionAnalyzer {
1012
1096
  return [ bin, ratio ];
1013
1097
  }
1014
1098
 
1015
- const analyzerWidth = this._analyzerWidth,
1016
- initialX = this._initialX,
1017
- isAnsiBands = this._ansiBands,
1018
- maxFreq = this._maxFreq,
1019
- minFreq = this._minFreq;
1099
+ let barWidth, scaleMin, unitWidth;
1020
1100
 
1021
- let scaleMin, unitWidth;
1022
-
1023
- if ( this._isOctaveBands ) {
1101
+ if ( isOctaves ) {
1024
1102
  // helper function to round a value to a given number of significant digits
1025
1103
  // `atLeast` set to true prevents reducing the number of integer significant digits
1026
1104
  const roundSD = ( value, digits, atLeast ) => +value.toPrecision( atLeast ? Math.max( digits, 1 + Math.log10( value ) | 0 ) : digits );
@@ -1075,9 +1153,9 @@ export default class AudioMotionAnalyzer {
1075
1153
  currFreq *= bandWidth;
1076
1154
  } while ( currFreq <= maxFreq );
1077
1155
 
1078
- this._barWidth = analyzerWidth / bars.length;
1156
+ barWidth = analyzerWidth / bars.length;
1079
1157
 
1080
- bars.forEach( ( bar, index ) => bar.posX = initialX + index * this._barWidth );
1158
+ bars.forEach( ( bar, index ) => bar.posX = initialX + index * barWidth );
1081
1159
 
1082
1160
  const firstBar = bars[0],
1083
1161
  lastBar = bars[ bars.length - 1 ];
@@ -1097,7 +1175,7 @@ export default class AudioMotionAnalyzer {
1097
1175
  [ lastBar.binHi, lastBar.ratioHi ] = calcRatio( maxFreq );
1098
1176
  }
1099
1177
  }
1100
- else if ( this._isBandsMode ) { // a bands mode is selected, but frequency scale is not logarithmic
1178
+ else if ( isBands ) { // a bands mode is selected, but frequency scale is not logarithmic
1101
1179
 
1102
1180
  const bands = [0,24,12,8,6,4,3,2,1][ this._mode ] * 10;
1103
1181
 
@@ -1112,15 +1190,15 @@ export default class AudioMotionAnalyzer {
1112
1190
  }
1113
1191
  }
1114
1192
 
1115
- this._barWidth = analyzerWidth / bands;
1193
+ barWidth = analyzerWidth / bands;
1116
1194
 
1117
1195
  scaleMin = this._freqScaling( minFreq );
1118
1196
  unitWidth = analyzerWidth / ( this._freqScaling( maxFreq ) - scaleMin );
1119
1197
 
1120
- for ( let i = 0, posX = 0; i < bands; i++, posX += this._barWidth ) {
1198
+ for ( let i = 0, posX = 0; i < bands; i++, posX += barWidth ) {
1121
1199
  const freqLo = invFreqScaling( scaleMin + posX / unitWidth ),
1122
- freq = invFreqScaling( scaleMin + ( posX + this._barWidth / 2 ) / unitWidth ),
1123
- freqHi = invFreqScaling( scaleMin + ( posX + this._barWidth ) / unitWidth ),
1200
+ freq = invFreqScaling( scaleMin + ( posX + barWidth / 2 ) / unitWidth ),
1201
+ freqHi = invFreqScaling( scaleMin + ( posX + barWidth ) / unitWidth ),
1124
1202
  [ binLo, ratioLo ] = calcRatio( freqLo ),
1125
1203
  [ binHi, ratioHi ] = calcRatio( freqHi );
1126
1204
 
@@ -1129,7 +1207,7 @@ export default class AudioMotionAnalyzer {
1129
1207
 
1130
1208
  }
1131
1209
  else { // Discrete frequencies modes
1132
- this._barWidth = 1;
1210
+ barWidth = 1;
1133
1211
 
1134
1212
  scaleMin = this._freqScaling( minFreq );
1135
1213
  unitWidth = analyzerWidth / ( this._freqScaling( maxFreq ) - scaleMin );
@@ -1157,78 +1235,130 @@ export default class AudioMotionAnalyzer {
1157
1235
  }
1158
1236
  }
1159
1237
 
1160
- // save these for scale generation
1161
- this._scaleMin = scaleMin;
1162
- this._unitWidth = unitWidth;
1238
+ /**
1239
+ * COMPUTE ATTRIBUTES FOR THE LED BARS
1240
+ *
1241
+ * USES:
1242
+ * analyzerHeight
1243
+ * barWidth
1244
+ * noLedGap
1245
+ *
1246
+ * GENERATES:
1247
+ * spaceH
1248
+ * spaceV
1249
+ * this._leds
1250
+ */
1251
+
1252
+ let spaceH = 0,
1253
+ spaceV = 0;
1254
+
1255
+ if ( isLeds ) {
1256
+ // adjustment for high pixel-ratio values on low-resolution screens (Android TV)
1257
+ const dPR = this._pixelRatio / ( window.devicePixelRatio > 1 && window.screen.height <= 540 ? 2 : 1 );
1258
+
1259
+ const params = [ [],
1260
+ [ 128, 3, .45 ], // mode 1
1261
+ [ 128, 4, .225 ], // mode 2
1262
+ [ 96, 6, .225 ], // mode 3
1263
+ [ 80, 6, .225 ], // mode 4
1264
+ [ 80, 6, .125 ], // mode 5
1265
+ [ 64, 6, .125 ], // mode 6
1266
+ [ 48, 8, .125 ], // mode 7
1267
+ [ 24, 16, .125 ], // mode 8
1268
+ ];
1269
+
1270
+ // use custom LED parameters if set, or the default parameters for the current mode
1271
+ const customParams = this._ledParams,
1272
+ [ maxLeds, spaceVRatio, spaceHRatio ] = customParams || params[ mode ];
1273
+
1274
+ let ledCount, maxHeight = analyzerHeight;
1275
+
1276
+ if ( customParams ) {
1277
+ const minHeight = 2 * dPR;
1278
+ let blockHeight;
1279
+ ledCount = maxLeds + 1;
1280
+ do {
1281
+ ledCount--;
1282
+ blockHeight = maxHeight / ledCount / ( 1 + spaceVRatio );
1283
+ spaceV = blockHeight * spaceVRatio;
1284
+ } while ( ( blockHeight < minHeight || spaceV < minHeight ) && ledCount > 1 );
1285
+ }
1286
+ else {
1287
+ // calculate vertical spacing - aim for the reference ratio, but make sure it's at least 2px
1288
+ const refRatio = 540 / spaceVRatio;
1289
+ spaceV = Math.min( spaceVRatio * dPR, Math.max( 2, maxHeight / refRatio + .1 | 0 ) );
1290
+ }
1163
1291
 
1164
- // update internal variables
1165
- this._calcAux();
1292
+ // remove the extra spacing below the last line of LEDs
1293
+ if ( noLedGap )
1294
+ maxHeight += spaceV;
1166
1295
 
1167
- // generate the X-axis and radial scales
1168
- this._createScales();
1169
-
1170
- // update LED properties
1171
- this._calcLeds();
1172
- }
1296
+ // recalculate the number of leds, considering the effective spaceV
1297
+ if ( ! customParams )
1298
+ ledCount = Math.min( maxLeds, maxHeight / ( spaceV * 2 ) | 0 );
1173
1299
 
1174
- /**
1175
- * Calculate attributes for the vintage LEDs effect, based on visualization mode and canvas resolution
1176
- */
1177
- _calcLeds() {
1178
- if ( ! this._isBandsMode || ! this._ready )
1179
- return;
1300
+ spaceH = spaceHRatio >= 1 ? spaceHRatio : barWidth * spaceHRatio;
1180
1301
 
1181
- // adjustment for high pixel-ratio values on low-resolution screens (Android TV)
1182
- const dPR = this._pixelRatio / ( window.devicePixelRatio > 1 && window.screen.height <= 540 ? 2 : 1 );
1183
-
1184
- const params = [ [],
1185
- [ 128, 3, .45 ], // mode 1
1186
- [ 128, 4, .225 ], // mode 2
1187
- [ 96, 6, .225 ], // mode 3
1188
- [ 80, 6, .225 ], // mode 4
1189
- [ 80, 6, .125 ], // mode 5
1190
- [ 64, 6, .125 ], // mode 6
1191
- [ 48, 8, .125 ], // mode 7
1192
- [ 24, 16, .125 ], // mode 8
1193
- ];
1194
-
1195
- // use custom LED parameters if set, or the default parameters for the current mode
1196
- const customParams = this._ledParams,
1197
- [ maxLeds, spaceVRatio, spaceHRatio ] = customParams || params[ this._mode ];
1198
-
1199
- let ledCount, spaceV,
1200
- analyzerHeight = this._analyzerHeight;
1201
-
1202
- if ( customParams ) {
1203
- const minHeight = 2 * dPR;
1204
- let blockHeight;
1205
- ledCount = maxLeds + 1;
1206
- do {
1207
- ledCount--;
1208
- blockHeight = analyzerHeight / ledCount / ( 1 + spaceVRatio );
1209
- spaceV = blockHeight * spaceVRatio;
1210
- } while ( ( blockHeight < minHeight || spaceV < minHeight ) && ledCount > 1 );
1302
+ this._leds = [
1303
+ ledCount,
1304
+ spaceH,
1305
+ spaceV,
1306
+ maxHeight / ledCount - spaceV // ledHeight
1307
+ ];
1211
1308
  }
1212
- else {
1213
- // calculate vertical spacing - aim for the reference ratio, but make sure it's at least 2px
1214
- const refRatio = 540 / spaceVRatio;
1215
- spaceV = Math.min( spaceVRatio * dPR, Math.max( 2, analyzerHeight / refRatio + .1 | 0 ) );
1309
+
1310
+ // COMPUTE ADDITIONAL BAR POSITIONING, ACCORDING TO THE CURRENT SETTINGS
1311
+ // uses: barSpace, barWidth, spaceH
1312
+
1313
+ const barSpacePx = Math.min( barWidth - 1, barSpace * ( barSpace > 0 && barSpace < 1 ? barWidth : 1 ) );
1314
+
1315
+ if ( isBands )
1316
+ barWidth -= Math.max( isLeds ? spaceH : 0, barSpacePx );
1317
+
1318
+ bars.forEach( ( bar, index ) => {
1319
+ let posX = bar.posX,
1320
+ width = barWidth;
1321
+
1322
+ // in bands modes we need to update bar.posX to account for bar/led spacing
1323
+
1324
+ if ( isBands ) {
1325
+ if ( barSpace == 0 && ! isLeds ) {
1326
+ // when barSpace == 0 use integer values for perfect gapless positioning
1327
+ posX |= 0;
1328
+ width |= 0;
1329
+ if ( index > 0 && posX > bars[ index - 1 ].posX + bars[ index - 1 ].width ) {
1330
+ posX--;
1331
+ width++;
1332
+ }
1333
+ }
1334
+ else
1335
+ posX += Math.max( ( isLeds ? spaceH : 0 ), barSpacePx ) / 2;
1336
+
1337
+ bar.posX = posX; // update
1338
+ }
1339
+
1340
+ bar.barCenter = posX + ( barWidth == 1 ? 0 : width / 2 );
1341
+ bar.width = width;
1342
+ });
1343
+
1344
+ // COMPUTE CHANNEL COORDINATES (uses spaceV)
1345
+
1346
+ const channelCoords = [];
1347
+ for ( const channel of [0,1] ) {
1348
+ const channelTop = chLayout == CHANNEL_VERTICAL ? ( channelHeight + channelGap ) * channel : 0,
1349
+ channelBottom = channelTop + channelHeight,
1350
+ analyzerBottom = channelTop + analyzerHeight - ( ! isLeds || noLedGap ? 0 : spaceV );
1351
+
1352
+ channelCoords.push( { channelTop, channelBottom, analyzerBottom } );
1216
1353
  }
1217
1354
 
1218
- // remove the extra spacing below the last line of LEDs
1219
- if ( this._maximizeLeds )
1220
- analyzerHeight += spaceV;
1355
+ // SAVE INTERNAL PROPERTIES
1221
1356
 
1222
- // recalculate the number of leds, considering the effective spaceV
1223
- if ( ! customParams )
1224
- ledCount = Math.min( maxLeds, analyzerHeight / ( spaceV * 2 ) | 0 );
1357
+ this._aux = { analyzerHeight, analyzerWidth, channelCoords, channelHeight, channelGap, initialX, radius, scaleMin, unitWidth };
1358
+ this._flg = { isAlpha, isBands, isLeds, isLumi, isOctaves, isOutline, isRound, noLedGap };
1225
1359
 
1226
- this._leds = [
1227
- ledCount,
1228
- spaceHRatio >= 1 ? spaceHRatio : this._barWidth * spaceHRatio, // spaceH
1229
- spaceV,
1230
- analyzerHeight / ledCount - spaceV // ledHeight
1231
- ];
1360
+ // generate the X-axis and radial scales
1361
+ this._createScales();
1232
1362
  }
1233
1363
 
1234
1364
  /**
@@ -1238,7 +1368,7 @@ export default class AudioMotionAnalyzer {
1238
1368
  if ( ! this._ready )
1239
1369
  return;
1240
1370
 
1241
- const analyzerWidth = this._analyzerWidth,
1371
+ const { analyzerWidth, initialX, radius, scaleMin, unitWidth } = this._aux,
1242
1372
  canvas = this._canvasCtx.canvas,
1243
1373
  scaleX = this._scaleX,
1244
1374
  scaleR = this._scaleR,
@@ -1246,20 +1376,22 @@ export default class AudioMotionAnalyzer {
1246
1376
  canvasR = scaleR.canvas,
1247
1377
  freqLabels = [],
1248
1378
  frequencyScale= this._frequencyScale,
1249
- initialX = this._initialX,
1250
- isDual = this._chLayout == CHANNEL_VERTICAL,
1251
- isMirror = this._mirror,
1252
1379
  isNoteLabels = this._noteLabels,
1380
+ isRadial = this._radial,
1381
+ isVertical = this._chLayout == CHANNEL_VERTICAL,
1382
+ mirror = this._mirror,
1253
1383
  scale = [ 'C',, 'D',, 'E', 'F',, 'G',, 'A',, 'B' ], // for note labels (no sharp notes)
1254
- scaleHeight = Math.min( canvas.width, canvas.height ) * .03 | 0, // circular scale height (radial mode)
1384
+ scaleHeight = Math.min( canvas.width, canvas.height ) / 34 | 0, // circular scale height (radial mode)
1255
1385
  fontSizeX = canvasX.height >> 1,
1256
1386
  fontSizeR = scaleHeight >> 1,
1387
+ labelWidthX = fontSizeX * ( isNoteLabels ? .7 : 1.5 ),
1388
+ labelWidthR = fontSizeR * ( isNoteLabels ? 1 : 2 ),
1257
1389
  root12 = 2 ** ( 1 / 12 );
1258
1390
 
1259
1391
  if ( ! isNoteLabels && ( this._ansiBands || frequencyScale != SCALE_LOG ) ) {
1260
1392
  freqLabels.push( 16, 31.5, 63, 125, 250, 500, 1e3, 2e3, 4e3 );
1261
1393
  if ( frequencyScale == SCALE_LINEAR )
1262
- freqLabels.push( 6e3, 8e3, 10e3, 12e3, 14e3, 16e3, 18e3, 20e3 );
1394
+ freqLabels.push( 6e3, 8e3, 10e3, 12e3, 14e3, 16e3, 18e3, 20e3, 22e3 );
1263
1395
  else
1264
1396
  freqLabels.push( 8e3, 16e3 );
1265
1397
  }
@@ -1270,7 +1402,7 @@ export default class AudioMotionAnalyzer {
1270
1402
  if ( freq >= this._minFreq && freq <= this._maxFreq ) {
1271
1403
  const pitch = scale[ note ],
1272
1404
  isC = pitch == 'C';
1273
- if ( ( pitch && isNoteLabels && ! isMirror ) || isC )
1405
+ if ( ( pitch && isNoteLabels && ! mirror ) || isC )
1274
1406
  freqLabels.push( isNoteLabels ? [ freq, pitch + ( isC ? octave : '' ) ] : freq );
1275
1407
  }
1276
1408
  freq *= root12;
@@ -1278,24 +1410,21 @@ export default class AudioMotionAnalyzer {
1278
1410
  }
1279
1411
  }
1280
1412
 
1281
- // in radial stereo mode, the scale is positioned exactly between both channels, by making the canvas a bit larger than the inner diameter
1282
- canvasR.width = canvasR.height = ( this._radius << 1 ) + ( isDual * scaleHeight );
1413
+ // in radial dual-vertical layout, the scale is positioned exactly between both channels, by making the canvas a bit larger than the inner diameter
1414
+ canvasR.width = canvasR.height = ( radius << 1 ) + ( isVertical * scaleHeight );
1283
1415
 
1284
- const radius = canvasR.width >> 1, // this is also used as the center X and Y coordinates of the circular scale canvas
1285
- radialY = radius - scaleHeight * .7; // vertical position of text labels in the circular scale
1416
+ const centerR = canvasR.width >> 1,
1417
+ radialY = centerR - scaleHeight * .7; // vertical position of text labels in the circular scale
1286
1418
 
1287
1419
  // helper function
1288
1420
  const radialLabel = ( x, label ) => {
1289
- if ( isNoteLabels && ! isDual && ! ['C','E','G'].includes( label[0] ) )
1290
- return;
1291
-
1292
1421
  const angle = TAU * ( x / canvas.width ),
1293
1422
  adjAng = angle - HALF_PI, // rotate angles so 0 is at the top
1294
1423
  posX = radialY * Math.cos( adjAng ),
1295
1424
  posY = radialY * Math.sin( adjAng );
1296
1425
 
1297
1426
  scaleR.save();
1298
- scaleR.translate( radius + posX, radius + posY );
1427
+ scaleR.translate( centerR + posX, centerR + posY );
1299
1428
  scaleR.rotate( angle );
1300
1429
  scaleR.fillText( label, 0, 0 );
1301
1430
  scaleR.restore();
@@ -1307,7 +1436,7 @@ export default class AudioMotionAnalyzer {
1307
1436
  scaleX.fillStyle = scaleR.strokeStyle = SCALEX_BACKGROUND_COLOR;
1308
1437
  scaleX.fillRect( 0, 0, canvasX.width, canvasX.height );
1309
1438
 
1310
- scaleR.arc( radius, radius, radius - scaleHeight / 2, 0, TAU );
1439
+ scaleR.arc( centerR, centerR, centerR - scaleHeight / 2, 0, TAU );
1311
1440
  scaleR.lineWidth = scaleHeight;
1312
1441
  scaleR.stroke();
1313
1442
 
@@ -1316,32 +1445,49 @@ export default class AudioMotionAnalyzer {
1316
1445
  scaleR.font = `${ fontSizeR }px ${FONT_FAMILY}`;
1317
1446
  scaleX.textAlign = scaleR.textAlign = 'center';
1318
1447
 
1319
- let prevX = 0, prevR = 0;
1448
+ let prevX = -labelWidthX / 4,
1449
+ prevR = -labelWidthR;
1320
1450
 
1321
1451
  for ( const item of freqLabels ) {
1322
1452
  const [ freq, label ] = Array.isArray( item ) ? item : [ item, item < 1e3 ? item | 0 : `${ ( item / 100 | 0 ) / 10 }k` ],
1323
- x = this._unitWidth * ( this._freqScaling( freq ) - this._scaleMin ),
1453
+ x = unitWidth * ( this._freqScaling( freq ) - scaleMin ),
1324
1454
  y = canvasX.height * .75,
1325
1455
  isC = label[0] == 'C',
1326
- maxW = fontSizeX * ( isNoteLabels && ! isMirror ? ( isC ? 1.2 : .6 ) : 3 );
1327
-
1328
- if ( x >= 0 && x <= analyzerWidth ) {
1329
-
1330
- scaleX.fillStyle = scaleR.fillStyle = isC && ! isMirror ? SCALEX_HIGHLIGHT_COLOR : SCALEX_LABEL_COLOR;
1456
+ maxW = fontSizeX * ( isNoteLabels && ! mirror ? ( isC ? 1.2 : .6 ) : 3 );
1457
+
1458
+ // set label color - no highlight when mirror effect is active (only Cs displayed)
1459
+ scaleX.fillStyle = scaleR.fillStyle = isC && ! mirror ? SCALEX_HIGHLIGHT_COLOR : SCALEX_LABEL_COLOR;
1460
+
1461
+ // prioritizes which note labels are displayed, due to the restricted space on some ranges/scales
1462
+ if ( isNoteLabels ) {
1463
+ let allowedLabels = ['C'];
1464
+ if ( frequencyScale == SCALE_LOG || freq > 2e3 || ( frequencyScale != SCALE_LINEAR && freq > 250 ) ||
1465
+ ( ( ! isRadial || isVertical ) && ( frequencyScale != SCALE_LINEAR && freq > 125 || freq > 1e3 ) ) )
1466
+ allowedLabels.push('G');
1467
+ if ( frequencyScale == SCALE_LOG || freq > 4e3 || ( frequencyScale != SCALE_LINEAR && freq > 500 ) ||
1468
+ ( ( ! isRadial || isVertical ) && ( frequencyScale != SCALE_LINEAR && freq > 250 || freq > 2e3 ) ) )
1469
+ allowedLabels.push('E');
1470
+ if ( frequencyScale == SCALE_LINEAR && freq > 4e3 ||
1471
+ ( ( ! isRadial || isVertical ) && ( frequencyScale == SCALE_LOG || freq > 2e3 || ( frequencyScale != SCALE_LINEAR && freq > 500 ) ) ) )
1472
+ allowedLabels.push('D','F','A','B');
1473
+ if ( ! allowedLabels.includes( label[0] ) )
1474
+ continue; // skip this label
1475
+ }
1331
1476
 
1332
- if ( x > prevX + fontSizeX / 2 ) {
1333
- scaleX.fillText( label, initialX + x, y, maxW );
1334
- if ( isMirror )
1335
- scaleX.fillText( label, ( initialX || canvas.width ) - x, y, maxW );
1336
- prevX = x + Math.min( maxW, scaleX.measureText( label ).width ) / 2;
1337
- }
1477
+ // linear scale
1478
+ if ( x >= prevX + labelWidthX / 2 && x <= analyzerWidth ) {
1479
+ scaleX.fillText( label, initialX + x, y, maxW );
1480
+ if ( mirror && ( x > labelWidthX || mirror == 1 ) )
1481
+ scaleX.fillText( label, ( initialX || canvas.width ) - x, y, maxW );
1482
+ prevX = x + Math.min( maxW, scaleX.measureText( label ).width ) / 2;
1483
+ }
1338
1484
 
1339
- if ( x < analyzerWidth && ( x > prevR + fontSizeR || isC ) ) { // avoid wrapping-around the last label and overlapping the first one
1340
- radialLabel( x, label );
1341
- if ( isMirror && x > fontSizeR ) // avoid overlapping of first labels on mirror mode
1342
- radialLabel( -x, label );
1343
- prevR = x;
1344
- }
1485
+ // radial scale
1486
+ if ( x >= prevR + labelWidthR && x < analyzerWidth - labelWidthR ) { // avoid overlapping the last label over the first one
1487
+ radialLabel( x, label );
1488
+ if ( mirror && ( x > labelWidthR || mirror == 1 ) ) // avoid overlapping of first labels on mirror mode
1489
+ radialLabel( -x, label );
1490
+ prevR = x;
1345
1491
  }
1346
1492
  }
1347
1493
  }
@@ -1351,48 +1497,132 @@ export default class AudioMotionAnalyzer {
1351
1497
  * this is called 60 times per second by requestAnimationFrame()
1352
1498
  */
1353
1499
  _draw( timestamp ) {
1354
- const barSpace = this._barSpace,
1355
- barSpacePx = this._barSpacePx,
1500
+ const { isAlpha, isBands, isLeds, isLumi,
1501
+ isOctaves, isOutline, isRound, noLedGap } = this._flg,
1356
1502
  ctx = this._canvasCtx,
1357
1503
  canvas = ctx.canvas,
1358
1504
  canvasX = this._scaleX.canvas,
1359
1505
  canvasR = this._scaleR.canvas,
1360
1506
  canvasGradients= this._canvasGradients,
1507
+ centerX = canvas.width >> 1,
1508
+ centerY = canvas.height >> 1,
1509
+ colorMode = this._colorMode,
1361
1510
  energy = this._energy,
1362
1511
  fillAlpha = this.fillAlpha,
1363
1512
  mode = this._mode,
1364
- isAlphaBars = this._isAlphaBars,
1365
- isLedDisplay = this._isLedDisplay,
1366
1513
  isLinear = this._linearAmplitude,
1367
- isLumiBars = this._isLumiBars,
1368
- isBandsMode = this._isBandsMode,
1369
- isOutline = this._isOutline,
1370
1514
  isOverlay = this.overlay,
1371
1515
  isRadial = this._radial,
1516
+ isTrueLeds = isLeds && this._trueLeds && colorMode == COLOR_GRADIENT,
1372
1517
  channelLayout = this._chLayout,
1373
- lineWidth = +this.lineWidth, // make sure the damn thing is a number!
1518
+ lineWidth = this._lineWidth,
1374
1519
  mirrorMode = this._mirror,
1375
- channelHeight = this._channelHeight,
1376
- channelGap = this._channelGap,
1377
- analyzerHeight = this._analyzerHeight,
1378
- analyzerWidth = isRadial ? canvas.width : this._analyzerWidth,
1379
- initialX = this._initialX,
1520
+ { analyzerHeight, channelCoords,
1521
+ channelHeight, channelGap, initialX, radius } = this._aux,
1522
+ analyzerWidth = isRadial ? canvas.width : this._aux.analyzerWidth,
1380
1523
  finalX = initialX + analyzerWidth,
1381
- centerX = canvas.width >> 1,
1382
- centerY = canvas.height >> 1,
1383
- radius = this._radius,
1384
1524
  showBgColor = this.showBgColor,
1385
1525
  maxBarHeight = isRadial ? Math.min( centerX, centerY ) - radius : analyzerHeight,
1386
1526
  maxdB = this.maxDecibels,
1387
1527
  mindB = this.minDecibels,
1388
1528
  dbRange = maxdB - mindB,
1389
1529
  useCanvas = this.useCanvas,
1390
- weightingFilter= this._weightingFilter;
1530
+ weightingFilter= this._weightingFilter,
1531
+ [ ledCount, ledSpaceH, ledSpaceV, ledHeight ] = this._leds || [];
1391
1532
 
1392
1533
  if ( energy.val > 0 )
1393
1534
  this._spinAngle += this._spinSpeed * RPM;
1394
1535
 
1395
- // helper function - apply the selected weighting filter and return dB gain for a given frequency
1536
+ /* HELPER FUNCTIONS */
1537
+
1538
+ // create Reflex effect
1539
+ const doReflex = channel => {
1540
+ if ( this._reflexRatio > 0 && ! isLumi ) {
1541
+ let posY, height;
1542
+ if ( this.reflexFit || channelLayout == CHANNEL_VERTICAL ) { // always fit reflex in vertical stereo mode
1543
+ posY = channelLayout == CHANNEL_VERTICAL && channel == 0 ? channelHeight + channelGap : 0;
1544
+ height = channelHeight - analyzerHeight;
1545
+ }
1546
+ else {
1547
+ posY = canvas.height - analyzerHeight * 2;
1548
+ height = analyzerHeight;
1549
+ }
1550
+
1551
+ ctx.save();
1552
+
1553
+ // set alpha and brightness for the reflection
1554
+ ctx.globalAlpha = this.reflexAlpha;
1555
+ if ( this.reflexBright != 1 )
1556
+ ctx.filter = `brightness(${this.reflexBright})`;
1557
+
1558
+ // create the reflection
1559
+ ctx.setTransform( 1, 0, 0, -1, 0, canvas.height );
1560
+ ctx.drawImage( canvas, 0, channelCoords[ channel ].channelTop, canvas.width, analyzerHeight, 0, posY, canvas.width, height );
1561
+
1562
+ ctx.restore();
1563
+ }
1564
+ }
1565
+
1566
+ // draw scale on X-axis
1567
+ const drawScaleX = () => {
1568
+ if ( this.showScaleX ) {
1569
+ if ( isRadial ) {
1570
+ ctx.save();
1571
+ ctx.translate( centerX, centerY );
1572
+ if ( this._spinSpeed )
1573
+ ctx.rotate( this._spinAngle + HALF_PI );
1574
+ ctx.drawImage( canvasR, -canvasR.width >> 1, -canvasR.width >> 1 );
1575
+ ctx.restore();
1576
+ }
1577
+ else
1578
+ ctx.drawImage( canvasX, 0, canvas.height - canvasX.height );
1579
+ }
1580
+ }
1581
+
1582
+ // draw scale on Y-axis
1583
+ const drawScaleY = channelTop => {
1584
+ const scaleWidth = canvasX.height,
1585
+ fontSize = scaleWidth >> 1,
1586
+ max = isLinear ? 100 : maxdB,
1587
+ min = isLinear ? 0 : mindB,
1588
+ incr = isLinear ? 20 : 5,
1589
+ interval = analyzerHeight / ( max - min );
1590
+
1591
+ ctx.save();
1592
+ ctx.fillStyle = SCALEY_LABEL_COLOR;
1593
+ ctx.font = `${fontSize}px ${FONT_FAMILY}`;
1594
+ ctx.textAlign = 'right';
1595
+ ctx.lineWidth = 1;
1596
+
1597
+ for ( let val = max; val > min; val -= incr ) {
1598
+ const posY = channelTop + ( max - val ) * interval,
1599
+ even = ( val % 2 == 0 ) | 0;
1600
+
1601
+ if ( even ) {
1602
+ const labelY = posY + fontSize * ( posY == channelTop ? .8 : .35 );
1603
+ if ( mirrorMode != -1 )
1604
+ ctx.fillText( val, scaleWidth * .85, labelY );
1605
+ if ( mirrorMode != 1 )
1606
+ ctx.fillText( val, canvas.width - scaleWidth * .1, labelY );
1607
+ ctx.strokeStyle = SCALEY_LABEL_COLOR;
1608
+ ctx.setLineDash([2,4]);
1609
+ ctx.lineDashOffset = 0;
1610
+ }
1611
+ else {
1612
+ ctx.strokeStyle = SCALEY_MIDLINE_COLOR;
1613
+ ctx.setLineDash([2,8]);
1614
+ ctx.lineDashOffset = 1;
1615
+ }
1616
+
1617
+ ctx.beginPath();
1618
+ ctx.moveTo( initialX + scaleWidth * even * ( mirrorMode != -1 ), ~~posY + .5 ); // for sharp 1px line (https://stackoverflow.com/a/13879402/2370385)
1619
+ ctx.lineTo( finalX - scaleWidth * even * ( mirrorMode != 1 ), ~~posY + .5 );
1620
+ ctx.stroke();
1621
+ }
1622
+ ctx.restore();
1623
+ }
1624
+
1625
+ // returns the gain (in dB) for a given frequency, considering the currently selected weighting filter
1396
1626
  const weightingdB = freq => {
1397
1627
  const f2 = freq ** 2,
1398
1628
  SQ20_6 = 424.36,
@@ -1430,6 +1660,15 @@ export default class AudioMotionAnalyzer {
1430
1660
  return 0; // unknown filter
1431
1661
  }
1432
1662
 
1663
+ // draws (stroke) a bar from x,y1 to x,y2
1664
+ const strokeBar = ( x, y1, y2 ) => {
1665
+ ctx.beginPath();
1666
+ ctx.moveTo( x, y1 );
1667
+ ctx.lineTo( x, y2 );
1668
+ ctx.stroke();
1669
+ }
1670
+
1671
+ // conditionally strokes current path on canvas
1433
1672
  const strokeIf = flag => {
1434
1673
  if ( flag && lineWidth ) {
1435
1674
  const alpha = ctx.globalAlpha;
@@ -1439,52 +1678,107 @@ export default class AudioMotionAnalyzer {
1439
1678
  }
1440
1679
  }
1441
1680
 
1442
- // helper function - convert planar X,Y coordinates to radial coordinates
1681
+ // converts a given X-coordinate to its corresponding angle in radial mode
1682
+ const getAngle = ( x, dir ) => dir * TAU * ( x / canvas.width ) + this._spinAngle;
1683
+
1684
+ // converts planar X,Y coordinates to radial coordinates
1443
1685
  const radialXY = ( x, y, dir ) => {
1444
1686
  const height = radius + y,
1445
- angle = dir * TAU * ( x / canvas.width ) + this._spinAngle;
1446
-
1687
+ angle = getAngle( x, dir );
1447
1688
  return [ centerX + height * Math.cos( angle ), centerY + height * Math.sin( angle ) ];
1448
1689
  }
1449
1690
 
1450
- // helper function - draw a polygon of width `w` and height `h` at (x,y) in radial mode
1691
+ // draws a polygon of width `w` and height `h` at (x,y) in radial mode
1451
1692
  const radialPoly = ( x, y, w, h, stroke ) => {
1452
1693
  ctx.beginPath();
1453
1694
  for ( const dir of ( mirrorMode ? [1,-1] : [1] ) ) {
1695
+ const [ startAngle, endAngle ] = isRound ? [ getAngle( x, dir ), getAngle( x + w, dir ) ] : [];
1454
1696
  ctx.moveTo( ...radialXY( x, y, dir ) );
1455
1697
  ctx.lineTo( ...radialXY( x, y + h, dir ) );
1456
- ctx.lineTo( ...radialXY( x + w, y + h, dir ) );
1698
+ if ( isRound )
1699
+ ctx.arc( centerX, centerY, radius + y + h, startAngle, endAngle, dir != 1 );
1700
+ else
1701
+ ctx.lineTo( ...radialXY( x + w, y + h, dir ) );
1457
1702
  ctx.lineTo( ...radialXY( x + w, y, dir ) );
1703
+ if ( isRound && ! stroke ) // close the bottom line only when not in outline mode
1704
+ ctx.arc( centerX, centerY, radius + y, endAngle, startAngle, dir == 1 );
1458
1705
  }
1459
-
1460
1706
  strokeIf( stroke );
1461
1707
  ctx.fill();
1462
1708
  }
1463
1709
 
1464
- // LED attributes and helper function for bar height calculation
1465
- const [ ledCount, ledSpaceH, ledSpaceV, ledHeight ] = this._leds || [];
1466
- const ledPosY = height => Math.max( 0, ( height * ledCount | 0 ) * ( ledHeight + ledSpaceV ) - ledSpaceV );
1710
+ // converts a value in [0;1] range to a height in pixels that fits into the current LED elements
1711
+ const ledPosY = value => Math.max( 0, ( value * ledCount | 0 ) * ( ledHeight + ledSpaceV ) - ledSpaceV );
1467
1712
 
1468
- // compute the effective bar width, considering the selected bar spacing
1469
- // if led effect is active, ensure at least the spacing from led definitions
1470
- let width = this._barWidth - ( ! isBandsMode ? 0 : Math.max( isLedDisplay ? ledSpaceH : 0, barSpacePx ) );
1713
+ // update energy information
1714
+ const updateEnergy = newVal => {
1715
+ energy.val = newVal;
1716
+ if ( newVal >= energy.peak ) {
1717
+ energy.peak = newVal;
1718
+ energy.hold = 30;
1719
+ }
1720
+ else {
1721
+ if ( energy.hold > 0 )
1722
+ energy.hold--;
1723
+ else if ( energy.peak > 0 )
1724
+ energy.peak *= ( 30 + energy.hold-- ) / 30; // decay (drops to zero in 30 frames)
1725
+ }
1726
+ }
1471
1727
 
1472
- // make sure width is integer for pixel accurate calculation, when no bar spacing is required
1473
- if ( barSpace == 0 && ! isLedDisplay )
1474
- width |= 0;
1728
+ // calculate and display (if enabled) the current frame rate
1729
+ const updateFPS = () => {
1730
+ this._frame++;
1731
+ const elapsed = timestamp - this._time;
1732
+
1733
+ if ( elapsed >= 1000 ) {
1734
+ this._fps = this._frame / ( elapsed / 1000 );
1735
+ this._frame = 0;
1736
+ this._time = timestamp;
1737
+ }
1738
+ if ( this.showFPS ) {
1739
+ const size = canvasX.height;
1740
+ ctx.font = `bold ${size}px ${FONT_FAMILY}`;
1741
+ ctx.fillStyle = FPS_COLOR;
1742
+ ctx.textAlign = 'right';
1743
+ ctx.fillText( Math.round( this._fps ), canvas.width - size, size * 2 );
1744
+ }
1745
+ }
1746
+
1747
+ /* MAIN FUNCTION */
1475
1748
 
1476
1749
  let currentEnergy = 0;
1477
1750
 
1478
- const nBars = this._bars.length,
1751
+ const bars = this._bars,
1752
+ nBars = bars.length,
1479
1753
  nChannels = channelLayout == CHANNEL_SINGLE ? 1 : 2;
1480
1754
 
1481
1755
  for ( let channel = 0; channel < nChannels; channel++ ) {
1482
1756
 
1483
- const channelTop = channelLayout == CHANNEL_VERTICAL ? channelHeight * channel + channelGap * channel : 0,
1484
- channelBottom = channelTop + channelHeight,
1485
- analyzerBottom = channelTop + analyzerHeight - ( isLedDisplay && ! this._maximizeLeds ? ledSpaceV : 0 ),
1486
- bgColor = ( ! showBgColor || isLedDisplay && ! isOverlay ) ? '#000' : this._gradients[ this._selectedGrads[ channel ] ].bgColor,
1487
- mustClear = channel == 0 || ! isRadial && channelLayout != CHANNEL_COMBINED;
1757
+ const { channelTop, channelBottom, analyzerBottom } = channelCoords[ channel ],
1758
+ channelGradient = this._gradients[ this._selectedGrads[ channel ] ],
1759
+ colorStops = channelGradient.colorStops,
1760
+ colorCount = colorStops.length,
1761
+ bgColor = ( ! showBgColor || isLeds && ! isOverlay ) ? '#000' : channelGradient.bgColor,
1762
+ mustClear = channel == 0 || ! isRadial && channelLayout != CHANNEL_COMBINED;
1763
+
1764
+ // helper function for FFT data interpolation (uses fftData)
1765
+ const interpolate = ( bin, ratio ) => {
1766
+ const value = fftData[ bin ] + ( bin < fftData.length - 1 ? ( fftData[ bin + 1 ] - fftData[ bin ] ) * ratio : 0 );
1767
+ return isNaN( value ) ? -Infinity : value;
1768
+ }
1769
+
1770
+ // set fillStyle and strokeStyle according to current colorMode (uses: channel, colorStops, colorCount)
1771
+ const setBarColor = ( value = 0, barIndex = 0 ) => {
1772
+ let color;
1773
+ // for mode 10, always use the channel gradient (ignore colorMode)
1774
+ if ( ( colorMode == COLOR_GRADIENT && ! isTrueLeds ) || mode == 10 )
1775
+ color = canvasGradients[ channel ];
1776
+ else {
1777
+ const selectedIndex = colorMode == COLOR_BAR_INDEX ? barIndex % colorCount : colorStops.findLastIndex( item => isLeds ? ledPosY( value ) <= ledPosY( item.level ) : value <= item.level );
1778
+ color = colorStops[ selectedIndex ].color;
1779
+ }
1780
+ ctx.fillStyle = ctx.strokeStyle = color;
1781
+ }
1488
1782
 
1489
1783
  if ( useCanvas ) {
1490
1784
  // clear the channel area, if in overlay mode
@@ -1506,60 +1800,25 @@ export default class AudioMotionAnalyzer {
1506
1800
  ctx.globalAlpha = 1;
1507
1801
  }
1508
1802
 
1509
- // draw dB scale (Y-axis)
1510
- if ( this.showScaleY && ! isLumiBars && ! isRadial ) {
1511
- const scaleWidth = canvasX.height,
1512
- fontSize = scaleWidth >> 1,
1513
- max = isLinear ? 100 : maxdB,
1514
- min = isLinear ? 0 : mindB,
1515
- incr = isLinear ? 20 : 5,
1516
- interval = analyzerHeight / ( max - min );
1517
-
1518
- ctx.fillStyle = SCALEY_LABEL_COLOR;
1519
- ctx.font = `${fontSize}px ${FONT_FAMILY}`;
1520
- ctx.textAlign = 'right';
1521
- ctx.lineWidth = 1;
1522
-
1523
- for ( let val = max; val > min; val -= incr ) {
1524
- const posY = channelTop + ( max - val ) * interval,
1525
- even = ( val % 2 == 0 ) | 0;
1526
-
1527
- if ( even ) {
1528
- const labelY = posY + fontSize * ( posY == channelTop ? .8 : .35 );
1529
- if ( mirrorMode != -1 )
1530
- ctx.fillText( val, scaleWidth * .85, labelY );
1531
- if ( mirrorMode != 1 )
1532
- ctx.fillText( val, canvas.width - scaleWidth * .1, labelY );
1533
- ctx.strokeStyle = SCALEY_LABEL_COLOR;
1534
- ctx.setLineDash([2,4]);
1535
- ctx.lineDashOffset = 0;
1536
- }
1537
- else {
1538
- ctx.strokeStyle = SCALEY_MIDLINE_COLOR;
1539
- ctx.setLineDash([2,8]);
1540
- ctx.lineDashOffset = 1;
1541
- }
1542
-
1543
- ctx.beginPath();
1544
- ctx.moveTo( initialX + scaleWidth * even * ( mirrorMode != -1 ), ~~posY + .5 ); // for sharp 1px line (https://stackoverflow.com/a/13879402/2370385)
1545
- ctx.lineTo( finalX - scaleWidth * even * ( mirrorMode != 1 ), ~~posY + .5 );
1546
- ctx.stroke();
1547
- }
1548
- // restore line properties
1549
- ctx.setLineDash([]);
1550
- ctx.lineDashOffset = 0;
1551
- }
1803
+ // draw dB scale (Y-axis) - avoid drawing it twice on 'dual-combined' channel layout
1804
+ if ( this.showScaleY && ! isLumi && ! isRadial && ( channel == 0 || channelLayout != CHANNEL_COMBINED ) )
1805
+ drawScaleY( channelTop );
1552
1806
 
1553
1807
  // set line width and dash for LEDs effect
1554
- if ( isLedDisplay ) {
1808
+ if ( isLeds ) {
1555
1809
  ctx.setLineDash( [ ledHeight, ledSpaceV ] );
1556
- ctx.lineWidth = width;
1810
+ ctx.lineWidth = bars[0].width;
1557
1811
  }
1558
1812
  else // for outline effect ensure linewidth is not greater than half the bar width
1559
- ctx.lineWidth = isOutline ? Math.min( lineWidth, width / 2 ) : lineWidth;
1560
-
1561
- // set selected gradient for fill and stroke
1562
- ctx.fillStyle = ctx.strokeStyle = canvasGradients[ channel ];
1813
+ ctx.lineWidth = isOutline ? Math.min( lineWidth, bars[0].width / 2 ) : lineWidth;
1814
+
1815
+ // set clip region
1816
+ ctx.save();
1817
+ if ( ! isRadial ) {
1818
+ const channelRegion = new Path2D();
1819
+ channelRegion.rect( 0, channelTop, canvas.width, analyzerHeight );
1820
+ ctx.clip( channelRegion );
1821
+ }
1563
1822
  } // if ( useCanvas )
1564
1823
 
1565
1824
  // get a new array of data from the FFT
@@ -1570,12 +1829,6 @@ export default class AudioMotionAnalyzer {
1570
1829
  if ( weightingFilter )
1571
1830
  fftData = fftData.map( ( val, idx ) => val + weightingdB( this._binToFreq( idx ) ) );
1572
1831
 
1573
- // helper function for FFT data interpolation
1574
- const interpolate = ( bin, ratio ) => {
1575
- const value = fftData[ bin ] + ( bin < fftData.length - 1 ? ( fftData[ bin + 1 ] - fftData[ bin ] ) * ratio : 0 );
1576
- return isNaN( value ) ? -Infinity : value;
1577
- }
1578
-
1579
1832
  // start drawing path (for mode 10)
1580
1833
  ctx.beginPath();
1581
1834
 
@@ -1584,24 +1837,24 @@ export default class AudioMotionAnalyzer {
1584
1837
 
1585
1838
  // draw bars / lines
1586
1839
 
1587
- for ( let i = 0; i < nBars; i++ ) {
1840
+ for ( let barIndex = 0; barIndex < nBars; barIndex++ ) {
1588
1841
 
1589
- const bar = this._bars[ i ],
1590
- { freq, binLo, binHi, ratioLo, ratioHi } = bar;
1842
+ const bar = bars[ barIndex ],
1843
+ { posX, barCenter, width, freq, binLo, binHi, ratioLo, ratioHi } = bar;
1591
1844
 
1592
- let barHeight = Math.max( interpolate( binLo, ratioLo ), interpolate( binHi, ratioHi ) );
1845
+ let barValue = Math.max( interpolate( binLo, ratioLo ), interpolate( binHi, ratioHi ) );
1593
1846
 
1594
1847
  // check additional bins (if any) for this bar and keep the highest value
1595
1848
  for ( let j = binLo + 1; j < binHi; j++ ) {
1596
- if ( fftData[ j ] > barHeight )
1597
- barHeight = fftData[ j ];
1849
+ if ( fftData[ j ] > barValue )
1850
+ barValue = fftData[ j ];
1598
1851
  }
1599
1852
 
1600
1853
  // normalize bar amplitude in [0;1] range
1601
- barHeight = this._normalizedB( barHeight );
1854
+ barValue = this._normalizedB( barValue );
1602
1855
 
1603
- bar.value[ channel ] = barHeight;
1604
- currentEnergy += barHeight;
1856
+ bar.value[ channel ] = barValue;
1857
+ currentEnergy += barValue;
1605
1858
 
1606
1859
  // update bar peak
1607
1860
  if ( bar.peak[ channel ] > 0 ) {
@@ -1612,8 +1865,8 @@ export default class AudioMotionAnalyzer {
1612
1865
  }
1613
1866
 
1614
1867
  // check if it's a new peak for this bar
1615
- if ( barHeight >= bar.peak[ channel ] ) {
1616
- bar.peak[ channel ] = barHeight;
1868
+ if ( barValue >= bar.peak[ channel ] ) {
1869
+ bar.peak[ channel ] = barValue;
1617
1870
  bar.hold[ channel ] = 30; // set peak hold time to 30 frames (0.5s)
1618
1871
  }
1619
1872
 
@@ -1622,31 +1875,30 @@ export default class AudioMotionAnalyzer {
1622
1875
  continue;
1623
1876
 
1624
1877
  // set opacity for bar effects
1625
- if ( isLumiBars || isAlphaBars )
1626
- ctx.globalAlpha = barHeight;
1878
+ if ( isLumi || isAlpha )
1879
+ ctx.globalAlpha = barValue;
1627
1880
  else if ( isOutline )
1628
1881
  ctx.globalAlpha = fillAlpha;
1629
1882
 
1883
+ // set fillStyle and strokeStyle for the current bar
1884
+ setBarColor( barValue, barIndex );
1885
+
1630
1886
  // compute actual bar height on screen
1631
- barHeight = isLedDisplay ? ledPosY( barHeight ) : barHeight * maxBarHeight | 0;
1887
+ let barHeight = isLumi ? maxBarHeight : isLeds ? ledPosY( barValue ) : barValue * maxBarHeight | 0;
1632
1888
 
1633
1889
  // invert bar for radial channel 1
1634
1890
  if ( isRadial && channel == 1 && channelLayout == CHANNEL_VERTICAL )
1635
1891
  barHeight *= -1;
1636
1892
 
1637
- // bar width may need small adjustments for some bars, when barSpace == 0
1638
- let adjWidth = width,
1639
- posX = bar.posX;
1640
-
1641
1893
  // Draw current bar or line segment
1642
1894
 
1643
1895
  if ( mode == 10 ) {
1644
- // compute the average between the initial bar (i==0) and the next one
1896
+ // compute the average between the initial bar (barIndex==0) and the next one
1645
1897
  // used to smooth the curve when the initial posX is off the screen, in mirror and radial modes
1646
- const nextBarAvg = i ? 0 : ( this._normalizedB( fftData[ this._bars[1].binLo ] ) * maxBarHeight * ( channel && isRadial && channelLayout == CHANNEL_VERTICAL ? -1 : 1 ) + barHeight ) / 2;
1898
+ const nextBarAvg = barIndex ? 0 : ( this._normalizedB( fftData[ bars[1].binLo ] ) * maxBarHeight * ( channel && isRadial && channelLayout == CHANNEL_VERTICAL ? -1 : 1 ) + barHeight ) / 2;
1647
1899
 
1648
1900
  if ( isRadial ) {
1649
- if ( i == 0 )
1901
+ if ( barIndex == 0 )
1650
1902
  ctx.lineTo( ...radialXY( 0, ( posX < 0 ? nextBarAvg : barHeight ), 1 ) );
1651
1903
  // draw line to the current point, avoiding overlapping wrap-around frequencies
1652
1904
  if ( posX >= 0 ) {
@@ -1656,7 +1908,7 @@ export default class AudioMotionAnalyzer {
1656
1908
  }
1657
1909
  }
1658
1910
  else { // Linear
1659
- if ( i == 0 ) {
1911
+ if ( barIndex == 0 ) {
1660
1912
  // start the line off-screen using the previous FFT bin value as the initial amplitude
1661
1913
  if ( mirrorMode != -1 ) {
1662
1914
  const prevFFTData = binLo ? this._normalizedB( fftData[ binLo - 1 ] ) * maxBarHeight : barHeight; // use previous FFT bin value, when available
@@ -1672,57 +1924,50 @@ export default class AudioMotionAnalyzer {
1672
1924
  }
1673
1925
  }
1674
1926
  else {
1675
- if ( mode > 0 ) {
1676
- if ( isLedDisplay )
1677
- posX += Math.max( ledSpaceH / 2, barSpacePx / 2 );
1678
- else {
1679
- if ( barSpace == 0 ) {
1680
- posX |= 0;
1681
- if ( i > 0 && posX > this._bars[ i - 1 ].posX + width ) {
1682
- posX--;
1683
- adjWidth++;
1684
- }
1685
- }
1686
- else
1687
- posX += barSpacePx / 2;
1688
- }
1689
- }
1690
-
1691
- if ( isLedDisplay ) {
1692
- const x = posX + width / 2;
1693
- // draw "unlit" leds
1694
- if ( showBgColor && ! isOverlay ) {
1927
+ if ( isLeds ) {
1928
+ // draw "unlit" leds - avoid drawing it twice on 'dual-combined' channel layout
1929
+ if ( showBgColor && ! isOverlay && ( channel == 0 || channelLayout != CHANNEL_COMBINED ) ) {
1695
1930
  const alpha = ctx.globalAlpha;
1696
- ctx.beginPath();
1697
- ctx.moveTo( x, channelTop );
1698
- ctx.lineTo( x, analyzerBottom );
1699
1931
  ctx.strokeStyle = LEDS_UNLIT_COLOR;
1700
1932
  ctx.globalAlpha = 1;
1701
- ctx.stroke();
1933
+ strokeBar( barCenter, channelTop, analyzerBottom );
1702
1934
  // restore properties
1703
1935
  ctx.strokeStyle = ctx.fillStyle;
1704
1936
  ctx.globalAlpha = alpha;
1705
1937
  }
1706
- ctx.beginPath();
1707
- ctx.moveTo( x, isLumiBars ? channelTop : analyzerBottom );
1708
- ctx.lineTo( x, isLumiBars ? channelBottom : analyzerBottom - barHeight );
1709
- ctx.stroke();
1938
+ if ( isTrueLeds ) {
1939
+ // ledPosY() is used below to fit one entire led height into the selected range
1940
+ const colorIndex = isLumi ? 0 : colorStops.findLastIndex( item => ledPosY( barValue ) <= ledPosY( item.level ) );
1941
+ let last = analyzerBottom;
1942
+ for ( let i = colorCount - 1; i >= colorIndex; i-- ) {
1943
+ ctx.strokeStyle = colorStops[ i ].color;
1944
+ let y = analyzerBottom - ( i == colorIndex ? barHeight : ledPosY( colorStops[ i ].level ) );
1945
+ strokeBar( barCenter, last, y );
1946
+ last = y - ledSpaceV;
1947
+ }
1948
+ }
1949
+ else
1950
+ strokeBar( barCenter, analyzerBottom, analyzerBottom - barHeight );
1710
1951
  }
1711
1952
  else if ( posX >= initialX ) {
1712
1953
  if ( isRadial )
1713
- radialPoly( posX, 0, adjWidth, barHeight, isOutline );
1714
- else {
1715
- const x = posX,
1716
- y = isLumiBars ? channelTop : analyzerBottom,
1717
- w = adjWidth,
1718
- h = isLumiBars ? channelBottom : -barHeight;
1954
+ radialPoly( posX, 0, width, barHeight, isOutline );
1955
+ else if ( isRound ) {
1956
+ const halfWidth = width / 2,
1957
+ y = analyzerBottom + halfWidth; // round caps have an additional height of half bar width
1719
1958
 
1720
1959
  ctx.beginPath();
1721
- ctx.moveTo( x, y );
1722
- ctx.lineTo( x, y + h );
1723
- ctx.lineTo( x + w, y + h );
1724
- ctx.lineTo( x + w, y );
1725
-
1960
+ ctx.moveTo( posX, y );
1961
+ ctx.lineTo( posX, y - barHeight );
1962
+ ctx.arc( barCenter, y - barHeight, halfWidth, Math.PI, TAU );
1963
+ ctx.lineTo( posX + width, y );
1964
+ strokeIf( isOutline );
1965
+ ctx.fill();
1966
+ }
1967
+ else {
1968
+ const offset = isOutline ? ctx.lineWidth : 0;
1969
+ ctx.beginPath();
1970
+ ctx.rect( posX, analyzerBottom + offset, width, -barHeight - offset );
1726
1971
  strokeIf( isOutline );
1727
1972
  ctx.fill();
1728
1973
  }
@@ -1731,36 +1976,44 @@ export default class AudioMotionAnalyzer {
1731
1976
 
1732
1977
  // Draw peak
1733
1978
  const peak = bar.peak[ channel ];
1734
- if ( peak > 0 && this.showPeaks && ! isLumiBars && posX >= initialX && posX < finalX ) {
1735
- // choose the best opacity for the peaks
1979
+ if ( peak > 0 && this.showPeaks && ! isLumi && posX >= initialX && posX < finalX ) {
1980
+ // set opacity
1736
1981
  if ( isOutline && lineWidth > 0 )
1737
1982
  ctx.globalAlpha = 1;
1738
- else if ( isAlphaBars )
1983
+ else if ( isAlpha )
1739
1984
  ctx.globalAlpha = peak;
1740
1985
 
1986
+ // select the peak color for 'bar-level' colorMode or 'trueLeds'
1987
+ if ( colorMode == COLOR_BAR_LEVEL || isTrueLeds )
1988
+ setBarColor( peak );
1989
+
1741
1990
  // render peak according to current mode / effect
1742
- if ( isLedDisplay ) {
1991
+ if ( isLeds ) {
1743
1992
  const ledPeak = ledPosY( peak );
1744
- if ( ledPeak >= ledHeight ) // avoid peak below zero level
1993
+ if ( ledPeak >= ledSpaceV ) // avoid peak below first led
1745
1994
  ctx.fillRect( posX, analyzerBottom - ledPeak, width, ledHeight );
1746
1995
  }
1747
1996
  else if ( ! isRadial )
1748
- ctx.fillRect( posX, analyzerBottom - peak * maxBarHeight, adjWidth, 2 );
1997
+ ctx.fillRect( posX, analyzerBottom - peak * maxBarHeight, width, 2 );
1749
1998
  else if ( mode != 10 ) // radial - no peaks for mode 10
1750
- radialPoly( posX, peak * maxBarHeight * ( channel && channelLayout == CHANNEL_VERTICAL ? -1 : 1 ), adjWidth, -2 );
1999
+ radialPoly( posX, peak * maxBarHeight * ( channel && channelLayout == CHANNEL_VERTICAL ? -1 : 1 ), width, -2 );
1751
2000
  }
1752
2001
 
1753
- } // for ( let i = 0; i < nBars; i++ )
2002
+ } // for ( let barIndex = 0; barIndex < nBars; barIndex++ )
1754
2003
 
1755
2004
  // if not using the canvas, move earlier to the next channel
1756
2005
  if ( ! useCanvas )
1757
2006
  continue;
1758
2007
 
2008
+ ctx.restore(); // restore clip region
2009
+
1759
2010
  // restore global alpha
1760
2011
  ctx.globalAlpha = 1;
1761
2012
 
1762
2013
  // Fill/stroke drawing path for mode 10
1763
2014
  if ( mode == 10 ) {
2015
+ setBarColor(); // select channel gradient
2016
+
1764
2017
  if ( isRadial ) {
1765
2018
  if ( mirrorMode ) {
1766
2019
  let p;
@@ -1790,47 +2043,12 @@ export default class AudioMotionAnalyzer {
1790
2043
  }
1791
2044
  }
1792
2045
 
1793
- // Reflex effect
1794
- if ( this._reflexRatio > 0 && ! isLumiBars ) {
1795
- let posY, height;
1796
- if ( this.reflexFit || channelLayout == CHANNEL_VERTICAL ) { // always fit reflex in vertical stereo mode
1797
- posY = channelLayout == CHANNEL_VERTICAL && channel == 0 ? channelHeight + channelGap : 0;
1798
- height = channelHeight - analyzerHeight;
1799
- }
1800
- else {
1801
- posY = canvas.height - analyzerHeight * 2;
1802
- height = analyzerHeight;
1803
- }
1804
-
1805
- // set alpha and brightness for the reflection
1806
- ctx.globalAlpha = this.reflexAlpha;
1807
- if ( this.reflexBright != 1 )
1808
- ctx.filter = `brightness(${this.reflexBright})`;
1809
-
1810
- // create the reflection
1811
- ctx.setTransform( 1, 0, 0, -1, 0, canvas.height );
1812
- ctx.drawImage( canvas, 0, channelTop, canvas.width, analyzerHeight, 0, posY, canvas.width, height );
1813
-
1814
- // reset changed properties
1815
- ctx.setTransform( 1, 0, 0, 1, 0, 0 );
1816
- ctx.filter = 'none';
1817
- ctx.globalAlpha = 1;
1818
- }
2046
+ // create Reflex effect
2047
+ doReflex( channel );
1819
2048
 
1820
2049
  } // for ( let channel = 0; channel < nChannels; channel++ ) {
1821
2050
 
1822
- // Update energy
1823
- energy.val = currentEnergy / ( nBars << ( nChannels - 1 ) );
1824
- if ( energy.val >= energy.peak ) {
1825
- energy.peak = energy.val;
1826
- energy.hold = 30;
1827
- }
1828
- else {
1829
- if ( energy.hold > 0 )
1830
- energy.hold--;
1831
- else if ( energy.peak > 0 )
1832
- energy.peak *= ( 30 + energy.hold-- ) / 30; // decay (drops to zero in 30 frames)
1833
- }
2051
+ updateEnergy( currentEnergy / ( nBars << ( nChannels - 1 ) ) );
1834
2052
 
1835
2053
  if ( useCanvas ) {
1836
2054
  // Mirror effect
@@ -1844,37 +2062,11 @@ export default class AudioMotionAnalyzer {
1844
2062
  ctx.setLineDash([]);
1845
2063
 
1846
2064
  // draw frequency scale (X-axis)
1847
- if ( this.showScaleX ) {
1848
- if ( isRadial ) {
1849
- ctx.save();
1850
- ctx.translate( centerX, centerY );
1851
- if ( this._spinSpeed )
1852
- ctx.rotate( this._spinAngle + HALF_PI );
1853
- ctx.drawImage( canvasR, -canvasR.width >> 1, -canvasR.width >> 1 );
1854
- ctx.restore();
1855
- }
1856
- else
1857
- ctx.drawImage( canvasX, 0, canvas.height - canvasX.height );
1858
- }
2065
+ drawScaleX();
1859
2066
  }
1860
2067
 
1861
- // calculate and update current frame rate
1862
-
1863
- this._frame++;
1864
- const elapsed = timestamp - this._time;
1865
-
1866
- if ( elapsed >= 1000 ) {
1867
- this._fps = this._frame / ( elapsed / 1000 );
1868
- this._frame = 0;
1869
- this._time = timestamp;
1870
- }
1871
- if ( this.showFPS ) {
1872
- const size = canvasX.height;
1873
- ctx.font = `bold ${size}px ${FONT_FAMILY}`;
1874
- ctx.fillStyle = FPS_COLOR;
1875
- ctx.textAlign = 'right';
1876
- ctx.fillText( Math.round( this._fps ), canvas.width - size, size * 2 );
1877
- }
2068
+ // calculate and display (if enabled) the current frame rate
2069
+ updateFPS();
1878
2070
 
1879
2071
  // call callback function, if defined
1880
2072
  if ( this.onCanvasDraw ) {
@@ -1925,18 +2117,17 @@ export default class AudioMotionAnalyzer {
1925
2117
  const ctx = this._canvasCtx,
1926
2118
  canvas = ctx.canvas,
1927
2119
  channelLayout = this._chLayout,
1928
- isLumiBars = this._isLumiBars,
2120
+ { isLumi } = this._flg,
1929
2121
  isRadial = this._radial,
1930
- gradientHeight = isLumiBars ? canvas.height : canvas.height * ( 1 - this._reflexRatio * ( channelLayout != CHANNEL_VERTICAL ) ) | 0,
2122
+ gradientHeight = isLumi ? canvas.height : canvas.height * ( 1 - this._reflexRatio * ( channelLayout != CHANNEL_VERTICAL ) ) | 0,
1931
2123
  // for vertical stereo we keep the full canvas height and handle the reflex areas while generating the color stops
1932
2124
  analyzerRatio = 1 - this._reflexRatio,
1933
- initialX = this._initialX;
2125
+ { analyzerWidth, initialX, radius } = this._aux;
1934
2126
 
1935
2127
  // for radial mode
1936
2128
  const centerX = canvas.width >> 1,
1937
2129
  centerY = canvas.height >> 1,
1938
- maxRadius = Math.min( centerX, centerY ),
1939
- radius = this._radius;
2130
+ maxRadius = Math.min( centerX, centerY );
1940
2131
 
1941
2132
  for ( const channel of [0,1] ) {
1942
2133
  const currGradient = this._gradients[ this._selectedGrads[ channel ] ],
@@ -1948,26 +2139,23 @@ export default class AudioMotionAnalyzer {
1948
2139
  if ( isRadial )
1949
2140
  grad = ctx.createRadialGradient( centerX, centerY, maxRadius, centerX, centerY, radius - ( maxRadius - radius ) * ( channelLayout == CHANNEL_VERTICAL ) );
1950
2141
  else
1951
- grad = ctx.createLinearGradient( ...( isHorizontal ? [ initialX, 0, initialX + this._analyzerWidth, 0 ] : [ 0, 0, 0, gradientHeight ] ) );
2142
+ grad = ctx.createLinearGradient( ...( isHorizontal ? [ initialX, 0, initialX + analyzerWidth, 0 ] : [ 0, 0, 0, gradientHeight ] ) );
1952
2143
 
1953
2144
  if ( colorStops ) {
1954
2145
  const dual = channelLayout == CHANNEL_VERTICAL && ! this._splitGradient && ( ! isHorizontal || isRadial );
1955
2146
 
1956
- // helper function
1957
- const addColorStop = ( offset, colorInfo ) => grad.addColorStop( offset, colorInfo.color || colorInfo );
1958
-
1959
2147
  for ( let channelArea = 0; channelArea < 1 + dual; channelArea++ ) {
2148
+ const maxIndex = colorStops.length - 1;
1960
2149
 
1961
- colorStops.forEach( ( colorInfo, index ) => {
1962
- const maxIndex = colorStops.length - 1;
1963
- let offset = colorInfo.pos !== undefined ? colorInfo.pos : index / Math.max( 1, maxIndex );
2150
+ colorStops.forEach( ( colorStop, index ) => {
2151
+ let offset = colorStop.pos;
1964
2152
 
1965
2153
  // in dual mode (not split), use half the original offset for each channel
1966
2154
  if ( dual )
1967
2155
  offset /= 2;
1968
2156
 
1969
2157
  // constrain the offset within the useful analyzer areas (avoid reflex areas)
1970
- if ( channelLayout == CHANNEL_VERTICAL && ! isLumiBars && ! isRadial && ! isHorizontal ) {
2158
+ if ( channelLayout == CHANNEL_VERTICAL && ! isLumi && ! isRadial && ! isHorizontal ) {
1971
2159
  offset *= analyzerRatio;
1972
2160
  // skip the first reflex area in split mode
1973
2161
  if ( ! dual && offset > .5 * analyzerRatio )
@@ -1977,26 +2165,26 @@ export default class AudioMotionAnalyzer {
1977
2165
  // only for dual-vertical non-split gradient (creates full gradient on both halves of the canvas)
1978
2166
  if ( channelArea == 1 ) {
1979
2167
  // add colors in reverse order if radial or lumi are active
1980
- if ( isRadial || isLumiBars ) {
2168
+ if ( isRadial || isLumi ) {
1981
2169
  const revIndex = maxIndex - index;
1982
- colorInfo = colorStops[ revIndex ];
1983
- offset = 1 - ( colorInfo.pos !== undefined ? colorInfo.pos : revIndex / Math.max( 1, maxIndex ) ) / 2;
2170
+ colorStop = colorStops[ revIndex ];
2171
+ offset = 1 - colorStop.pos / 2;
1984
2172
  }
1985
2173
  else {
1986
2174
  // if the first offset is not 0, create an additional color stop to prevent bleeding from the first channel
1987
2175
  if ( index == 0 && offset > 0 )
1988
- addColorStop( .5, colorInfo );
2176
+ grad.addColorStop( .5, colorStop.color );
1989
2177
  // bump the offset to the second half of the gradient
1990
2178
  offset += .5;
1991
2179
  }
1992
2180
  }
1993
2181
 
1994
2182
  // add gradient color stop
1995
- addColorStop( offset, colorInfo );
2183
+ grad.addColorStop( offset, colorStop.color );
1996
2184
 
1997
2185
  // create additional color stop at the end of first channel to prevent bleeding
1998
2186
  if ( channelLayout == CHANNEL_VERTICAL && index == maxIndex && offset < .5 )
1999
- addColorStop( .5, colorInfo );
2187
+ grad.addColorStop( .5, colorStop.color );
2000
2188
  });
2001
2189
  } // for ( let channelArea = 0; channelArea < 1 + dual; channelArea++ )
2002
2190
  }
@@ -2064,9 +2252,6 @@ export default class AudioMotionAnalyzer {
2064
2252
  canvas.width = newWidth;
2065
2253
  canvas.height = newHeight;
2066
2254
 
2067
- // update internal variables
2068
- this._calcAux();
2069
-
2070
2255
  // if not in overlay mode, paint the canvas black
2071
2256
  if ( ! this.overlay ) {
2072
2257
  ctx.fillStyle = '#000';
@@ -2078,14 +2263,14 @@ export default class AudioMotionAnalyzer {
2078
2263
 
2079
2264
  // update dimensions of the scale canvas
2080
2265
  canvasX.width = newWidth;
2081
- canvasX.height = Math.max( 20 * pixelRatio, Math.min( newWidth, newHeight ) / 27 | 0 );
2082
-
2083
- // (re)generate gradient
2084
- this._makeGrad();
2266
+ canvasX.height = Math.max( 20 * pixelRatio, Math.min( newWidth, newHeight ) / 32 | 0 );
2085
2267
 
2086
2268
  // calculate bar positions and led options
2087
2269
  this._calcBars();
2088
2270
 
2271
+ // (re)generate gradient
2272
+ this._makeGrad();
2273
+
2089
2274
  // detect fullscreen changes (for Safari)
2090
2275
  if ( this._fsStatus !== undefined && this._fsStatus !== isFullscreen )
2091
2276
  reason = REASON_FSCHANGE;
@@ -2127,6 +2312,7 @@ export default class AudioMotionAnalyzer {
2127
2312
  barSpace : 0.1,
2128
2313
  bgAlpha : 0.7,
2129
2314
  channelLayout : CHANNEL_SINGLE,
2315
+ colorMode : COLOR_GRADIENT,
2130
2316
  fftSize : 8192,
2131
2317
  fillAlpha : 1,
2132
2318
  frequencyScale : SCALE_LOG,
@@ -2151,6 +2337,7 @@ export default class AudioMotionAnalyzer {
2151
2337
  reflexBright : 1,
2152
2338
  reflexFit : true,
2153
2339
  reflexRatio : 0,
2340
+ roundBars : false,
2154
2341
  showBgColor : true,
2155
2342
  showFPS : false,
2156
2343
  showPeaks : true,
@@ -2160,6 +2347,7 @@ export default class AudioMotionAnalyzer {
2160
2347
  spinSpeed : 0,
2161
2348
  splitGradient : false,
2162
2349
  start : true,
2350
+ trueLeds : false,
2163
2351
  useCanvas : true,
2164
2352
  volume : 1,
2165
2353
  weightingFilter: FILTER_NONE