fpmk-audiomotion-analyzer
Version:
High-resolution real-time graphic audio spectrum analyzer JavaScript module with no dependencies.
1,698 lines (1,453 loc) • 88.2 kB
JavaScript
/**!
* audioMotion-analyzer
* High-resolution real-time graphic audio spectrum analyzer JS module
*
* @version 4.5.0
* @author Henrique Avila Vianna <hvianna@gmail.com> <https://henriquevianna.com>
* @license AGPL-3.0-or-later
*/
const VERSION = '4.5.0';
// internal constants
const PI = Math.PI,
TAU = 2 * PI,
HALF_PI = PI / 2,
C_1 = 8.17579892; // frequency for C -1
const CANVAS_BACKGROUND_COLOR = '#000',
CHANNEL_COMBINED = 'dual-combined',
CHANNEL_HORIZONTAL = 'dual-horizontal',
CHANNEL_SINGLE = 'single',
CHANNEL_VERTICAL = 'dual-vertical',
COLOR_BAR_INDEX = 'bar-index',
COLOR_BAR_LEVEL = 'bar-level',
COLOR_GRADIENT = 'gradient',
DEBOUNCE_TIMEOUT = 60,
EVENT_CLICK = 'click',
EVENT_FULLSCREENCHANGE = 'fullscreenchange',
EVENT_RESIZE = 'resize',
GRADIENT_DEFAULT_BGCOLOR = '#111',
FILTER_NONE = '',
FILTER_A = 'A',
FILTER_B = 'B',
FILTER_C = 'C',
FILTER_D = 'D',
FILTER_468 = '468',
FONT_FAMILY = 'sans-serif',
FPS_COLOR = '#0f0',
LEDS_UNLIT_COLOR = '#7f7f7f22',
MODE_GRAPH = 10,
REASON_CREATE = 'create',
REASON_FSCHANGE = 'fschange',
REASON_LORES = 'lores',
REASON_RESIZE = EVENT_RESIZE,
REASON_USER = 'user',
SCALEX_BACKGROUND_COLOR = '#000c',
SCALEX_LABEL_COLOR = '#fff',
SCALEX_HIGHLIGHT_COLOR = '#4f4',
SCALEY_LABEL_COLOR = '#888',
SCALEY_MIDLINE_COLOR = '#555',
SCALE_BARK = 'bark',
SCALE_LINEAR = 'linear',
SCALE_LOG = 'log',
SCALE_MEL = 'mel';
// built-in gradients
const PRISM = [ '#a35', '#c66', '#e94', '#ed0', '#9d5', '#4d8', '#2cb', '#0bc', '#09c', '#36b' ],
GRADIENTS = [
[ 'classic', {
colorStops: [
'red',
{ color: 'yellow', level: .85, pos: .6 },
{ color: 'lime', level: .475 }
]
}],
[ 'prism', {
colorStops: PRISM
}],
[ 'rainbow', {
dir: 'h',
colorStops: [ '#817', ...PRISM, '#639' ]
}],
[ 'orangered', {
bgColor: '#3e2f29',
colorStops: [ 'OrangeRed' ]
}],
[ 'steelblue', {
bgColor: '#222c35',
colorStops: [ 'SteelBlue' ]
}]
];
// settings defaults
const DEFAULT_SETTINGS = {
alphaBars : false,
ansiBands : false,
barSpace : 0.1,
bgAlpha : 0.7,
channelLayout : CHANNEL_SINGLE,
colorMode : COLOR_GRADIENT,
fadePeaks : false,
fftSize : 8192,
fillAlpha : 1,
frequencyScale : SCALE_LOG,
gradient : GRADIENTS[0][0],
gravity : 3.8,
height : undefined,
ledBars : false,
linearAmplitude: false,
linearBoost : 1,
lineWidth : 0,
loRes : false,
lumiBars : false,
maxDecibels : -25,
maxFPS : 0,
maxFreq : 22000,
minDecibels : -85,
minFreq : 20,
mirror : 0,
mode : 0,
noteLabels : false,
outlineBars : false,
overlay : false,
peakFadeTime : 750,
peakHoldTime : 500,
peakLine : false,
radial : false,
radialInvert : false,
radius : 0.3,
reflexAlpha : 0.15,
reflexBright : 1,
reflexFit : true,
reflexRatio : 0,
roundBars : false,
showBgColor : true,
showFPS : false,
showPeaks : true,
showScaleX : true,
showScaleY : false,
smoothing : 0.5,
spinSpeed : 0,
splitGradient : false,
start : true,
trueLeds : false,
useCanvas : true,
volume : 1,
weightingFilter: FILTER_NONE,
width : undefined
};
// custom error messages
const ERR_AUDIO_CONTEXT_FAIL = [ 'ERR_AUDIO_CONTEXT_FAIL', 'Could not create audio context. Web Audio API not supported?' ],
ERR_INVALID_AUDIO_CONTEXT = [ 'ERR_INVALID_AUDIO_CONTEXT', 'Provided audio context is not valid' ],
ERR_UNKNOWN_GRADIENT = [ 'ERR_UNKNOWN_GRADIENT', 'Unknown gradient' ],
ERR_FREQUENCY_TOO_LOW = [ 'ERR_FREQUENCY_TOO_LOW', 'Frequency values must be >= 1' ],
ERR_INVALID_MODE = [ 'ERR_INVALID_MODE', 'Invalid mode' ],
ERR_REFLEX_OUT_OF_RANGE = [ 'ERR_REFLEX_OUT_OF_RANGE', 'Reflex ratio must be >= 0 and < 1' ],
ERR_INVALID_AUDIO_SOURCE = [ 'ERR_INVALID_AUDIO_SOURCE', 'Audio source must be an instance of HTMLMediaElement or AudioNode' ],
ERR_GRADIENT_INVALID_NAME = [ 'ERR_GRADIENT_INVALID_NAME', 'Gradient name must be a non-empty string' ],
ERR_GRADIENT_NOT_AN_OBJECT = [ 'ERR_GRADIENT_NOT_AN_OBJECT', 'Gradient options must be an object' ],
ERR_GRADIENT_MISSING_COLOR = [ 'ERR_GRADIENT_MISSING_COLOR', 'Gradient colorStops must be a non-empty array' ];
class AudioMotionError extends Error {
constructor( error, value ) {
const [ code, message ] = error;
super( message + ( value !== undefined ? `: ${value}` : '' ) );
this.name = 'AudioMotionError';
this.code = code;
}
}
// helper function - output deprecation warning message on console
const deprecate = ( name, alternative ) => console.warn( `${name} is deprecated. Use ${alternative} instead.` );
// helper function - check if a given object is empty (also returns `true` on null, undefined or any non-object value)
const isEmpty = obj => {
for ( const p in obj )
return false;
return true;
}
// helper function - validate a given value with an array of strings (by default, all lowercase)
// returns the validated value, or the first element of `list` if `value` is not found in the array
const validateFromList = ( value, list, modifier = 'toLowerCase' ) => list[ Math.max( 0, list.indexOf( ( '' + value )[ modifier ]() ) ) ];
// helper function - find the Y-coordinate of a point located between two other points, given its X-coordinate
const findY = ( x1, y1, x2, y2, x ) => y1 + ( y2 - y1 ) * ( x - x1 ) / ( x2 - x1 );
// Polyfill for Array.findLastIndex()
if ( ! Array.prototype.findLastIndex ) {
Array.prototype.findLastIndex = function( callback ) {
let index = this.length;
while ( index-- > 0 ) {
if ( callback( this[ index ] ) )
return index;
}
return -1;
}
}
// AudioMotionAnalyzer class
class AudioMotionAnalyzer {
/**
* CONSTRUCTOR
*
* @param {object} [container] DOM element where to insert the analyzer; if undefined, uses the document body
* @param {object} [options]
* @returns {object} AudioMotionAnalyzer object
*/
constructor( container, options = {} ) {
this._ready = false;
// Initialize internal objects
this._aux = {}; // auxiliary variables
this._canvasGradients = []; // CanvasGradient objects for channels 0 and 1
this._destroyed = false;
this._energy = { val: 0, peak: 0, hold: 0 };
this._flg = {}; // flags
this._fps = 0;
this._gradients = {}; // registered gradients
this._last = 0; // timestamp of last rendered frame
this._outNodes = []; // output nodes
this._ownContext = false;
this._selectedGrads = []; // names of the currently selected gradients for channels 0 and 1
this._sources = []; // input nodes
// Check if options object passed as first argument
if ( ! ( container instanceof Element ) ) {
if ( isEmpty( options ) && ! isEmpty( container ) )
options = container;
container = null;
}
this._ownCanvas = ! ( options.canvas instanceof HTMLCanvasElement );
// Create a new canvas or use the one provided by the user
const canvas = this._ownCanvas ? document.createElement('canvas') : options.canvas;
canvas.style = 'max-width: 100%;';
this._ctx = canvas.getContext('2d');
// Register built-in gradients
for ( const [ name, options ] of GRADIENTS )
this.registerGradient( name, options );
// Set container
this._container = container || ( ! this._ownCanvas && canvas.parentElement ) || document.body;
// Make sure we have minimal width and height dimensions in case of an inline container
this._defaultWidth = this._container.clientWidth || 640;
this._defaultHeight = this._container.clientHeight || 270;
// Use audio context provided by user, or create a new one
let audioCtx;
if ( options.source && ( audioCtx = options.source.context ) ) {
// get audioContext from provided source audioNode
}
else if ( audioCtx = options.audioCtx ) {
// use audioContext provided by user
}
else {
try {
audioCtx = new ( window.AudioContext || window.webkitAudioContext )();
this._ownContext = true;
}
catch( err ) {
throw new AudioMotionError( ERR_AUDIO_CONTEXT_FAIL );
}
}
// make sure audioContext is valid
if ( ! audioCtx.createGain )
throw new AudioMotionError( ERR_INVALID_AUDIO_CONTEXT );
/*
Connection routing:
===================
for dual channel layouts: +---> analyzer[0] ---+
| |
(source) ---> input ---> splitter ---+ +---> merger ---> output ---> (destination)
| |
+---> analyzer[1] ---+
for single channel layout:
(source) ---> input -----------------------> analyzer[0] ---------------------> output ---> (destination)
*/
// create the analyzer nodes, channel splitter and merger, and gain nodes for input/output connections
const analyzer = this._analyzer = [ audioCtx.createAnalyser(), audioCtx.createAnalyser() ];
const splitter = this._splitter = audioCtx.createChannelSplitter(2);
const merger = this._merger = audioCtx.createChannelMerger(2);
this._input = audioCtx.createGain();
this._output = audioCtx.createGain();
// connect audio source if provided in the options
if ( options.source )
this.connectInput( options.source );
// connect splitter -> analyzers
for ( const i of [0,1] )
splitter.connect( analyzer[ i ], i );
// connect merger -> output
merger.connect( this._output );
// connect output -> destination (speakers)
if ( options.connectSpeakers !== false )
this.connectOutput();
// create auxiliary canvases for the X-axis and radial scale labels
for ( const ctx of [ '_scaleX', '_scaleR' ] )
this[ ctx ] = document.createElement('canvas').getContext('2d');
// set fullscreen element (defaults to canvas)
this._fsEl = options.fsElement || canvas;
// Update canvas size on container / window resize and fullscreen events
// Fullscreen changes are handled quite differently across browsers:
// 1. Chromium browsers will trigger a `resize` event followed by a `fullscreenchange`
// 2. Firefox triggers the `fullscreenchange` first and then the `resize`
// 3. Chrome on Android (TV) won't trigger a `resize` event, only `fullscreenchange`
// 4. Safari won't trigger `fullscreenchange` events at all, and on iPadOS the `resize`
// event is triggered **on the window** only (last tested on iPadOS 14)
// helper function for resize events
const onResize = () => {
if ( ! this._fsTimeout ) {
// delay the resize to prioritize a possible following `fullscreenchange` event
this._fsTimeout = window.setTimeout( () => {
if ( ! this._fsChanging ) {
this._setCanvas( REASON_RESIZE );
this._fsTimeout = 0;
}
}, DEBOUNCE_TIMEOUT );
}
}
// if browser supports ResizeObserver, listen for resize on the container
if ( window.ResizeObserver ) {
this._observer = new ResizeObserver( onResize );
this._observer.observe( this._container );
}
// create an AbortController to remove event listeners on destroy()
this._controller = new AbortController();
const signal = this._controller.signal;
// listen for resize events on the window - required for fullscreen on iPadOS
window.addEventListener( EVENT_RESIZE, onResize, { signal } );
// listen for fullscreenchange events on the canvas - not available on Safari
canvas.addEventListener( EVENT_FULLSCREENCHANGE, () => {
// set flag to indicate a fullscreen change in progress
this._fsChanging = true;
// if there is a scheduled resize event, clear it
if ( this._fsTimeout )
window.clearTimeout( this._fsTimeout );
// update the canvas
this._setCanvas( REASON_FSCHANGE );
// delay clearing the flag to prevent any shortly following resize event
this._fsTimeout = window.setTimeout( () => {
this._fsChanging = false;
this._fsTimeout = 0;
}, DEBOUNCE_TIMEOUT );
}, { signal } );
// Resume audio context if in suspended state (browsers' autoplay policy)
const unlockContext = () => {
if ( audioCtx.state == 'suspended' )
audioCtx.resume();
window.removeEventListener( EVENT_CLICK, unlockContext );
}
window.addEventListener( EVENT_CLICK, unlockContext );
// reset FPS-related variables when window becomes visible (avoid FPS drop due to frames not rendered while hidden)
document.addEventListener( 'visibilitychange', () => {
if ( document.visibilityState != 'hidden' ) {
this._frames = 0;
this._time = performance.now();
}
}, { signal } );
// Set configuration options and use defaults for any missing properties
this._setProps( options, true );
// Add canvas to the container (only when canvas not provided by user)
if ( this.useCanvas && this._ownCanvas )
this._container.appendChild( canvas );
// Finish canvas setup
this._ready = true;
this._setCanvas( REASON_CREATE );
}
/**
* ==========================================================================
*
* PUBLIC PROPERTIES GETTERS AND SETTERS
*
* ==========================================================================
*/
get alphaBars() {
return this._alphaBars;
}
set alphaBars( value ) {
this._alphaBars = !! value;
this._calcBars();
}
get ansiBands() {
return this._ansiBands;
}
set ansiBands( value ) {
this._ansiBands = !! value;
this._calcBars();
}
get barSpace() {
return this._barSpace;
}
set barSpace( value ) {
this._barSpace = +value || 0;
this._calcBars();
}
get channelLayout() {
return this._chLayout;
}
set channelLayout( value ) {
this._chLayout = validateFromList( value, [ CHANNEL_SINGLE, CHANNEL_HORIZONTAL, CHANNEL_VERTICAL, CHANNEL_COMBINED ] );
// update node connections
this._input.disconnect();
this._input.connect( this._chLayout != CHANNEL_SINGLE ? this._splitter : this._analyzer[0] );
this._analyzer[0].disconnect();
if ( this._outNodes.length ) // connect analyzer only if the output is connected to other nodes
this._analyzer[0].connect( this._chLayout != CHANNEL_SINGLE ? this._merger : this._output );
this._calcBars();
this._makeGrad();
}
get colorMode() {
return this._colorMode;
}
set colorMode( value ) {
this._colorMode = validateFromList( value, [ COLOR_GRADIENT, COLOR_BAR_INDEX, COLOR_BAR_LEVEL ] );
}
get fadePeaks() {
return this._fadePeaks;
}
set fadePeaks( value ) {
this._fadePeaks = !! value;
}
get fftSize() {
return this._analyzer[0].fftSize;
}
set fftSize( value ) {
for ( const i of [0,1] )
this._analyzer[ i ].fftSize = value;
const binCount = this._analyzer[0].frequencyBinCount;
this._fftData = [ new Float32Array( binCount ), new Float32Array( binCount ) ];
this._calcBars();
}
get frequencyScale() {
return this._frequencyScale;
}
set frequencyScale( value ) {
this._frequencyScale = validateFromList( value, [ SCALE_LOG, SCALE_BARK, SCALE_MEL, SCALE_LINEAR ] );
this._calcBars();
}
get gradient() {
return this._selectedGrads[0];
}
set gradient( value ) {
this._setGradient( value );
}
get gradientLeft() {
return this._selectedGrads[0];
}
set gradientLeft( value ) {
this._setGradient( value, 0 );
}
get gradientRight() {
return this._selectedGrads[1];
}
set gradientRight( value ) {
this._setGradient( value, 1 );
}
get gravity() {
return this._gravity;
}
set gravity( value ) {
this._gravity = value > 0 ? +value : this._gravity || DEFAULT_SETTINGS.gravity;
}
get height() {
return this._height;
}
set height( h ) {
this._height = h;
this._setCanvas( REASON_USER );
}
get ledBars() {
return this._showLeds;
}
set ledBars( value ) {
this._showLeds = !! value;
this._calcBars();
}
get linearAmplitude() {
return this._linearAmplitude;
}
set linearAmplitude( value ) {
this._linearAmplitude = !! value;
}
get linearBoost() {
return this._linearBoost;
}
set linearBoost( value ) {
this._linearBoost = value >= 1 ? +value : 1;
}
get lineWidth() {
return this._lineWidth;
}
set lineWidth( value ) {
this._lineWidth = +value || 0;
}
get loRes() {
return this._loRes;
}
set loRes( value ) {
this._loRes = !! value;
this._setCanvas( REASON_LORES );
}
get lumiBars() {
return this._lumiBars;
}
set lumiBars( value ) {
this._lumiBars = !! value;
this._calcBars();
this._makeGrad();
}
get maxDecibels() {
return this._analyzer[0].maxDecibels;
}
set maxDecibels( value ) {
for ( const i of [0,1] )
this._analyzer[ i ].maxDecibels = value;
}
get maxFPS() {
return this._maxFPS;
}
set maxFPS( value ) {
this._maxFPS = value < 0 ? 0 : +value || 0;
}
get maxFreq() {
return this._maxFreq;
}
set maxFreq( value ) {
if ( value < 1 )
throw new AudioMotionError( ERR_FREQUENCY_TOO_LOW );
else {
this._maxFreq = Math.min( value, this.audioCtx.sampleRate / 2 );
this._calcBars();
}
}
get minDecibels() {
return this._analyzer[0].minDecibels;
}
set minDecibels( value ) {
for ( const i of [0,1] )
this._analyzer[ i ].minDecibels = value;
}
get minFreq() {
return this._minFreq;
}
set minFreq( value ) {
if ( value < 1 )
throw new AudioMotionError( ERR_FREQUENCY_TOO_LOW );
else {
this._minFreq = +value;
this._calcBars();
}
}
get mirror() {
return this._mirror;
}
set mirror( value ) {
this._mirror = Math.sign( value ) | 0; // ensure only -1, 0 or 1
this._calcBars();
this._makeGrad();
}
get mode() {
return this._mode;
}
set mode( value ) {
const mode = value | 0;
if ( mode >= 0 && mode <= 10 && mode != 9 ) {
this._mode = mode;
this._calcBars();
this._makeGrad();
}
else
throw new AudioMotionError( ERR_INVALID_MODE, value );
}
get noteLabels() {
return this._noteLabels;
}
set noteLabels( value ) {
this._noteLabels = !! value;
this._createScales();
}
get outlineBars() {
return this._outlineBars;
}
set outlineBars( value ) {
this._outlineBars = !! value;
this._calcBars();
}
get peakFadeTime() {
return this._peakFadeTime;
}
set peakFadeTime( value ) {
this._peakFadeTime = value >= 0 ? +value : this._peakFadeTime || DEFAULT_SETTINGS.peakFadeTime;
}
get peakHoldTime() {
return this._peakHoldTime;
}
set peakHoldTime( value ) {
this._peakHoldTime = +value || 0;
}
get peakLine() {
return this._peakLine;
}
set peakLine( value ) {
this._peakLine = !! value;
}
get radial() {
return this._radial;
}
set radial( value ) {
this._radial = !! value;
this._calcBars();
this._makeGrad();
}
get radialInvert() {
return this._radialInvert;
}
set radialInvert( value ) {
this._radialInvert = !! value;
this._calcBars();
this._makeGrad();
}
get radius() {
return this._radius;
}
set radius( value ) {
this._radius = +value || 0;
this._calcBars();
this._makeGrad();
}
get reflexRatio() {
return this._reflexRatio;
}
set reflexRatio( value ) {
value = +value || 0;
if ( value < 0 || value >= 1 )
throw new AudioMotionError( ERR_REFLEX_OUT_OF_RANGE );
else {
this._reflexRatio = value;
this._calcBars();
this._makeGrad();
}
}
get roundBars() {
return this._roundBars;
}
set roundBars( value ) {
this._roundBars = !! value;
this._calcBars();
}
get smoothing() {
return this._analyzer[0].smoothingTimeConstant;
}
set smoothing( value ) {
for ( const i of [0,1] )
this._analyzer[ i ].smoothingTimeConstant = value;
}
get spinSpeed() {
return this._spinSpeed;
}
set spinSpeed( value ) {
value = +value || 0;
if ( this._spinSpeed === undefined || value == 0 )
this._spinAngle = -HALF_PI; // initialize or reset the rotation angle
this._spinSpeed = value;
}
get splitGradient() {
return this._splitGradient;
}
set splitGradient( value ) {
this._splitGradient = !! value;
this._makeGrad();
}
get stereo() {
deprecate( 'stereo', 'channelLayout' );
return this._chLayout != CHANNEL_SINGLE;
}
set stereo( value ) {
deprecate( 'stereo', 'channelLayout' );
this.channelLayout = value ? CHANNEL_VERTICAL : CHANNEL_SINGLE;
}
get trueLeds() {
return this._trueLeds;
}
set trueLeds( value ) {
this._trueLeds = !! value;
}
get volume() {
return this._output.gain.value;
}
set volume( value ) {
this._output.gain.value = value;
}
get weightingFilter() {
return this._weightingFilter;
}
set weightingFilter( value ) {
this._weightingFilter = validateFromList( value, [ FILTER_NONE, FILTER_A, FILTER_B, FILTER_C, FILTER_D, FILTER_468 ], 'toUpperCase' );
}
get width() {
return this._width;
}
set width( w ) {
this._width = w;
this._setCanvas( REASON_USER );
}
// Read only properties
get audioCtx() {
return this._input.context;
}
get canvas() {
return this._ctx.canvas;
}
get canvasCtx() {
return this._ctx;
}
get connectedSources() {
return this._sources;
}
get connectedTo() {
return this._outNodes;
}
get fps() {
return this._fps;
}
get fsHeight() {
return this._fsHeight;
}
get fsWidth() {
return this._fsWidth;
}
get isAlphaBars() {
return this._flg.isAlpha;
}
get isBandsMode() {
return this._flg.isBands;
}
get isDestroyed() {
return this._destroyed;
}
get isFullscreen() {
return this._fsEl && ( document.fullscreenElement || document.webkitFullscreenElement ) === this._fsEl;
}
get isLedBars() {
return this._flg.isLeds;
}
get isLumiBars() {
return this._flg.isLumi;
}
get isOctaveBands() {
return this._flg.isOctaves;
}
get isOn() {
return !! this._runId;
}
get isOutlineBars() {
return this._flg.isOutline;
}
get pixelRatio() {
return this._pixelRatio;
}
get isRoundBars() {
return this._flg.isRound;
}
static get version() {
return VERSION;
}
/**
* ==========================================================================
*
* PUBLIC METHODS
*
* ==========================================================================
*/
/**
* Connects an HTML media element or audio node to the analyzer
*
* @param {object} an instance of HTMLMediaElement or AudioNode
* @returns {object} a MediaElementAudioSourceNode object if created from HTML element, or the same input object otherwise
*/
connectInput( source ) {
const isHTML = source instanceof HTMLMediaElement;
if ( ! ( isHTML || source.connect ) )
throw new AudioMotionError( ERR_INVALID_AUDIO_SOURCE );
// if source is an HTML element, create an audio node for it; otherwise, use the provided audio node
const node = isHTML ? this.audioCtx.createMediaElementSource( source ) : source;
if ( ! this._sources.includes( node ) ) {
node.connect( this._input );
this._sources.push( node );
}
return node;
}
/**
* Connects the analyzer output to another audio node
*
* @param [{object}] an AudioNode; if undefined, the output is connected to the audio context destination (speakers)
*/
connectOutput( node = this.audioCtx.destination ) {
if ( this._outNodes.includes( node ) )
return;
this._output.connect( node );
this._outNodes.push( node );
// when connecting the first node, also connect the analyzer nodes to the merger / output nodes
if ( this._outNodes.length == 1 ) {
for ( const i of [0,1] )
this._analyzer[ i ].connect( ( this._chLayout == CHANNEL_SINGLE && ! i ? this._output : this._merger ), 0, i );
}
}
/**
* Destroys instance
*/
destroy() {
if ( ! this._ready )
return;
const { audioCtx, canvas, _controller, _input, _merger, _observer, _ownCanvas, _ownContext, _splitter } = this;
this._destroyed = true;
this._ready = false;
this.stop();
// remove event listeners
_controller.abort();
if ( _observer )
_observer.disconnect();
// clear callbacks and fullscreen element
this.onCanvasResize = null;
this.onCanvasDraw = null;
this._fsEl = null;
// disconnect audio nodes
this.disconnectInput();
this.disconnectOutput(); // also disconnects analyzer nodes
_input.disconnect();
_splitter.disconnect();
_merger.disconnect();
// if audio context is our own (not provided by the user), close it
if ( _ownContext )
audioCtx.close();
// remove canvas from the DOM (if not provided by the user)
if ( _ownCanvas )
canvas.remove();
// reset flags
this._calcBars();
}
/**
* Disconnects audio sources from the analyzer
*
* @param [{object|array}] a connected AudioNode object or an array of such objects; if falsy, all connected nodes are disconnected
* @param [{boolean}] if true, stops/releases audio tracks from disconnected media streams (e.g. microphone)
*/
disconnectInput( sources, stopTracks ) {
if ( ! sources )
sources = Array.from( this._sources );
else if ( ! Array.isArray( sources ) )
sources = [ sources ];
for ( const node of sources ) {
const idx = this._sources.indexOf( node );
if ( stopTracks && node.mediaStream ) {
for ( const track of node.mediaStream.getAudioTracks() ) {
track.stop();
}
}
if ( idx >= 0 ) {
node.disconnect( this._input );
this._sources.splice( idx, 1 );
}
}
}
/**
* Disconnects the analyzer output from other audio nodes
*
* @param [{object}] a connected AudioNode object; if undefined, all connected nodes are disconnected
*/
disconnectOutput( node ) {
if ( node && ! this._outNodes.includes( node ) )
return;
this._output.disconnect( node );
this._outNodes = node ? this._outNodes.filter( e => e !== node ) : [];
// if disconnected from all nodes, also disconnect the analyzer nodes so they keep working on Chromium
// see https://github.com/hvianna/audioMotion-analyzer/issues/13#issuecomment-808764848
if ( this._outNodes.length == 0 ) {
for ( const i of [0,1] )
this._analyzer[ i ].disconnect();
}
}
/**
* Returns analyzer bars data
*
* @returns {array}
*/
getBars() {
return Array.from( this._bars, ( { posX, freq, freqLo, freqHi, hold, peak, value } ) => ( { posX, freq, freqLo, freqHi, hold, peak, value } ) );
}
/**
* Returns the energy of a frequency, or average energy of a range of frequencies
*
* @param [{number|string}] single or initial frequency (Hz), or preset name; if undefined, returns the overall energy
* @param [{number}] ending frequency (Hz)
* @returns {number|null} energy value (0 to 1) or null, if the specified preset is unknown
*/
getEnergy( startFreq, endFreq ) {
if ( startFreq === undefined )
return this._energy.val;
// if startFreq is a string, check for presets
if ( startFreq != +startFreq ) {
if ( startFreq == 'peak' )
return this._energy.peak;
const presets = {
bass: [ 20, 250 ],
lowMid: [ 250, 500 ],
mid: [ 500, 2e3 ],
highMid: [ 2e3, 4e3 ],
treble: [ 4e3, 16e3 ]
}
if ( ! presets[ startFreq ] )
return null;
[ startFreq, endFreq ] = presets[ startFreq ];
}
const startBin = this._freqToBin( startFreq ),
endBin = endFreq ? this._freqToBin( endFreq ) : startBin,
chnCount = this._chLayout == CHANNEL_SINGLE ? 1 : 2;
let energy = 0;
for ( let channel = 0; channel < chnCount; channel++ ) {
for ( let i = startBin; i <= endBin; i++ )
energy += this._normalizedB( this._fftData[ channel ][ i ] );
}
return energy / ( endBin - startBin + 1 ) / chnCount;
}
/**
* Returns current analyzer settings in object format
*
* @param [{string|array}] a property name or an array of property names to not include in the returned object
* @returns {object} Options object
*/
getOptions( ignore ) {
if ( ! Array.isArray( ignore ) )
ignore = [ ignore ];
let options = {};
for ( const prop of Object.keys( DEFAULT_SETTINGS ) ) {
if ( ! ignore.includes( prop ) ) {
if ( prop == 'gradient' && this.gradientLeft != this.gradientRight ) {
options.gradientLeft = this.gradientLeft;
options.gradientRight = this.gradientRight;
}
else if ( prop != 'start' )
options[ prop ] = this[ prop ];
}
}
return options;
}
/**
* Registers a custom gradient
*
* @param {string} name
* @param {object} options
*/
registerGradient( name, options ) {
if ( typeof name != 'string' || name.trim().length == 0 )
throw new AudioMotionError( ERR_GRADIENT_INVALID_NAME );
if ( typeof options != 'object' )
throw new AudioMotionError( ERR_GRADIENT_NOT_AN_OBJECT );
const { colorStops } = options;
if ( ! Array.isArray( colorStops ) || ! colorStops.length )
throw new AudioMotionError( ERR_GRADIENT_MISSING_COLOR );
const count = colorStops.length,
isInvalid = val => +val != val || val < 0 || val > 1;
// normalize all colorStops as objects with `pos`, `color` and `level` properties
colorStops.forEach( ( colorStop, index ) => {
const pos = index / Math.max( 1, count - 1 );
if ( typeof colorStop != 'object' ) // only color string was defined
colorStops[ index ] = { pos, color: colorStop };
else if ( isInvalid( colorStop.pos ) )
colorStop.pos = pos;
if ( isInvalid( colorStop.level ) )
colorStops[ index ].level = 1 - index / count;
});
// make sure colorStops is in descending `level` order and that the first one has `level == 1`
// this is crucial for proper operation of 'bar-level' colorMode!
colorStops.sort( ( a, b ) => a.level < b.level ? 1 : a.level > b.level ? -1 : 0 );
colorStops[0].level = 1;
this._gradients[ name ] = {
bgColor: options.bgColor || GRADIENT_DEFAULT_BGCOLOR,
dir: options.dir,
colorStops: colorStops
};
// if the registered gradient is one of the currently selected gradients, regenerate them
if ( this._selectedGrads.includes( name ) )
this._makeGrad();
}
/**
* Set dimensions of analyzer's canvas
*
* @param {number} w width in pixels
* @param {number} h height in pixels
*/
setCanvasSize( w, h ) {
this._width = w;
this._height = h;
this._setCanvas( REASON_USER );
}
/**
* Set desired frequency range
*
* @param {number} min lowest frequency represented in the x-axis
* @param {number} max highest frequency represented in the x-axis
*/
setFreqRange( min, max ) {
if ( min < 1 || max < 1 )
throw new AudioMotionError( ERR_FREQUENCY_TOO_LOW );
else {
this._minFreq = Math.min( min, max );
this.maxFreq = Math.max( min, max ); // use the setter for maxFreq
}
}
/**
* Set custom parameters for LED effect
* If called with no arguments or if any property is invalid, clears any previous custom parameters
*
* @param {object} [params]
*/
setLedParams( params ) {
let maxLeds, spaceV, spaceH;
// coerce parameters to Number; `NaN` results are rejected in the condition below
if ( params ) {
maxLeds = params.maxLeds | 0, // ensure integer
spaceV = +params.spaceV,
spaceH = +params.spaceH;
}
this._ledParams = maxLeds > 0 && spaceV > 0 && spaceH >= 0 ? [ maxLeds, spaceV, spaceH ] : undefined;
this._calcBars();
}
/**
* Shorthand function for setting several options at once
*
* @param {object} options
*/
setOptions( options ) {
this._setProps( options );
}
/**
* Adjust the analyzer's sensitivity
*
* @param {number} min minimum decibels value
* @param {number} max maximum decibels value
*/
setSensitivity( min, max ) {
for ( const i of [0,1] ) {
this._analyzer[ i ].minDecibels = Math.min( min, max );
this._analyzer[ i ].maxDecibels = Math.max( min, max );
}
}
/**
* Start the analyzer
*/
start(newContainer) {
if (newContainer) {
this.setNewContainer(newContainer);
}
this.toggleAnalyzer( true );
}
setNewContainer( newContainer ) {
this._container = newContainer;
this._defaultWidth = this._container.clientWidth || 640;
this._defaultHeight = this._container.clientHeight || 270;
this._ownCanvas = true;
const canvas = document.createElement('canvas');
canvas.style = 'max-width: 100%;';
this._ctx = canvas.getContext('2d');
this._fsEl = canvas;
this._controller = new AbortController();
const signal = this._controller.signal;
canvas.addEventListener( EVENT_FULLSCREENCHANGE, () => {
// set flag to indicate a fullscreen change in progress
this._fsChanging = true;
// if there is a scheduled resize event, clear it
if ( this._fsTimeout )
window.clearTimeout( this._fsTimeout );
// update the canvas
this._setCanvas( REASON_FSCHANGE );
// delay clearing the flag to prevent any shortly following resize event
this._fsTimeout = window.setTimeout( () => {
this._fsChanging = false;
this._fsTimeout = 0;
}, DEBOUNCE_TIMEOUT );
}, { signal } );
if ( this.useCanvas && this._ownCanvas )
this._container.appendChild( canvas );
this._ready = true;
this._setCanvas( REASON_CREATE );
}
/**
* Stop the analyzer
*/
stop() {
this.toggleAnalyzer( false );
}
/**
* Start / stop canvas animation
*
* @param {boolean} [force] if undefined, inverts the current state
* @returns {boolean} resulting state after the change
*/
toggleAnalyzer( force ) {
const hasStarted = this.isOn;
if ( force === undefined )
force = ! hasStarted;
// Stop the analyzer if it was already running and must be disabled
if ( hasStarted && ! force ) {
cancelAnimationFrame( this._runId );
this._runId = 0;
}
// Start the analyzer if it was stopped and must be enabled
else if ( ! hasStarted && force && ! this._destroyed ) {
this._frames = 0;
this._time = performance.now();
this._runId = requestAnimationFrame( timestamp => this._draw( timestamp ) ); // arrow function preserves the scope of *this*
}
return this.isOn;
}
/**
* Toggles canvas full-screen mode
*/
toggleFullscreen() {
if ( this.isFullscreen ) {
if ( document.exitFullscreen )
document.exitFullscreen();
else if ( document.webkitExitFullscreen )
document.webkitExitFullscreen();
}
else {
const fsEl = this._fsEl;
if ( ! fsEl )
return;
if ( fsEl.requestFullscreen )
fsEl.requestFullscreen();
else if ( fsEl.webkitRequestFullscreen )
fsEl.webkitRequestFullscreen();
}
}
/**
* ==========================================================================
*
* PRIVATE METHODS
*
* ==========================================================================
*/
/**
* Return the frequency (in Hz) for a given FFT bin
*/
_binToFreq( bin ) {
return bin * this.audioCtx.sampleRate / this.fftSize || 1; // returns 1 for bin 0
}
/**
* Compute all internal data required for the analyzer, based on its current settings
*/
_calcBars() {
const bars = this._bars = []; // initialize object property
if ( ! this._ready ) {
this._flg = { isAlpha: false, isBands: false, isLeds: false, isLumi: false, isOctaves: false, isOutline: false, isRound: false, noLedGap: false };
return;
}
const { _ansiBands, _barSpace, canvas, _chLayout, _maxFreq, _minFreq, _mirror, _mode, _radial, _radialInvert, _reflexRatio } = this,
centerX = canvas.width >> 1,
centerY = canvas.height >> 1,
isDualVertical = _chLayout == CHANNEL_VERTICAL && ! _radial,
isDualHorizontal = _chLayout == CHANNEL_HORIZONTAL,
// COMPUTE FLAGS
isBands = _mode % 10 != 0, // true for modes 1 to 9
isOctaves = isBands && this._frequencyScale == SCALE_LOG,
isLeds = this._showLeds && isBands && ! _radial,
isLumi = this._lumiBars && isBands && ! _radial,
isAlpha = this._alphaBars && ! isLumi && _mode != MODE_GRAPH,
isOutline = this._outlineBars && isBands && ! isLumi && ! isLeds,
isRound = this._roundBars && isBands && ! isLumi && ! isLeds,
noLedGap = _chLayout != CHANNEL_VERTICAL || _reflexRatio > 0 && ! isLumi,
// COMPUTE AUXILIARY VALUES
// channelHeight is the total canvas height dedicated to each channel, including the reflex area, if any)
channelHeight = canvas.height - ( isDualVertical && ! isLeds ? .5 : 0 ) >> isDualVertical,
// analyzerHeight is the effective height used to render the analyzer, excluding the reflex area
analyzerHeight = channelHeight * ( isLumi || _radial ? 1 : 1 - _reflexRatio ) | 0,
analyzerWidth = canvas.width - centerX * ( isDualHorizontal || _mirror != 0 ),
// channelGap is **0** if isLedDisplay == true (LEDs already have spacing); **1** if canvas height is odd (windowed); **2** if it's even
// TODO: improve this, make it configurable?
channelGap = isDualVertical ? canvas.height - channelHeight * 2 : 0,
initialX = centerX * ( _mirror == -1 && ! isDualHorizontal && ! _radial );
let innerRadius = Math.min( canvas.width, canvas.height ) * .375 * ( _chLayout == CHANNEL_VERTICAL ? 1 : this._radius ) | 0,
outerRadius = Math.min( centerX, centerY );
if ( _radialInvert && _chLayout != CHANNEL_VERTICAL )
[ innerRadius, outerRadius ] = [ outerRadius, innerRadius ];
/**
* CREATE ANALYZER BANDS
*
* USES:
* analyzerWidth
* initialX
* isBands
* isOctaves
*
* GENERATES:
* bars (populates this._bars)
* bardWidth
* scaleMin
* unitWidth
*/
// helper function to add a bar to the bars array
// bar object format:
// {
// posX,
// freq,
// freqLo,
// freqHi,
// binLo,
// binHi,
// ratioLo,
// ratioHi,
// peak, // peak value
// hold, // peak hold frames (negative value indicates peak falling / fading)
// alpha, // peak alpha (used by fadePeaks)
// value // current bar value
// }
const barsPush = args => bars.push( { ...args, peak: [0,0], hold: [0], alpha: [0], value: [0] } );
/*
A simple interpolation is used to obtain an approximate amplitude value for any given frequency,
from the available FFT data. We find the FFT bin which closer matches the desired frequency and
interpolate its value with that of the next adjacent bin, like so:
v = v0 + ( v1 - v0 ) * ( log2( f / f0 ) / log2( f1 / f0 ) )
\__________________________________/
|
ratio
where:
f - desired frequency
v - amplitude (volume) of desired frequency
f0 - frequency represented by the lower FFT bin
f1 - frequency represented by the upper FFT bin
v0 - amplitude of f0
v1 - amplitude of f1
ratio is calculated in advance here, to reduce computational complexity during real-time rendering.
*/
// helper function to calculate FFT bin and interpolation ratio for a given frequency
const calcRatio = freq => {
const bin = this._freqToBin( freq, 'floor' ), // find closest FFT bin
lower = this._binToFreq( bin ),
upper = this._binToFreq( bin + 1 ),
ratio = Math.log2( freq / lower ) / Math.log2( upper / lower );
return [ bin, ratio ];
}
let barWidth, scaleMin, unitWidth;
if ( isOctaves ) {
// helper function to round a value to a given number of significant digits
// `atLeast` set to true prevents reducing the number of integer significant digits
const roundSD = ( value, digits, atLeast ) => +value.toPrecision( atLeast ? Math.max( digits, 1 + Math.log10( value ) | 0 ) : digits );
// helper function to find the nearest preferred number (Renard series) for a given value
const nearestPreferred = value => {
// R20 series is used here, as it provides closer approximations for 1/2 octave bands (non-standard)
const preferred = [ 1, 1.12, 1.25, 1.4, 1.6, 1.8, 2, 2.24, 2.5, 2.8, 3.15, 3.55, 4, 4.5, 5, 5.6, 6.3, 7.1, 8, 9, 10 ],
power = Math.log10( value ) | 0,
normalized = value / 10 ** power;
let i = 1;
while ( i < preferred.length && normalized > preferred[ i ] )
i++;
if ( normalized - preferred[ i - 1 ] < preferred[ i ] - normalized )
i--;
return ( preferred[ i ] * 10 ** ( power + 5 ) | 0 ) / 1e5; // keep 5 significant digits
}
// ANSI standard octave bands use the base-10 frequency ratio, as preferred by [ANSI S1.11-2004, p.2]
// The equal-tempered scale uses the base-2 ratio
const bands = [0,24,12,8,6,4,3,2,1][ _mode ],
bandWidth = _ansiBands ? 10 ** ( 3 / ( bands * 10 ) ) : 2 ** ( 1 / bands ), // 10^(3/10N) or 2^(1/N)
halfBand = bandWidth ** .5;
let analyzerBars = [],
currFreq = _ansiBands ? 7.94328235 / ( bands % 2 ? 1 : halfBand ) : C_1;
// For ANSI bands with even denominators (all except 1/1 and 1/3), the reference frequency (1 kHz)
// must fall on the edges of a pair of adjacent bands, instead of midband [ANSI S1.11-2004, p.2]
// In the equal-tempered scale, all midband frequencies represent a musical note or quarter-tone.
do {
let freq = currFreq; // midband frequency
const freqLo = roundSD( freq / halfBand, 4, true ), // lower edge frequency
freqHi = roundSD( freq * halfBand, 4, true ), // upper edge frequency
[ binLo, ratioLo ] = calcRatio( freqLo ),
[ binHi, ratioHi ] = calcRatio( freqHi );
// for 1/1, 1/2 and 1/3 ANSI bands, use the preferred numbers to find the nominal midband frequency
// for 1/4 to 1/24, round to 2 or 3 significant digits, according to the MSD [ANSI S1.11-2004, p.12]
if ( _ansiBands )
freq = bands < 4 ? nearestPreferred( freq ) : roundSD( freq, freq.toString()[0] < 5 ? 3 : 2 );
else
freq = roundSD( freq, 4, true );
if ( freq >= _minFreq )
barsPush( { posX: 0, freq, freqLo, freqHi, binLo, binHi, ratioLo, ratioHi } );
currFreq *= bandWidth;
} while ( currFreq <= _maxFreq );
barWidth = analyzerWidth / bars.length;
bars.forEach( ( bar, index ) => bar.posX = initialX + index * barWidth );
const firstBar = bars[0],
lastBar = bars[ bars.length - 1 ];
scaleMin = this._freqScaling( firstBar.freqLo );
unitWidth = analyzerWidth / ( this._freqScaling( lastBar.freqHi ) - scaleMin );
// clamp edge frequencies to minFreq / maxFreq, if necessary
// this is done after computing scaleMin and unitWidth, for the proper positioning of labels on the X-axis
if ( firstBar.freqLo < _minFreq ) {
firstBar.freqLo = _minFreq;
[ firstBar.binLo, firstBar.ratioLo ] = calcRatio( _minFreq );
}
if ( lastBar.freqHi > _maxFreq ) {
lastBar.freqHi = _maxFreq;
[ lastBar.binHi, lastBar.ratioHi ] = calcRatio( _maxFreq );
}
}
else if ( isBands ) { // a bands mode is selected, but frequency scale is not logarithmic
const bands = [0,24,12,8,6,4,3,2,1][ _mode ] * 10;
const invFreqScaling = x => {
switch ( this._frequencyScale ) {
case SCALE_BARK :
return 1960 / ( 26.81 / ( x + .53 ) - 1 );
case SCALE_MEL :
return 700 * ( 2 ** x - 1 );
case SCALE_LINEAR :
return x;
}
}
barWidth = analyzerWidth / bands;
scaleMin = this._freqScaling( _minFreq );
unitWidth = analyzerWidth / ( this._freqScaling( _maxFreq ) - scaleMin );
for ( let i = 0, posX = 0; i < bands; i++, posX += barWidth ) {
const freqLo = invFreqScaling( scaleMin + posX / unitWidth ),
freq = invFreqScaling( scaleMin + ( posX + barWidth / 2 ) / unitWidth ),
freqHi = invFreqScaling( scaleMin + ( posX + barWidth ) / unitWidth ),
[ binLo, ratioLo ] = calcRatio( freqLo ),
[ binHi, ratioHi ] = calcRatio( freqHi );
barsPush( { posX: initialX + posX, freq, freqLo, freqHi, binLo, binHi, ratioLo, ratioHi } );
}
}
else { // Discrete frequencies modes
barWidth = 1;
scaleMin = this._freqScaling( _minFreq );
unitWidth = analyzerWidth / ( this._freqScaling( _maxFreq ) - scaleMin );
const minIndex = this._freqToBin( _minFreq, 'floor' ),
maxIndex = this._freqToBin( _maxFreq );
let lastPos = -999;
for ( let i = minIndex; i <= maxIndex; i++ ) {
const freq = this._binToFreq( i ), // frequency represented by this index
posX = initialX + Math.round( unitWidth * ( this._freqScaling( freq ) - scaleMin ) ); // avoid fractionary pixel values
// if it's on a different X-coordinate, create a new bar for this frequency
if ( posX > lastPos ) {
barsPush( { posX, freq, freqLo: freq, freqHi: freq, binLo: i, binHi: i, ratioLo: 0, ratioHi: 0 } );
lastPos = posX;
} // otherwise, add this frequency to the last bar's range
else if ( bars.length ) {
const lastBar = bars[ bars.length - 1 ];
lastBar.binHi = i;
lastBar.freqHi = freq;
lastBar.freq = ( lastBar.freqLo * freq ) ** .5; // compute center frequency (geometric mean)
}
}
}
/**
* COMPUTE ATTRIBUTES FOR THE LED BARS
*
* USES:
* analyzerHeight
* barWidth
* noLedGap
*
* GENERATES:
* spaceH
* spaceV
* this._leds
*/
let spaceH = 0,
spaceV = 0;
if ( isLeds ) {
// adjustment for high pixel-ratio values on low-resolution screens (Android TV)
const dPR = this._pixelRatio / ( window.devicePixelRatio > 1 && window.screen.height <= 540 ? 2 : 1 );
const params = [ [],
[ 128, 3, .45 ], // mode 1
[ 128, 4, .225 ], // mode 2
[ 96, 6, .225 ], // mode 3
[ 80, 6, .225 ], // mode 4
[ 80, 6, .125 ], // mode 5
[ 64, 6, .125 ], // mode 6
[ 48, 8, .125 ], // mode 7
[ 24, 16, .125 ], // mode 8
];
// use custom LED parameters if set, or the default parameters for the current mode
const customParams = this._ledParams,
[ maxLeds, spaceVRatio, spaceHRatio ] = customParams || params[ _mode ];
let ledCount, maxHeight = analyzerHeight;
if ( customParams ) {
const minHeight = 2 * dPR;
let blockHeight;
ledCount = maxLeds + 1;
do {
ledCount--;
blockHeight = maxHeight / ledCount / ( 1 + spaceVRatio );
spaceV = blockHeight * spaceVRatio;
} while ( ( blockHeight < minHeight || spaceV < minHeight ) && ledCount > 1 );
}
else {
// calculate vertical spacing - aim for the reference ratio, but make sure it's at least 2px
const refRatio = 540 / spaceVRatio;
spaceV = Math.min( spaceVRatio * dPR, Math.max( 2, maxHeight / refRatio + .1 | 0 ) );
}
// remove the extra spacing below the last line of LEDs
if ( noLedGap )
maxHeight += spaceV;
// recalculate the number of leds, considering the effective spaceV
if ( ! customParams )
ledCount = Math.min( maxLeds, maxHeight / ( spaceV * 2 ) | 0 );
spaceH = spaceHRatio >= 1 ? spaceHRatio : barWidth * spaceHRatio;
this._leds = [
ledCount,
spaceH,
spaceV,
maxHeight / ledCount - spaceV // ledHeight
];
}
// COMPUTE ADDITIONAL BAR POSITIONING, ACCORDING TO THE CURRENT SETTINGS
// uses: _barSpace, barWidth, spaceH
const barSpacePx = Math.min( barWidth - 1, _barSpace * ( _barSpace > 0 && _barSpace < 1 ? barWidth : 1 ) );
if ( isBands )
barWidth -= Math.max( isLeds ? spaceH : 0, barSpacePx );
bars.forEach( ( bar, index ) => {
let posX = bar.posX,
width = barWidth;
// in bands modes we need to update bar.posX to account for bar/led spacing
if ( isBands ) {
if ( _barSpace == 0 && ! isLeds ) {
// when barSpace == 0 use integer values for perfect gapless positioning
posX |= 0;
width |= 0;
if ( index > 0 && posX > bars[ index - 1 ].posX + bars[ index - 1 ].width ) {
posX--;
width++;
}
}
else
posX += Math.max( ( isLeds ? spaceH : 0 ), barSpacePx ) / 2;
bar.posX = posX; // update
}
bar.barCenter = posX + ( barWidth == 1 ? 0 : width / 2 );
bar.width = width;
});
// COMPUTE CHANNEL COORDINATES (uses spaceV)
const channelCoords = [];
for ( const channel of [0,1] ) {
const channelTop = _chLayout == CHANNEL_VERTICAL ? ( channelHeight + channelGap ) * channel : 0,
channelBottom = channelTop + channelHeight,
analyzerBottom = channelTop + analyzerHeight - ( ! isLeds || noLedGap ? 0 : spaceV );
channelCoords.push( { channelTop, channelBottom, analyzerBottom } );
}
// SAVE INTERNAL PROPERTIES
this._aux = { analyzerHeight, analyzerWidth, centerX, centerY, channelCoords, channelHeight, channelGap, initialX, innerRadius, outerRadius, scaleMin, unitWidth };
this._flg = { isAlpha, isBands, isLeds, isLumi, isOctaves, isOutline, isRound, noLedGap };
// generate the X-axis and radial scales
this._createScales();
}
/**
* Generate the X-axis and radial scales in auxiliary canvases
*/
_createScales() {
if ( ! this._ready )
return;
const { analyzerWidth, initialX, innerRadius, scaleMin, unitWidth } = this._aux,
{ canvas, _frequencyScale, _mirror, _noteLabels, _radial, _scaleX, _scaleR } = this,
canvasX = _scaleX.canvas,
canvasR = _scaleR.canvas,
freqLabels = [],
isDualHorizontal = this._chLayout == CHANNEL_HORIZONTAL,
isDualVertical = this._chLayout == CHANNEL_VERTICAL,
minDimension = Math.min( canvas.width, canvas.height ),
scale = [ 'C',, 'D',, 'E', 'F',, 'G',, 'A',, 'B' ], // for note labels (no sharp notes)
scaleHeight = minDimension / 34 | 0, // circular scale height (radial mode)
fontSizeX = canvasX.height >> 1,
fontSizeR = scaleHeight >> 1,
labelWidthX = fontSizeX * ( _noteLabels ? .7 : 1.5 ),
labelWidthR = fontSizeR * ( _noteLabels ? 1 : 2 ),
root12 = 2 ** ( 1 / 12 );
if ( ! _noteLabels && ( this._ansiBands || _frequencyScale != SCALE_LOG ) ) {
freqLabels.push( 16, 31.5, 63, 125, 250, 500, 1e3, 2e3, 4e3 );
if ( _frequencyScale == SCALE_LINEAR )
freqLabels.push( 6e3, 8e3, 10e3, 12e3, 14e3, 16e3, 18e3, 20e3, 22e3 );
else
freqLabels.push( 8e3, 16e3 );
}
else {
let freq