audiomotion-analyzer 4.2.0 → 4.3.0

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/README.md CHANGED
@@ -333,11 +333,12 @@ Defaults to **0.7**.
333
333
 
334
334
  Defines the number and layout of analyzer channels.
335
335
 
336
- channelLayout | Description
337
- ----------------|------------
338
- 'single' | Single channel analyzer, representing the combined output of both left and right channels.
339
- 'dual-combined' | Dual channel analyzer, with both channel graphs overlaid. Works best with semi-transparent **Graph** [`mode`](#mode-number) or [`outlineBars`](#outlinebars-boolean).
340
- 'dual-vertical' | Left channel shown at the top half of the canvas and right channel at the bottom.
336
+ channelLayout | Description | Note
337
+ ------------------|-------------|------
338
+ 'single' | Single channel analyzer, representing the combined output of both left and right channels.
339
+ 'dual-combined' | Dual channel analyzer, both channels overlaid. Works best with semi-transparent **Graph** [`mode`](#mode-number) or [`outlineBars`](#outlinebars-boolean).
340
+ 'dual-horizontal' | Dual channel, side by side - see [`mirror`](#mirror-number) for additional layout options. | *since v4.3.0*
341
+ 'dual-vertical' | Dual channel, left channel at the top half of the canvas and right channel at the bottom.
341
342
 
342
343
  !> When a *dual* layout is selected, any mono (single channel) audio source connected to the analyzer will output sound only from the left speaker,
343
344
  unless a stereo source is simultaneously connected to the analyzer, which will force the mono input to be upmixed to stereo.
@@ -668,15 +669,19 @@ It is preferable to use the [`setFreqRange()`](#setfreqrange-minfreq-maxfreq-) m
668
669
 
669
670
  *Available since v3.3.0*
670
671
 
671
- Horizontal mirroring effect. Valid values are:
672
+ When [`channelLayout`](#channellayout-string) is **dual-horizontal**, this property controls the orientation of the X-axis (frequencies) on both channels.
672
673
 
673
- mirror | Effect
674
- :-----:|--------
675
- -1 | Mirrors the analyzer to the left (low frequencies at the center of the screen)
676
- 0 | Disables mirror effect (default)
677
- 1 | Mirrors the analyzer to the right (high frequencies at the center of the screen)
674
+ For other layouts, it horizontally mirrors the spectrum image to the left or right side.
678
675
 
679
- **Note:** when [`radial`](#radial-boolean) is **_true_**, both `1` and `-1` will produce the same effect.
676
+ Valid values are:
677
+
678
+ mirror | Description
679
+ :-----:|-------------
680
+ -1 | Low frequencies meet at the center of the screen (mirror left)
681
+ 0 | No mirror effect or change to axis orientation (default)
682
+ 1 | High frequencies meet at the center of the screen (mirror right)
683
+
684
+ **Note:** On [`radial`](#radial-boolean) spectrum with channel layouts other than *dual-horizontal*, both `1` and `-1` have the same effect.
680
685
 
681
686
  Defaults to **0**.
682
687
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "audiomotion-analyzer",
3
3
  "description": "High-resolution real-time graphic audio spectrum analyzer JavaScript module with no dependencies.",
4
- "version": "4.2.0",
4
+ "version": "4.3.0",
5
5
  "main": "./src/audioMotion-analyzer.js",
6
6
  "module": "./src/audioMotion-analyzer.js",
7
7
  "types": "./src/index.d.ts",
@@ -2,20 +2,22 @@
2
2
  * audioMotion-analyzer
3
3
  * High-resolution real-time graphic audio spectrum analyzer JS module
4
4
  *
5
- * @version 4.2.0
5
+ * @version 4.3.0
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.2.0';
10
+ const VERSION = '4.3.0';
11
11
 
12
12
  // internal constants
13
- const TAU = 2 * Math.PI,
14
- HALF_PI = Math.PI / 2,
13
+ const PI = Math.PI,
14
+ TAU = 2 * PI,
15
+ HALF_PI = PI / 2,
15
16
  C_1 = 8.17579892; // frequency for C -1
16
17
 
17
18
  const CANVAS_BACKGROUND_COLOR = '#000',
18
19
  CHANNEL_COMBINED = 'dual-combined',
20
+ CHANNEL_HORIZONTAL = 'dual-horizontal',
19
21
  CHANNEL_SINGLE = 'single',
20
22
  CHANNEL_VERTICAL = 'dual-vertical',
21
23
  COLOR_BAR_INDEX = 'bar-index',
@@ -35,6 +37,7 @@ const CANVAS_BACKGROUND_COLOR = '#000',
35
37
  FONT_FAMILY = 'sans-serif',
36
38
  FPS_COLOR = '#0f0',
37
39
  LEDS_UNLIT_COLOR = '#7f7f7f22',
40
+ MODE_GRAPH = 10,
38
41
  REASON_CREATE = 'create',
39
42
  REASON_FSCHANGE = 'fschange',
40
43
  REASON_LORES = 'lores',
@@ -352,7 +355,7 @@ export default class AudioMotionAnalyzer {
352
355
  return this._chLayout;
353
356
  }
354
357
  set channelLayout( value ) {
355
- this._chLayout = validateFromList( value, [ CHANNEL_SINGLE, CHANNEL_VERTICAL, CHANNEL_COMBINED ] );
358
+ this._chLayout = validateFromList( value, [ CHANNEL_SINGLE, CHANNEL_HORIZONTAL, CHANNEL_VERTICAL, CHANNEL_COMBINED ] );
356
359
 
357
360
  // update node connections
358
361
  this._input.disconnect();
@@ -1112,43 +1115,39 @@ export default class AudioMotionAnalyzer {
1112
1115
  return;
1113
1116
  }
1114
1117
 
1115
- const barSpace = this._barSpace,
1116
- canvas = this.canvas,
1117
- centerX = canvas.width >> 1,
1118
- chLayout = this._chLayout,
1119
- isAnsiBands = this._ansiBands,
1120
- isRadial = this._radial,
1121
- isDual = chLayout == CHANNEL_VERTICAL && ! isRadial,
1122
- maxFreq = this._maxFreq,
1123
- minFreq = this._minFreq,
1124
- mode = this._mode,
1118
+ const { _ansiBands, _barSpace, canvas, _chLayout, _maxFreq, _minFreq, _mirror, _mode, _radial, _reflexRatio } = this,
1119
+ centerX = canvas.width >> 1,
1120
+ centerY = canvas.height >> 1,
1121
+ isDualVertical = _chLayout == CHANNEL_VERTICAL && ! _radial,
1122
+ isDualHorizontal = _chLayout == CHANNEL_HORIZONTAL,
1125
1123
 
1126
1124
  // COMPUTE FLAGS
1127
1125
 
1128
- isBands = mode % 10 != 0,
1126
+ isBands = _mode % 10 != 0, // true for modes 1 to 9
1129
1127
  isOctaves = isBands && this._frequencyScale == SCALE_LOG,
1130
- isLeds = this._showLeds && isBands && ! isRadial,
1131
- isLumi = this._lumiBars && isBands && ! isRadial,
1132
- isAlpha = this._alphaBars && ! isLumi && mode != 10,
1128
+ isLeds = this._showLeds && isBands && ! _radial,
1129
+ isLumi = this._lumiBars && isBands && ! _radial,
1130
+ isAlpha = this._alphaBars && ! isLumi && _mode != MODE_GRAPH,
1133
1131
  isOutline = this._outlineBars && isBands && ! isLumi && ! isLeds,
1134
1132
  isRound = this._roundBars && isBands && ! isLumi && ! isLeds,
1135
- noLedGap = chLayout != CHANNEL_VERTICAL || this._reflexRatio > 0 && ! isLumi,
1133
+ noLedGap = _chLayout != CHANNEL_VERTICAL || _reflexRatio > 0 && ! isLumi,
1136
1134
 
1137
1135
  // COMPUTE AUXILIARY VALUES
1138
1136
 
1139
1137
  // channelHeight is the total canvas height dedicated to each channel, including the reflex area, if any)
1140
- channelHeight = canvas.height - ( isDual && ! isLeds ? .5 : 0 ) >> isDual,
1138
+ channelHeight = canvas.height - ( isDualVertical && ! isLeds ? .5 : 0 ) >> isDualVertical,
1141
1139
  // analyzerHeight is the effective height used to render the analyzer, excluding the reflex area
1142
- analyzerHeight = channelHeight * ( isLumi || isRadial ? 1 : 1 - this._reflexRatio ) | 0,
1140
+ analyzerHeight = channelHeight * ( isLumi || _radial ? 1 : 1 - _reflexRatio ) | 0,
1143
1141
 
1144
- analyzerWidth = canvas.width - centerX * ( this._mirror != 0 ),
1142
+ analyzerWidth = canvas.width - centerX * ( isDualHorizontal || _mirror != 0 ),
1145
1143
 
1146
1144
  // channelGap is **0** if isLedDisplay == true (LEDs already have spacing); **1** if canvas height is odd (windowed); **2** if it's even
1147
1145
  // TODO: improve this, make it configurable?
1148
- channelGap = isDual ? canvas.height - channelHeight * 2 : 0,
1146
+ channelGap = isDualVertical ? canvas.height - channelHeight * 2 : 0,
1149
1147
 
1150
- initialX = centerX * ( this._mirror == -1 && ! isRadial ),
1151
- radius = Math.min( canvas.width, canvas.height ) * ( chLayout == CHANNEL_VERTICAL ? .375 : .125 ) | 0;
1148
+ initialX = centerX * ( _mirror == -1 && ! isDualHorizontal && ! _radial ),
1149
+ innerRadius = Math.min( canvas.width, canvas.height ) * ( _chLayout == CHANNEL_VERTICAL ? .375 : .125 ) | 0,
1150
+ outerRadius = Math.min( centerX, centerY );
1152
1151
 
1153
1152
  /**
1154
1153
  * CREATE ANALYZER BANDS
@@ -1227,12 +1226,12 @@ export default class AudioMotionAnalyzer {
1227
1226
 
1228
1227
  // ANSI standard octave bands use the base-10 frequency ratio, as preferred by [ANSI S1.11-2004, p.2]
1229
1228
  // The equal-tempered scale uses the base-2 ratio
1230
- const bands = [0,24,12,8,6,4,3,2,1][ this._mode ],
1231
- bandWidth = isAnsiBands ? 10 ** ( 3 / ( bands * 10 ) ) : 2 ** ( 1 / bands ), // 10^(3/10N) or 2^(1/N)
1229
+ const bands = [0,24,12,8,6,4,3,2,1][ _mode ],
1230
+ bandWidth = _ansiBands ? 10 ** ( 3 / ( bands * 10 ) ) : 2 ** ( 1 / bands ), // 10^(3/10N) or 2^(1/N)
1232
1231
  halfBand = bandWidth ** .5;
1233
1232
 
1234
1233
  let analyzerBars = [],
1235
- currFreq = isAnsiBands ? 7.94328235 / ( bands % 2 ? 1 : halfBand ) : C_1;
1234
+ currFreq = _ansiBands ? 7.94328235 / ( bands % 2 ? 1 : halfBand ) : C_1;
1236
1235
  // For ANSI bands with even denominators (all except 1/1 and 1/3), the reference frequency (1 kHz)
1237
1236
  // must fall on the edges of a pair of adjacent bands, instead of midband [ANSI S1.11-2004, p.2]
1238
1237
  // In the equal-tempered scale, all midband frequencies represent a musical note or quarter-tone.
@@ -1247,16 +1246,16 @@ export default class AudioMotionAnalyzer {
1247
1246
 
1248
1247
  // for 1/1, 1/2 and 1/3 ANSI bands, use the preferred numbers to find the nominal midband frequency
1249
1248
  // for 1/4 to 1/24, round to 2 or 3 significant digits, according to the MSD [ANSI S1.11-2004, p.12]
1250
- if ( isAnsiBands )
1249
+ if ( _ansiBands )
1251
1250
  freq = bands < 4 ? nearestPreferred( freq ) : roundSD( freq, freq.toString()[0] < 5 ? 3 : 2 );
1252
1251
  else
1253
1252
  freq = roundSD( freq, 4, true );
1254
1253
 
1255
- if ( freq >= minFreq )
1254
+ if ( freq >= _minFreq )
1256
1255
  barsPush( { posX: 0, freq, freqLo, freqHi, binLo, binHi, ratioLo, ratioHi } );
1257
1256
 
1258
1257
  currFreq *= bandWidth;
1259
- } while ( currFreq <= maxFreq );
1258
+ } while ( currFreq <= _maxFreq );
1260
1259
 
1261
1260
  barWidth = analyzerWidth / bars.length;
1262
1261
 
@@ -1270,19 +1269,19 @@ export default class AudioMotionAnalyzer {
1270
1269
 
1271
1270
  // clamp edge frequencies to minFreq / maxFreq, if necessary
1272
1271
  // this is done after computing scaleMin and unitWidth, for the proper positioning of labels on the X-axis
1273
- if ( firstBar.freqLo < minFreq ) {
1274
- firstBar.freqLo = minFreq;
1275
- [ firstBar.binLo, firstBar.ratioLo ] = calcRatio( minFreq );
1272
+ if ( firstBar.freqLo < _minFreq ) {
1273
+ firstBar.freqLo = _minFreq;
1274
+ [ firstBar.binLo, firstBar.ratioLo ] = calcRatio( _minFreq );
1276
1275
  }
1277
1276
 
1278
- if ( lastBar.freqHi > maxFreq ) {
1279
- lastBar.freqHi = maxFreq;
1280
- [ lastBar.binHi, lastBar.ratioHi ] = calcRatio( maxFreq );
1277
+ if ( lastBar.freqHi > _maxFreq ) {
1278
+ lastBar.freqHi = _maxFreq;
1279
+ [ lastBar.binHi, lastBar.ratioHi ] = calcRatio( _maxFreq );
1281
1280
  }
1282
1281
  }
1283
1282
  else if ( isBands ) { // a bands mode is selected, but frequency scale is not logarithmic
1284
1283
 
1285
- const bands = [0,24,12,8,6,4,3,2,1][ this._mode ] * 10;
1284
+ const bands = [0,24,12,8,6,4,3,2,1][ _mode ] * 10;
1286
1285
 
1287
1286
  const invFreqScaling = x => {
1288
1287
  switch ( this._frequencyScale ) {
@@ -1297,8 +1296,8 @@ export default class AudioMotionAnalyzer {
1297
1296
 
1298
1297
  barWidth = analyzerWidth / bands;
1299
1298
 
1300
- scaleMin = this._freqScaling( minFreq );
1301
- unitWidth = analyzerWidth / ( this._freqScaling( maxFreq ) - scaleMin );
1299
+ scaleMin = this._freqScaling( _minFreq );
1300
+ unitWidth = analyzerWidth / ( this._freqScaling( _maxFreq ) - scaleMin );
1302
1301
 
1303
1302
  for ( let i = 0, posX = 0; i < bands; i++, posX += barWidth ) {
1304
1303
  const freqLo = invFreqScaling( scaleMin + posX / unitWidth ),
@@ -1314,11 +1313,11 @@ export default class AudioMotionAnalyzer {
1314
1313
  else { // Discrete frequencies modes
1315
1314
  barWidth = 1;
1316
1315
 
1317
- scaleMin = this._freqScaling( minFreq );
1318
- unitWidth = analyzerWidth / ( this._freqScaling( maxFreq ) - scaleMin );
1316
+ scaleMin = this._freqScaling( _minFreq );
1317
+ unitWidth = analyzerWidth / ( this._freqScaling( _maxFreq ) - scaleMin );
1319
1318
 
1320
- const minIndex = this._freqToBin( minFreq, 'floor' ),
1321
- maxIndex = this._freqToBin( maxFreq );
1319
+ const minIndex = this._freqToBin( _minFreq, 'floor' ),
1320
+ maxIndex = this._freqToBin( _maxFreq );
1322
1321
 
1323
1322
  let lastPos = -999;
1324
1323
 
@@ -1374,7 +1373,7 @@ export default class AudioMotionAnalyzer {
1374
1373
 
1375
1374
  // use custom LED parameters if set, or the default parameters for the current mode
1376
1375
  const customParams = this._ledParams,
1377
- [ maxLeds, spaceVRatio, spaceHRatio ] = customParams || params[ mode ];
1376
+ [ maxLeds, spaceVRatio, spaceHRatio ] = customParams || params[ _mode ];
1378
1377
 
1379
1378
  let ledCount, maxHeight = analyzerHeight;
1380
1379
 
@@ -1413,9 +1412,9 @@ export default class AudioMotionAnalyzer {
1413
1412
  }
1414
1413
 
1415
1414
  // COMPUTE ADDITIONAL BAR POSITIONING, ACCORDING TO THE CURRENT SETTINGS
1416
- // uses: barSpace, barWidth, spaceH
1415
+ // uses: _barSpace, barWidth, spaceH
1417
1416
 
1418
- const barSpacePx = Math.min( barWidth - 1, barSpace * ( barSpace > 0 && barSpace < 1 ? barWidth : 1 ) );
1417
+ const barSpacePx = Math.min( barWidth - 1, _barSpace * ( _barSpace > 0 && _barSpace < 1 ? barWidth : 1 ) );
1419
1418
 
1420
1419
  if ( isBands )
1421
1420
  barWidth -= Math.max( isLeds ? spaceH : 0, barSpacePx );
@@ -1427,7 +1426,7 @@ export default class AudioMotionAnalyzer {
1427
1426
  // in bands modes we need to update bar.posX to account for bar/led spacing
1428
1427
 
1429
1428
  if ( isBands ) {
1430
- if ( barSpace == 0 && ! isLeds ) {
1429
+ if ( _barSpace == 0 && ! isLeds ) {
1431
1430
  // when barSpace == 0 use integer values for perfect gapless positioning
1432
1431
  posX |= 0;
1433
1432
  width |= 0;
@@ -1450,7 +1449,7 @@ export default class AudioMotionAnalyzer {
1450
1449
 
1451
1450
  const channelCoords = [];
1452
1451
  for ( const channel of [0,1] ) {
1453
- const channelTop = chLayout == CHANNEL_VERTICAL ? ( channelHeight + channelGap ) * channel : 0,
1452
+ const channelTop = _chLayout == CHANNEL_VERTICAL ? ( channelHeight + channelGap ) * channel : 0,
1454
1453
  channelBottom = channelTop + channelHeight,
1455
1454
  analyzerBottom = channelTop + analyzerHeight - ( ! isLeds || noLedGap ? 0 : spaceV );
1456
1455
 
@@ -1459,7 +1458,7 @@ export default class AudioMotionAnalyzer {
1459
1458
 
1460
1459
  // SAVE INTERNAL PROPERTIES
1461
1460
 
1462
- this._aux = { analyzerHeight, analyzerWidth, channelCoords, channelHeight, channelGap, initialX, radius, scaleMin, unitWidth };
1461
+ this._aux = { analyzerHeight, analyzerWidth, centerX, centerY, channelCoords, channelHeight, channelGap, initialX, innerRadius, outerRadius, scaleMin, unitWidth };
1463
1462
  this._flg = { isAlpha, isBands, isLeds, isLumi, isOctaves, isOutline, isRound, noLedGap };
1464
1463
 
1465
1464
  // generate the X-axis and radial scales
@@ -1473,19 +1472,20 @@ export default class AudioMotionAnalyzer {
1473
1472
  if ( ! this._ready )
1474
1473
  return;
1475
1474
 
1476
- const { analyzerWidth, initialX, radius, scaleMin, unitWidth } = this._aux,
1475
+ const { analyzerWidth, initialX, innerRadius, scaleMin, unitWidth } = this._aux,
1477
1476
  { canvas, _frequencyScale, _mirror, _noteLabels, _radial, _scaleX, _scaleR } = this,
1478
- canvasX = _scaleX.canvas,
1479
- canvasR = _scaleR.canvas,
1480
- freqLabels = [],
1481
- isVertical = this._chLayout == CHANNEL_VERTICAL,
1482
- scale = [ 'C',, 'D',, 'E', 'F',, 'G',, 'A',, 'B' ], // for note labels (no sharp notes)
1483
- scaleHeight = Math.min( canvas.width, canvas.height ) / 34 | 0, // circular scale height (radial mode)
1484
- fontSizeX = canvasX.height >> 1,
1485
- fontSizeR = scaleHeight >> 1,
1486
- labelWidthX = fontSizeX * ( _noteLabels ? .7 : 1.5 ),
1487
- labelWidthR = fontSizeR * ( _noteLabels ? 1 : 2 ),
1488
- root12 = 2 ** ( 1 / 12 );
1477
+ canvasX = _scaleX.canvas,
1478
+ canvasR = _scaleR.canvas,
1479
+ freqLabels = [],
1480
+ isDualHorizontal = this._chLayout == CHANNEL_HORIZONTAL,
1481
+ isDualVertical = this._chLayout == CHANNEL_VERTICAL,
1482
+ scale = [ 'C',, 'D',, 'E', 'F',, 'G',, 'A',, 'B' ], // for note labels (no sharp notes)
1483
+ scaleHeight = Math.min( canvas.width, canvas.height ) / 34 | 0, // circular scale height (radial mode)
1484
+ fontSizeX = canvasX.height >> 1,
1485
+ fontSizeR = scaleHeight >> 1,
1486
+ labelWidthX = fontSizeX * ( _noteLabels ? .7 : 1.5 ),
1487
+ labelWidthR = fontSizeR * ( _noteLabels ? 1 : 2 ),
1488
+ root12 = 2 ** ( 1 / 12 );
1489
1489
 
1490
1490
  if ( ! _noteLabels && ( this._ansiBands || _frequencyScale != SCALE_LOG ) ) {
1491
1491
  freqLabels.push( 16, 31.5, 63, 125, 250, 500, 1e3, 2e3, 4e3 );
@@ -1501,7 +1501,7 @@ export default class AudioMotionAnalyzer {
1501
1501
  if ( freq >= this._minFreq && freq <= this._maxFreq ) {
1502
1502
  const pitch = scale[ note ],
1503
1503
  isC = pitch == 'C';
1504
- if ( ( pitch && _noteLabels && ! _mirror ) || isC )
1504
+ if ( ( pitch && _noteLabels && ! _mirror && ! isDualHorizontal ) || isC )
1505
1505
  freqLabels.push( _noteLabels ? [ freq, pitch + ( isC ? octave : '' ) ] : freq );
1506
1506
  }
1507
1507
  freq *= root12;
@@ -1510,7 +1510,7 @@ export default class AudioMotionAnalyzer {
1510
1510
  }
1511
1511
 
1512
1512
  // in radial dual-vertical layout, the scale is positioned exactly between both channels, by making the canvas a bit larger than the inner diameter
1513
- canvasR.width = canvasR.height = ( radius << 1 ) + ( isVertical * scaleHeight );
1513
+ canvasR.width = canvasR.height = ( innerRadius << 1 ) + ( isDualVertical * scaleHeight );
1514
1514
 
1515
1515
  const centerR = canvasR.width >> 1,
1516
1516
  radialY = centerR - scaleHeight * .7; // vertical position of text labels in the circular scale
@@ -1552,10 +1552,10 @@ export default class AudioMotionAnalyzer {
1552
1552
  x = unitWidth * ( this._freqScaling( freq ) - scaleMin ),
1553
1553
  y = canvasX.height * .75,
1554
1554
  isC = label[0] == 'C',
1555
- maxW = fontSizeX * ( _noteLabels && ! _mirror ? ( isC ? 1.2 : .6 ) : 3 );
1555
+ maxW = fontSizeX * ( _noteLabels && ! _mirror && ! isDualHorizontal ? ( isC ? 1.2 : .6 ) : 3 );
1556
1556
 
1557
1557
  // set label color - no highlight when mirror effect is active (only Cs displayed)
1558
- _scaleX.fillStyle = _scaleR.fillStyle = isC && ! _mirror ? SCALEX_HIGHLIGHT_COLOR : SCALEX_LABEL_COLOR;
1558
+ _scaleX.fillStyle = _scaleR.fillStyle = isC && ! _mirror && ! isDualHorizontal ? SCALEX_HIGHLIGHT_COLOR : SCALEX_LABEL_COLOR;
1559
1559
 
1560
1560
  // prioritizes which note labels are displayed, due to the restricted space on some ranges/scales
1561
1561
  if ( _noteLabels ) {
@@ -1565,13 +1565,13 @@ export default class AudioMotionAnalyzer {
1565
1565
  let allowedLabels = ['C'];
1566
1566
 
1567
1567
  if ( isLog || freq > 2e3 || ( ! isLinear && freq > 250 ) ||
1568
- ( ( ! _radial || isVertical ) && ( ! isLinear && freq > 125 || freq > 1e3 ) ) )
1568
+ ( ( ! _radial || isDualVertical ) && ( ! isLinear && freq > 125 || freq > 1e3 ) ) )
1569
1569
  allowedLabels.push('G');
1570
1570
  if ( isLog || freq > 4e3 || ( ! isLinear && freq > 500 ) ||
1571
- ( ( ! _radial || isVertical ) && ( ! isLinear && freq > 250 || freq > 2e3 ) ) )
1571
+ ( ( ! _radial || isDualVertical ) && ( ! isLinear && freq > 250 || freq > 2e3 ) ) )
1572
1572
  allowedLabels.push('E');
1573
1573
  if ( isLinear && freq > 4e3 ||
1574
- ( ( ! _radial || isVertical ) && ( isLog || freq > 2e3 || ( ! isLinear && freq > 500 ) ) ) )
1574
+ ( ( ! _radial || isDualVertical ) && ( isLog || freq > 2e3 || ( ! isLinear && freq > 500 ) ) ) )
1575
1575
  allowedLabels.push('D','F','A','B');
1576
1576
  if ( ! allowedLabels.includes( label[0] ) )
1577
1577
  continue; // skip this label
@@ -1579,17 +1579,17 @@ export default class AudioMotionAnalyzer {
1579
1579
 
1580
1580
  // linear scale
1581
1581
  if ( x >= prevX + labelWidthX / 2 && x <= analyzerWidth ) {
1582
- _scaleX.fillText( label, initialX + x, y, maxW );
1583
- if ( _mirror && ( x > labelWidthX || _mirror == 1 ) )
1584
- _scaleX.fillText( label, ( initialX || canvas.width ) - x, y, maxW );
1582
+ _scaleX.fillText( label, isDualHorizontal && _mirror == -1 ? analyzerWidth - x : initialX + x, y, maxW );
1583
+ if ( isDualHorizontal || ( _mirror && ( x > labelWidthX || _mirror == 1 ) ) )
1584
+ _scaleX.fillText( label, isDualHorizontal && _mirror != 1 ? analyzerWidth + x : ( initialX || canvas.width ) - x, y, maxW );
1585
1585
  prevX = x + Math.min( maxW, _scaleX.measureText( label ).width ) / 2;
1586
1586
  }
1587
1587
 
1588
1588
  // radial scale
1589
1589
  if ( x >= prevR + labelWidthR && x < analyzerWidth - labelWidthR ) { // avoid overlapping the last label over the first one
1590
- radialLabel( x, label );
1591
- if ( _mirror && ( x > labelWidthR || _mirror == 1 ) ) // avoid overlapping of first labels on mirror mode
1592
- radialLabel( -x, label );
1590
+ radialLabel( isDualHorizontal && _mirror == 1 ? analyzerWidth - x : x, label );
1591
+ if ( isDualHorizontal || ( _mirror && ( x > labelWidthR || _mirror == 1 ) ) ) // avoid overlapping of first labels on mirror mode
1592
+ radialLabel( isDualHorizontal && _mirror != -1 ? analyzerWidth + x : -x, label );
1593
1593
  prevR = x;
1594
1594
  }
1595
1595
  }
@@ -1603,9 +1603,25 @@ export default class AudioMotionAnalyzer {
1603
1603
  // schedule next canvas update
1604
1604
  this._runId = requestAnimationFrame( timestamp => this._draw( timestamp ) );
1605
1605
 
1606
- if ( this._maxFPS && ( timestamp - this._last < 1000 / this._maxFPS ) )
1606
+ // frame rate control
1607
+ const elapsed = timestamp - this._time, // time since last FPS computation
1608
+ frameTime = timestamp - this._last, // time since last rendered frame
1609
+ targetInterval = this._maxFPS ? 975 / this._maxFPS : 0; // small tolerance for best results
1610
+
1611
+ if ( frameTime < targetInterval )
1607
1612
  return;
1608
1613
 
1614
+ this._last = timestamp - ( targetInterval ? frameTime % targetInterval : 0 ); // thanks https://stackoverflow.com/a/19772220/2370385
1615
+ this._frames++;
1616
+
1617
+ if ( elapsed >= 1000 ) { // update FPS every second
1618
+ this._fps = this._frames / elapsed * 1000;
1619
+ this._frames = 0;
1620
+ this._time = timestamp;
1621
+ }
1622
+
1623
+ // initialize local constants
1624
+
1609
1625
  const { isAlpha,
1610
1626
  isBands,
1611
1627
  isLeds,
@@ -1616,11 +1632,14 @@ export default class AudioMotionAnalyzer {
1616
1632
  noLedGap } = this._flg,
1617
1633
 
1618
1634
  { analyzerHeight,
1635
+ centerX,
1636
+ centerY,
1619
1637
  channelCoords,
1620
1638
  channelHeight,
1621
1639
  channelGap,
1622
1640
  initialX,
1623
- radius } = this._aux,
1641
+ innerRadius,
1642
+ outerRadius } = this._aux,
1624
1643
 
1625
1644
  { _bars,
1626
1645
  canvas,
@@ -1644,20 +1663,19 @@ export default class AudioMotionAnalyzer {
1644
1663
  useCanvas,
1645
1664
  _weightingFilter } = this,
1646
1665
 
1647
- canvasX = this._scaleX.canvas,
1648
- canvasR = this._scaleR.canvas,
1649
- centerX = canvas.width >> 1,
1650
- centerY = canvas.height >> 1,
1651
- holdFrames = _fps >> 1, // number of frames in half a second
1652
- isDualVertical = _chLayout == CHANNEL_VERTICAL,
1653
- isDualCombined = _chLayout == CHANNEL_COMBINED,
1654
- isSingle = _chLayout == CHANNEL_SINGLE,
1655
- isTrueLeds = isLeds && this._trueLeds && _colorMode == COLOR_GRADIENT,
1656
- analyzerWidth = _radial ? canvas.width : this._aux.analyzerWidth,
1657
- finalX = initialX + analyzerWidth,
1658
- showPeakLine = showPeaks && this._peakLine && _mode == 10,
1659
- maxBarHeight = _radial ? Math.min( centerX, centerY ) - radius : analyzerHeight,
1660
- dbRange = maxDecibels - minDecibels,
1666
+ canvasX = this._scaleX.canvas,
1667
+ canvasR = this._scaleR.canvas,
1668
+ holdFrames = _fps >> 1, // number of frames in half a second
1669
+ isDualCombined = _chLayout == CHANNEL_COMBINED,
1670
+ isDualHorizontal = _chLayout == CHANNEL_HORIZONTAL,
1671
+ isDualVertical = _chLayout == CHANNEL_VERTICAL,
1672
+ isSingle = _chLayout == CHANNEL_SINGLE,
1673
+ isTrueLeds = isLeds && this._trueLeds && _colorMode == COLOR_GRADIENT,
1674
+ analyzerWidth = _radial ? canvas.width : this._aux.analyzerWidth,
1675
+ finalX = initialX + analyzerWidth,
1676
+ showPeakLine = showPeaks && this._peakLine && _mode == MODE_GRAPH,
1677
+ maxBarHeight = _radial ? outerRadius - innerRadius : analyzerHeight,
1678
+ dbRange = maxDecibels - minDecibels,
1661
1679
  [ ledCount, ledSpaceH, ledSpaceV, ledHeight ] = this._leds || [];
1662
1680
 
1663
1681
  if ( _energy.val > 0 )
@@ -1667,7 +1685,7 @@ export default class AudioMotionAnalyzer {
1667
1685
 
1668
1686
  // create Reflex effect
1669
1687
  const doReflex = channel => {
1670
- if ( this._reflexRatio > 0 && ! isLumi ) {
1688
+ if ( this._reflexRatio > 0 && ! isLumi && ! _radial ) {
1671
1689
  let posY, height;
1672
1690
  if ( this.reflexFit || isDualVertical ) { // always fit reflex in vertical stereo mode
1673
1691
  posY = isDualVertical && channel == 0 ? channelHeight + channelGap : 0;
@@ -1709,49 +1727,6 @@ export default class AudioMotionAnalyzer {
1709
1727
  }
1710
1728
  }
1711
1729
 
1712
- // draw scale on Y-axis
1713
- const drawScaleY = channelTop => {
1714
- const scaleWidth = canvasX.height,
1715
- fontSize = scaleWidth >> 1,
1716
- max = _linearAmplitude ? 100 : maxDecibels,
1717
- min = _linearAmplitude ? 0 : minDecibels,
1718
- incr = _linearAmplitude ? 20 : 5,
1719
- interval = analyzerHeight / ( max - min );
1720
-
1721
- _ctx.save();
1722
- _ctx.fillStyle = SCALEY_LABEL_COLOR;
1723
- _ctx.font = `${fontSize}px ${FONT_FAMILY}`;
1724
- _ctx.textAlign = 'right';
1725
- _ctx.lineWidth = 1;
1726
-
1727
- for ( let val = max; val > min; val -= incr ) {
1728
- const posY = channelTop + ( max - val ) * interval,
1729
- even = ( val % 2 == 0 ) | 0;
1730
-
1731
- if ( even ) {
1732
- const labelY = posY + fontSize * ( posY == channelTop ? .8 : .35 );
1733
- if ( _mirror != -1 )
1734
- _ctx.fillText( val, scaleWidth * .85, labelY );
1735
- if ( _mirror != 1 )
1736
- _ctx.fillText( val, canvas.width - scaleWidth * .1, labelY );
1737
- _ctx.strokeStyle = SCALEY_LABEL_COLOR;
1738
- _ctx.setLineDash([2,4]);
1739
- _ctx.lineDashOffset = 0;
1740
- }
1741
- else {
1742
- _ctx.strokeStyle = SCALEY_MIDLINE_COLOR;
1743
- _ctx.setLineDash([2,8]);
1744
- _ctx.lineDashOffset = 1;
1745
- }
1746
-
1747
- _ctx.beginPath();
1748
- _ctx.moveTo( initialX + scaleWidth * even * ( _mirror != -1 ), ~~posY + .5 ); // for sharp 1px line (https://stackoverflow.com/a/13879402/2370385)
1749
- _ctx.lineTo( finalX - scaleWidth * even * ( _mirror != 1 ), ~~posY + .5 );
1750
- _ctx.stroke();
1751
- }
1752
- _ctx.restore();
1753
- }
1754
-
1755
1730
  // returns the gain (in dB) for a given frequency, considering the currently selected weighting filter
1756
1731
  const weightingdB = freq => {
1757
1732
  const f2 = freq ** 2,
@@ -1808,35 +1783,6 @@ export default class AudioMotionAnalyzer {
1808
1783
  }
1809
1784
  }
1810
1785
 
1811
- // converts a given X-coordinate to its corresponding angle in radial mode
1812
- const getAngle = ( x, dir ) => dir * TAU * ( x / canvas.width ) + this._spinAngle;
1813
-
1814
- // converts planar X,Y coordinates to radial coordinates
1815
- const radialXY = ( x, y, dir = 1 ) => {
1816
- const height = radius + y,
1817
- angle = getAngle( x, dir );
1818
- return [ centerX + height * Math.cos( angle ), centerY + height * Math.sin( angle ) ];
1819
- }
1820
-
1821
- // draws a polygon of width `w` and height `h` at (x,y) in radial mode
1822
- const radialPoly = ( x, y, w, h, stroke ) => {
1823
- _ctx.beginPath();
1824
- for ( const dir of ( _mirror ? [1,-1] : [1] ) ) {
1825
- const [ startAngle, endAngle ] = isRound ? [ getAngle( x, dir ), getAngle( x + w, dir ) ] : [];
1826
- _ctx.moveTo( ...radialXY( x, y, dir ) );
1827
- _ctx.lineTo( ...radialXY( x, y + h, dir ) );
1828
- if ( isRound )
1829
- _ctx.arc( centerX, centerY, radius + y + h, startAngle, endAngle, dir != 1 );
1830
- else
1831
- _ctx.lineTo( ...radialXY( x + w, y + h, dir ) );
1832
- _ctx.lineTo( ...radialXY( x + w, y, dir ) );
1833
- if ( isRound && ! stroke ) // close the bottom line only when not in outline mode
1834
- _ctx.arc( centerX, centerY, radius + y, endAngle, startAngle, dir == 1 );
1835
- }
1836
- strokeIf( stroke );
1837
- _ctx.fill();
1838
- }
1839
-
1840
1786
  // converts a value in [0;1] range to a height in pixels that fits into the current LED elements
1841
1787
  const ledPosY = value => Math.max( 0, ( value * ledCount | 0 ) * ( ledHeight + ledSpaceV ) - ledSpaceV );
1842
1788
 
@@ -1854,29 +1800,11 @@ export default class AudioMotionAnalyzer {
1854
1800
  }
1855
1801
  }
1856
1802
 
1857
- // calculate and display (if enabled) the current frame rate
1858
- const updateFPS = () => {
1859
- const elapsed = timestamp - this._time; // elapsed time since the last FPS computation
1860
-
1861
- this._last = timestamp - ( this._maxFPS ? elapsed % ( 1000 / this._maxFPS ) : 0 ); // thanks https://stackoverflow.com/a/19772220/2370385
1862
- this._frames++;
1863
-
1864
- if ( elapsed >= 1000 ) {
1865
- this._fps = this._frames / ( elapsed / 1000 );
1866
- this._frames = 0;
1867
- this._time = timestamp;
1868
- }
1869
- if ( this.showFPS ) {
1870
- const size = canvasX.height;
1871
- _ctx.font = `bold ${size}px ${FONT_FAMILY}`;
1872
- _ctx.fillStyle = FPS_COLOR;
1873
- _ctx.textAlign = 'right';
1874
- _ctx.fillText( Math.round( this._fps ), canvas.width - size, size * 2 );
1875
- }
1876
- }
1877
-
1878
1803
  /* MAIN FUNCTION */
1879
1804
 
1805
+ if ( overlay )
1806
+ _ctx.clearRect( 0, 0, canvas.width, canvas.height );
1807
+
1880
1808
  let currentEnergy = 0;
1881
1809
 
1882
1810
  const nBars = _bars.length,
@@ -1885,24 +1813,122 @@ export default class AudioMotionAnalyzer {
1885
1813
  for ( let channel = 0; channel < nChannels; channel++ ) {
1886
1814
 
1887
1815
  const { channelTop, channelBottom, analyzerBottom } = channelCoords[ channel ],
1888
- channelGradient = this._gradients[ this._selectedGrads[ channel ] ],
1889
- colorStops = channelGradient.colorStops,
1890
- colorCount = colorStops.length,
1891
- bgColor = ( ! showBgColor || isLeds && ! overlay ) ? '#000' : channelGradient.bgColor,
1892
- mustClear = channel == 0 || ! _radial && ! isDualCombined,
1893
- direction = channel && _radial && isDualVertical ? -1 : 1; // for radial dual vertical layout
1894
-
1895
- // helper function for FFT data interpolation (uses fftData)
1816
+ channelGradient = this._gradients[ this._selectedGrads[ channel ] ],
1817
+ colorStops = channelGradient.colorStops,
1818
+ colorCount = colorStops.length,
1819
+ bgColor = ( ! showBgColor || isLeds && ! overlay ) ? '#000' : channelGradient.bgColor,
1820
+ radialDirection = isDualVertical && _radial && channel ? -1 : 1, // 1 = outwards, -1 = inwards
1821
+ invertedChannel = ( ! channel && _mirror == -1 ) || ( channel && _mirror == 1 ),
1822
+ radialOffsetX = ! isDualHorizontal || ( channel && _mirror != 1 ) ? 0 : analyzerWidth >> ( channel || ! invertedChannel ),
1823
+ angularDirection = isDualHorizontal && invertedChannel ? -1 : 1; // 1 = clockwise, -1 = counterclockwise
1824
+ /*
1825
+ Expanded logic for radialOffsetX and angularDirection:
1826
+
1827
+ let radialOffsetX = 0,
1828
+ angularDirection = 1;
1829
+
1830
+ if ( isDualHorizontal ) {
1831
+ if ( channel == 0 ) { // LEFT channel
1832
+ if ( _mirror == -1 ) {
1833
+ radialOffsetX = analyzerWidth;
1834
+ angularDirection = -1;
1835
+ }
1836
+ else
1837
+ radialOffsetX = analyzerWidth >> 1;
1838
+ }
1839
+ else { // RIGHT channel
1840
+ if ( _mirror == 1 ) {
1841
+ radialOffsetX = analyzerWidth >> 1;
1842
+ angularDirection = -1;
1843
+ }
1844
+ }
1845
+ }
1846
+ */
1847
+ // draw scale on Y-axis (uses: channel, channelTop)
1848
+ const drawScaleY = () => {
1849
+ const scaleWidth = canvasX.height,
1850
+ fontSize = scaleWidth >> 1,
1851
+ max = _linearAmplitude ? 100 : maxDecibels,
1852
+ min = _linearAmplitude ? 0 : minDecibels,
1853
+ incr = _linearAmplitude ? 20 : 5,
1854
+ interval = analyzerHeight / ( max - min ),
1855
+ atStart = _mirror != -1 && ( ! isDualHorizontal || channel == 0 || _mirror == 1 ),
1856
+ atEnd = _mirror != 1 && ( ! isDualHorizontal || channel != _mirror );
1857
+
1858
+ _ctx.save();
1859
+ _ctx.fillStyle = SCALEY_LABEL_COLOR;
1860
+ _ctx.font = `${fontSize}px ${FONT_FAMILY}`;
1861
+ _ctx.textAlign = 'right';
1862
+ _ctx.lineWidth = 1;
1863
+
1864
+ for ( let val = max; val > min; val -= incr ) {
1865
+ const posY = channelTop + ( max - val ) * interval,
1866
+ even = ( val % 2 == 0 ) | 0;
1867
+
1868
+ if ( even ) {
1869
+ const labelY = posY + fontSize * ( posY == channelTop ? .8 : .35 );
1870
+ if ( atStart )
1871
+ _ctx.fillText( val, scaleWidth * .85, labelY );
1872
+ if ( atEnd )
1873
+ _ctx.fillText( val, ( isDualHorizontal ? analyzerWidth : canvas.width ) - scaleWidth * .1, labelY );
1874
+ _ctx.strokeStyle = SCALEY_LABEL_COLOR;
1875
+ _ctx.setLineDash([2,4]);
1876
+ _ctx.lineDashOffset = 0;
1877
+ }
1878
+ else {
1879
+ _ctx.strokeStyle = SCALEY_MIDLINE_COLOR;
1880
+ _ctx.setLineDash([2,8]);
1881
+ _ctx.lineDashOffset = 1;
1882
+ }
1883
+
1884
+ _ctx.beginPath();
1885
+ _ctx.moveTo( initialX + scaleWidth * even * atStart, ~~posY + .5 ); // for sharp 1px line (https://stackoverflow.com/a/13879402/2370385)
1886
+ _ctx.lineTo( finalX - scaleWidth * even * atEnd, ~~posY + .5 );
1887
+ _ctx.stroke();
1888
+ }
1889
+ _ctx.restore();
1890
+ }
1891
+
1892
+ // FFT bin data interpolation (uses fftData)
1896
1893
  const interpolate = ( bin, ratio ) => {
1897
1894
  const value = fftData[ bin ] + ( bin < fftData.length - 1 ? ( fftData[ bin + 1 ] - fftData[ bin ] ) * ratio : 0 );
1898
1895
  return isNaN( value ) ? -Infinity : value;
1899
1896
  }
1900
1897
 
1898
+ // converts a given X-coordinate to its corresponding angle in radial mode (uses angularDirection)
1899
+ const getAngle = ( x, dir = angularDirection ) => dir * TAU * ( ( x + radialOffsetX ) / canvas.width ) + this._spinAngle;
1900
+
1901
+ // converts planar X,Y coordinates to radial coordinates (uses: getAngle(), radialDirection)
1902
+ const radialXY = ( x, y, dir ) => {
1903
+ const height = innerRadius + y * radialDirection,
1904
+ angle = getAngle( x, dir );
1905
+ return [ centerX + height * Math.cos( angle ), centerY + height * Math.sin( angle ) ];
1906
+ }
1907
+
1908
+ // draws a polygon of width `w` and height `h` at (x,y) in radial mode (uses: angularDirection, radialDirection)
1909
+ const radialPoly = ( x, y, w, h, stroke ) => {
1910
+ _ctx.beginPath();
1911
+ for ( const dir of ( _mirror && ! isDualHorizontal ? [1,-1] : [ angularDirection ] ) ) {
1912
+ const [ startAngle, endAngle ] = isRound ? [ getAngle( x, dir ), getAngle( x + w, dir ) ] : [];
1913
+ _ctx.moveTo( ...radialXY( x, y, dir ) );
1914
+ _ctx.lineTo( ...radialXY( x, y + h, dir ) );
1915
+ if ( isRound )
1916
+ _ctx.arc( centerX, centerY, innerRadius + ( y + h ) * radialDirection, startAngle, endAngle, dir != 1 );
1917
+ else
1918
+ _ctx.lineTo( ...radialXY( x + w, y + h, dir ) );
1919
+ _ctx.lineTo( ...radialXY( x + w, y, dir ) );
1920
+ if ( isRound && ! stroke ) // close the bottom line only when not in outline mode
1921
+ _ctx.arc( centerX, centerY, innerRadius + y * radialDirection, endAngle, startAngle, dir == 1 );
1922
+ }
1923
+ strokeIf( stroke );
1924
+ _ctx.fill();
1925
+ }
1926
+
1901
1927
  // set fillStyle and strokeStyle according to current colorMode (uses: channel, colorStops, colorCount)
1902
1928
  const setBarColor = ( value = 0, barIndex = 0 ) => {
1903
1929
  let color;
1904
- // for mode 10, always use the channel gradient (ignore colorMode)
1905
- if ( ( _colorMode == COLOR_GRADIENT && ! isTrueLeds ) || _mode == 10 )
1930
+ // for graph mode, always use the channel gradient (ignore colorMode)
1931
+ if ( ( _colorMode == COLOR_GRADIENT && ! isTrueLeds ) || _mode == MODE_GRAPH )
1906
1932
  color = _canvasGradients[ channel ];
1907
1933
  else {
1908
1934
  const selectedIndex = _colorMode == COLOR_BAR_INDEX ? barIndex % colorCount : colorStops.findLastIndex( item => isLeds ? ledPosY( value ) <= ledPosY( item.level ) : value <= item.level );
@@ -1911,11 +1937,16 @@ export default class AudioMotionAnalyzer {
1911
1937
  _ctx.fillStyle = _ctx.strokeStyle = color;
1912
1938
  }
1913
1939
 
1940
+ // CHANNEL START
1941
+
1914
1942
  if ( useCanvas ) {
1915
- // clear the channel area, if in overlay mode
1916
- // this is done per channel to clear any residue below 0 off the top channel (especially in line graph mode with lineWidth > 1)
1917
- if ( overlay && mustClear )
1918
- _ctx.clearRect( 0, channelTop - channelGap, canvas.width, channelHeight + channelGap );
1943
+ // set transform (horizontal flip and translation) for dual-horizontal layout
1944
+ if ( isDualHorizontal && ! _radial ) {
1945
+ const translateX = analyzerWidth * ( channel + invertedChannel ),
1946
+ flipX = invertedChannel ? -1 : 1;
1947
+
1948
+ _ctx.setTransform( flipX, 0, 0, 1, translateX, 0 );
1949
+ }
1919
1950
 
1920
1951
  // fill the analyzer background if needed (not overlay or overlay + showBgColor)
1921
1952
  if ( ! overlay || showBgColor ) {
@@ -1925,7 +1956,7 @@ export default class AudioMotionAnalyzer {
1925
1956
  _ctx.fillStyle = bgColor;
1926
1957
 
1927
1958
  // exclude the reflection area when overlay is true and reflexAlpha == 1 (avoids alpha over alpha difference, in case bgAlpha < 1)
1928
- if ( mustClear )
1959
+ if ( channel == 0 || ( ! _radial && ! isDualCombined ) )
1929
1960
  _ctx.fillRect( initialX, channelTop - channelGap, analyzerWidth, ( overlay && this.reflexAlpha == 1 ? analyzerHeight : channelHeight ) + channelGap );
1930
1961
 
1931
1962
  _ctx.globalAlpha = 1;
@@ -1933,7 +1964,7 @@ export default class AudioMotionAnalyzer {
1933
1964
 
1934
1965
  // draw dB scale (Y-axis) - avoid drawing it twice on 'dual-combined' channel layout
1935
1966
  if ( this.showScaleY && ! isLumi && ! _radial && ( channel == 0 || ! isDualCombined ) )
1936
- drawScaleY( channelTop );
1967
+ drawScaleY();
1937
1968
 
1938
1969
  // set line width and dash for LEDs effect
1939
1970
  if ( isLeds ) {
@@ -1943,13 +1974,14 @@ export default class AudioMotionAnalyzer {
1943
1974
  else // for outline effect ensure linewidth is not greater than half the bar width
1944
1975
  _ctx.lineWidth = isOutline ? Math.min( _lineWidth, _bars[0].width / 2 ) : _lineWidth;
1945
1976
 
1946
- // set clip region
1977
+ // set clipping region
1947
1978
  _ctx.save();
1948
1979
  if ( ! _radial ) {
1949
- const channelRegion = new Path2D();
1950
- channelRegion.rect( 0, channelTop, canvas.width, analyzerHeight );
1951
- _ctx.clip( channelRegion );
1980
+ const region = new Path2D();
1981
+ region.rect( 0, channelTop, canvas.width, analyzerHeight );
1982
+ _ctx.clip( region );
1952
1983
  }
1984
+
1953
1985
  } // if ( useCanvas )
1954
1986
 
1955
1987
  // get a new array of data from the FFT
@@ -1960,7 +1992,7 @@ export default class AudioMotionAnalyzer {
1960
1992
  if ( _weightingFilter )
1961
1993
  fftData = fftData.map( ( val, idx ) => val + weightingdB( this._binToFreq( idx ) ) );
1962
1994
 
1963
- // start drawing path (for mode 10)
1995
+ // start drawing path (for graph mode)
1964
1996
  _ctx.beginPath();
1965
1997
 
1966
1998
  // store line graph points to create mirror effect in radial mode
@@ -2015,18 +2047,21 @@ export default class AudioMotionAnalyzer {
2015
2047
  setBarColor( barValue, barIndex );
2016
2048
 
2017
2049
  // compute actual bar height on screen
2018
- const barHeight = ( isLumi ? maxBarHeight : isLeds ? ledPosY( barValue ) : barValue * maxBarHeight | 0 ) * direction;
2050
+ const barHeight = isLumi ? maxBarHeight : isLeds ? ledPosY( barValue ) : barValue * maxBarHeight | 0;
2019
2051
 
2020
2052
  // Draw current bar or line segment
2021
2053
 
2022
- if ( _mode == 10 ) {
2054
+ if ( _mode == MODE_GRAPH ) {
2023
2055
  // compute the average between the initial bar (barIndex==0) and the next one
2024
2056
  // used to smooth the curve when the initial posX is off the screen, in mirror and radial modes
2025
- const nextBarAvg = barIndex ? 0 : ( this._normalizedB( fftData[ _bars[1].binLo ] ) * maxBarHeight * direction + barHeight ) / 2;
2057
+ const nextBarAvg = barIndex ? 0 : ( this._normalizedB( fftData[ _bars[1].binLo ] ) * maxBarHeight + barHeight ) / 2;
2026
2058
 
2027
2059
  if ( _radial ) {
2028
- if ( barIndex == 0 )
2060
+ if ( barIndex == 0 ) {
2061
+ if ( isDualHorizontal )
2062
+ _ctx.moveTo( ...radialXY( 0, 0 ) );
2029
2063
  _ctx.lineTo( ...radialXY( 0, ( posX < 0 ? nextBarAvg : barHeight ) ) );
2064
+ }
2030
2065
  // draw line to the current point, avoiding overlapping wrap-around frequencies
2031
2066
  if ( posX >= 0 ) {
2032
2067
  const point = [ posX, barHeight ];
@@ -2037,16 +2072,16 @@ export default class AudioMotionAnalyzer {
2037
2072
  else { // Linear
2038
2073
  if ( barIndex == 0 ) {
2039
2074
  // start the line off-screen using the previous FFT bin value as the initial amplitude
2040
- if ( _mirror != -1 ) {
2075
+ if ( _mirror == -1 && ! isDualHorizontal )
2076
+ _ctx.moveTo( initialX, analyzerBottom - ( posX < initialX ? nextBarAvg : barHeight ) );
2077
+ else {
2041
2078
  const prevFFTData = binLo ? this._normalizedB( fftData[ binLo - 1 ] ) * maxBarHeight : barHeight; // use previous FFT bin value, when available
2042
2079
  _ctx.moveTo( initialX - _lineWidth, analyzerBottom - prevFFTData );
2043
2080
  }
2044
- else
2045
- _ctx.moveTo( initialX, analyzerBottom - ( posX < initialX ? nextBarAvg : barHeight ) );
2046
2081
  }
2047
2082
  // draw line to the current point
2048
2083
  // avoid X values lower than the origin when mirroring left, otherwise draw them for best graph accuracy
2049
- if ( _mirror != -1 || posX >= initialX )
2084
+ if ( isDualHorizontal || _mirror != -1 || posX >= initialX )
2050
2085
  _ctx.lineTo( posX, analyzerBottom - barHeight );
2051
2086
  }
2052
2087
  }
@@ -2086,7 +2121,7 @@ export default class AudioMotionAnalyzer {
2086
2121
  _ctx.beginPath();
2087
2122
  _ctx.moveTo( posX, y );
2088
2123
  _ctx.lineTo( posX, y - barHeight );
2089
- _ctx.arc( barCenter, y - barHeight, halfWidth, Math.PI, TAU );
2124
+ _ctx.arc( barCenter, y - barHeight, halfWidth, PI, TAU );
2090
2125
  _ctx.lineTo( posX + width, y );
2091
2126
  strokeIf( isOutline );
2092
2127
  _ctx.fill();
@@ -2118,12 +2153,12 @@ export default class AudioMotionAnalyzer {
2118
2153
  if ( isLeds ) {
2119
2154
  const ledPeak = ledPosY( peak );
2120
2155
  if ( ledPeak >= ledSpaceV ) // avoid peak below first led
2121
- _ctx.fillRect( posX, analyzerBottom - ledPeak, width, ledHeight );
2156
+ _ctx.fillRect( posX, analyzerBottom - ledPeak, width, ledHeight );
2122
2157
  }
2123
2158
  else if ( ! _radial )
2124
2159
  _ctx.fillRect( posX, analyzerBottom - peak * maxBarHeight, width, 2 );
2125
- else if ( _mode != 10 ) // radial - peaks for mode 10 are done by the peak line code
2126
- radialPoly( posX, peak * maxBarHeight * direction, width, -2 );
2160
+ else if ( _mode != MODE_GRAPH ) // radial - peaks for graph mode are done by the peak line code
2161
+ radialPoly( posX, peak * maxBarHeight, width, -2 );
2127
2162
  }
2128
2163
 
2129
2164
  } // for ( let barIndex = 0; barIndex < nBars; barIndex++ )
@@ -2132,16 +2167,14 @@ export default class AudioMotionAnalyzer {
2132
2167
  if ( ! useCanvas )
2133
2168
  continue;
2134
2169
 
2135
- _ctx.restore(); // restore clip region
2136
-
2137
2170
  // restore global alpha
2138
2171
  _ctx.globalAlpha = 1;
2139
2172
 
2140
- // Fill/stroke drawing path for mode 10
2141
- if ( _mode == 10 ) {
2173
+ // Fill/stroke drawing path for graph mode
2174
+ if ( _mode == MODE_GRAPH ) {
2142
2175
  setBarColor(); // select channel gradient
2143
2176
 
2144
- if ( _radial ) {
2177
+ if ( _radial && ! isDualHorizontal ) {
2145
2178
  if ( _mirror ) {
2146
2179
  let p;
2147
2180
  while ( p = points.pop() )
@@ -2156,10 +2189,13 @@ export default class AudioMotionAnalyzer {
2156
2189
  if ( fillAlpha > 0 ) {
2157
2190
  if ( _radial ) {
2158
2191
  // exclude the center circle from the fill area
2159
- _ctx.moveTo( centerX + radius, centerY );
2160
- _ctx.arc( centerX, centerY, radius, 0, TAU, true );
2192
+ const start = isDualHorizontal ? getAngle( analyzerWidth >> 1 ) : 0,
2193
+ end = isDualHorizontal ? getAngle( analyzerWidth ) : TAU;
2194
+ _ctx.moveTo( ...radialXY( isDualHorizontal ? analyzerWidth >> 1 : 0, 0 ) );
2195
+ _ctx.arc( centerX, centerY, innerRadius, start, end, isDualHorizontal ? ! invertedChannel : true );
2161
2196
  }
2162
- else { // close the fill area
2197
+ else {
2198
+ // close the fill area
2163
2199
  _ctx.lineTo( finalX, analyzerBottom );
2164
2200
  _ctx.lineTo( initialX, analyzerBottom );
2165
2201
  }
@@ -2182,14 +2218,14 @@ export default class AudioMotionAnalyzer {
2182
2218
  h = findY( x, h, nextBar.posX, nextBar.peak[ channel ], 0 );
2183
2219
  x = 0;
2184
2220
  }
2185
- h *= maxBarHeight * direction;
2221
+ h *= maxBarHeight;
2186
2222
  if ( showPeakLine ) {
2187
2223
  _ctx[ m ]( ...( _radial ? radialXY( x, h ) : [ x, analyzerBottom - h ] ) );
2188
- if ( _radial && _mirror )
2224
+ if ( _radial && _mirror && ! isDualHorizontal )
2189
2225
  points.push( [ x, h ] );
2190
2226
  }
2191
- else if ( h )
2192
- radialPoly( x, h, 1, -2 * direction ); // standard peaks (also does mirror)
2227
+ else if ( h > 0 )
2228
+ radialPoly( x, h, 1, -2 ); // standard peaks (also does mirror)
2193
2229
  });
2194
2230
  if ( showPeakLine ) {
2195
2231
  let p;
@@ -2201,8 +2237,14 @@ export default class AudioMotionAnalyzer {
2201
2237
  }
2202
2238
  }
2203
2239
 
2204
- // create Reflex effect
2205
- doReflex( channel );
2240
+ _ctx.restore(); // restore clip region
2241
+
2242
+ if ( isDualHorizontal && ! _radial )
2243
+ _ctx.setTransform( 1, 0, 0, 1, 0, 0 );
2244
+
2245
+ // create Reflex effect - for dual-combined and dual-horizontal do it only once, after channel 1
2246
+ if ( ( ! isDualHorizontal && ! isDualCombined ) || channel )
2247
+ doReflex( channel );
2206
2248
 
2207
2249
  } // for ( let channel = 0; channel < nChannels; channel++ ) {
2208
2250
 
@@ -2210,7 +2252,7 @@ export default class AudioMotionAnalyzer {
2210
2252
 
2211
2253
  if ( useCanvas ) {
2212
2254
  // Mirror effect
2213
- if ( _mirror && ! _radial ) {
2255
+ if ( _mirror && ! _radial && ! isDualHorizontal ) {
2214
2256
  _ctx.setTransform( -1, 0, 0, 1, canvas.width - initialX, 0 );
2215
2257
  _ctx.drawImage( canvas, initialX, 0, centerX, canvas.height, 0, 0, centerX, canvas.height );
2216
2258
  _ctx.setTransform( 1, 0, 0, 1, 0, 0 );
@@ -2223,8 +2265,14 @@ export default class AudioMotionAnalyzer {
2223
2265
  drawScaleX();
2224
2266
  }
2225
2267
 
2226
- // calculate and display (if enabled) the current frame rate
2227
- updateFPS();
2268
+ // display current frame rate
2269
+ if ( this.showFPS ) {
2270
+ const size = canvasX.height;
2271
+ _ctx.font = `bold ${size}px ${FONT_FAMILY}`;
2272
+ _ctx.fillStyle = FPS_COLOR;
2273
+ _ctx.textAlign = 'right';
2274
+ _ctx.fillText( Math.round( _fps ), canvas.width - size, size * 2 );
2275
+ }
2228
2276
 
2229
2277
  // call callback function, if defined
2230
2278
  if ( this.onCanvasDraw ) {
@@ -2269,13 +2317,10 @@ export default class AudioMotionAnalyzer {
2269
2317
  return;
2270
2318
 
2271
2319
  const { canvas, _ctx, _radial, _reflexRatio } = this,
2272
- { analyzerWidth, initialX, radius } = this._aux,
2320
+ { analyzerWidth, centerX, centerY, initialX, innerRadius, outerRadius } = this._aux,
2273
2321
  { isLumi } = this._flg,
2274
2322
  isDualVertical = this._chLayout == CHANNEL_VERTICAL,
2275
2323
  analyzerRatio = 1 - _reflexRatio,
2276
- centerX = canvas.width >> 1,
2277
- centerY = canvas.height >> 1,
2278
- maxRadius = Math.min( centerX, centerY ),
2279
2324
  gradientHeight = isLumi ? canvas.height : canvas.height * ( 1 - _reflexRatio * ( ! isDualVertical ) ) | 0;
2280
2325
  // for vertical stereo we keep the full canvas height and handle the reflex areas while generating the color stops
2281
2326
 
@@ -2287,7 +2332,7 @@ export default class AudioMotionAnalyzer {
2287
2332
  let grad;
2288
2333
 
2289
2334
  if ( _radial )
2290
- grad = _ctx.createRadialGradient( centerX, centerY, maxRadius, centerX, centerY, radius - ( maxRadius - radius ) * isDualVertical );
2335
+ grad = _ctx.createRadialGradient( centerX, centerY, outerRadius, centerX, centerY, innerRadius - ( outerRadius - innerRadius ) * isDualVertical );
2291
2336
  else
2292
2337
  grad = _ctx.createLinearGradient( ...( isHorizontal ? [ initialX, 0, initialX + analyzerWidth, 0 ] : [ 0, 0, 0, gradientHeight ] ) );
2293
2338
 
package/src/index.d.ts CHANGED
@@ -88,7 +88,7 @@ export interface ConstructorOptions extends Options {
88
88
  source?: HTMLMediaElement | AudioNode;
89
89
  }
90
90
 
91
- export type ChannelLayout = "single" | "dual-vertical" | "dual-combined";
91
+ export type ChannelLayout = "single" | "dual-horizontal" | "dual-vertical" | "dual-combined";
92
92
 
93
93
  export type ColorMode = "gradient" | "bar-index" | "bar-level";
94
94