diff --git a/web/includes/Monitor.php b/web/includes/Monitor.php index 6f9c05cfb2..0f72e9a9a1 100644 --- a/web/includes/Monitor.php +++ b/web/includes/Monitor.php @@ -227,6 +227,7 @@ public static function getStreamChannelOptions() { 'AnalysisImage' => 'FullColour', 'Enabled' => array('type'=>'boolean','default'=>1), 'Decoding' => 'Always', + 'WhatDisplay' => 'OnlyVideo', 'RTSP2WebEnabled' => array('type'=>'integer','default'=>0), 'DefaultPlayer' => '', 'StreamChannel' => 'Restream', @@ -1078,6 +1079,7 @@ function getMonitorStateHTML() { $html = '
+
@@ -1109,6 +1111,16 @@ function getMonitorStateHTML() { */ function getStreamHTML($options) { global $basename; + global $view; + + $whatDisplay = (isset($_COOKIE["zmWhatDisplay"])) ? strtolower($_COOKIE["zmWhatDisplay"]) : 'default'; + $dataNotDisplayVideo = 'false'; + + if (false !== strpos($whatDisplay, 'default')) { // Default monitor settings + if (false === (strpos(strtolower($this->WhatDisplay()), 'video'))) $dataNotDisplayVideo = 'true'; + } else { + if (false === (strpos($whatDisplay, 'video'))) $dataNotDisplayVideo = 'true'; + } if (isset($options['scale']) and $options['scale'] != '' and $options['scale'] != 'fixed') { if ($options['scale'] != 'auto' && $options['scale'] != '0') { @@ -1175,6 +1187,7 @@ function getStreamHTML($options) { class="monitorStream imageFeed" data-monitor-id="'. $this->Id() .'" data-width="'. $this->ViewWidth() .'" + data-not-display-video="'. $dataNotDisplayVideo .'" data-height="'.$this->ViewHeight() .'" style="'. #(($options['width'] and ($options['width'] != '0px')) ? 'width: '.$options['width'].';' : ''). #(($options['height'] and ($options['height'] != '0px')) ? 'height: '.$options['height'].';' : ''). @@ -1270,6 +1283,22 @@ class="monitorStream imageFeed" //if ((!ZM_WEB_COMPACT_MONTAGE) && ($this->Type() != 'WebSite')) { $html .= $this->getMonitorStateHTML(); } + $html .= ' + + '.PHP_EOL; + if ($view == 'montage') { + $html .= ' +
+
+
+ +
+
+ + '.PHP_EOL; + } + $html .= ' +
'.PHP_EOL; $html .= PHP_EOL.'
'.PHP_EOL; return $html; } // end getStreamHTML diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index c386ad3ba2..6b4787e632 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -45,6 +45,7 @@ function MonitorStream(monitorData) { this.server_id = monitorData.server_id; this.scale = monitorData.scale ? parseInt(monitorData.scale) : 100; this.status = {capturefps: 0, analysisfps: 0}; // json object with alarmstatus, fps etc + this.whatDisplay = monitorData.whatDisplay; this.lastAlarmState = STATE_IDLE; this.statusCmdTimer = null; // timer for requests using ajax to get monitor status this.statusCmdParms = { @@ -430,7 +431,8 @@ function MonitorStream(monitorData) { //console.log('start go2rtcenabled:', this.Go2RTCEnabled, 'this.player:', this.player, 'muted', this.muted); - $j('#volumeControls'+this.id).hide(); + //$j('#volumeControls'+this.id).hide(); + $j('#volumeControls'+this.id).addClass('disabled'); $j('#delay'+this.id).addClass('hidden'); if (this.Go2RTCEnabled && ((!this.player) || (-1 !== this.player.indexOf('go2rtc')))) { @@ -640,6 +642,7 @@ function MonitorStream(monitorData) { this.handlerEventListener['killStream'] = this.streamListenerBind(); this.activePlayer = 'zms'; this.updateStreamInfo('ZMS MJPEG'); + hideAudioMotion(this.id); }; // this.start this.setSrcInfoBlock = function() { @@ -728,7 +731,14 @@ function MonitorStream(monitorData) { infoBlock.style.left = '50%'; infoBlock.style.transform = 'translate(-50%, -50%)'; infoBlock.style.pointerEvents = 'none'; - this.getElement().parentNode.appendChild(infoBlock); + let node = null; + const _imageFeed = document.getElementById('imageFeed'+this.id); + if (_imageFeed && _imageFeed.getAttribute('data-not-display-video') === 'true') { + node = document.getElementById("audioVisualization" + this.id) || this.getElement(); + } else { + node = this.getElement(); + } + node.parentNode.appendChild(infoBlock); currentInfoBlock = infoBlock; } return currentInfoBlock; @@ -803,6 +813,7 @@ function MonitorStream(monitorData) { } else { console.log("Unknown activePlayer", this.activePlayer); } + if (this.audioMotion && this.audioMotion.stop) this.audioMotion.stop(); this.activePlayer = ''; this.started = false; }; @@ -941,6 +952,7 @@ function MonitorStream(monitorData) { } this.statusCmdTimer = setInterval(this.statusCmdQuery.bind(this), statusRefreshTimeout); } + if (this.audioMotion && this.audioMotion.init) this.audioMotion.init(); }; this.eventHandler = function(event) { @@ -994,20 +1006,16 @@ function MonitorStream(monitorData) { this.onplay = func; }; - this.getVolumeControls = function() { - // On Watch page slider has no ID, on Montage page it has ID - return (document.getElementById('volumeControls')) ? document.getElementById('volumeControls') : document.getElementById('volumeControls'+this.id); + return getVolumeControls(this.id); }; this.getVolumeSlider = function() { - // On Watch page slider has no ID, on Montage page it has ID - return (document.getElementById('volumeSlider')) ? document.getElementById('volumeSlider') : document.getElementById('volumeSlider'+this.id); + return getVolumeSlider(this.id); }; this.getIconMute = function() { - // On Watch page icon has no ID, on Montage page it has ID - return (document.getElementById('controlMute')) ? document.getElementById('controlMute') : document.getElementById('controlMute'+this.id); + return getIconMute(this.id); }; this.getAVStream = function() { @@ -1051,8 +1059,9 @@ function MonitorStream(monitorData) { const volumeSlider = this.getVolumeSlider(); const audioStream = this.getAVStream(); if (!volumeSlider || !audioStream) return; - const iconMute = this.getIconMute(); - $j('#volumeControls'+this.id).show(); + + //$j('#volumeControls'+this.id).show(); + $j('#volumeControls'+this.id).removeClass('disabled'); if (!this.handlerEventListener['volumechange']) { this.handlerEventListener['volumechange'] = manageEventListener.addEventListener(audioStream, 'volumechange', (event) => { @@ -1061,155 +1070,56 @@ function MonitorStream(monitorData) { ); } if (volumeSlider.noUiSlider) return; - const defaultVolume = (volumeSlider.getAttribute("data-volume") || 50); - - noUiSlider.create(volumeSlider, { - start: [(defaultVolume) ? defaultVolume : audioStream.volume * 100], - step: 1, - //behaviour: 'unconstrained', - behaviour: 'tap', - connect: [true, false], - range: { - 'min': 0, - 'max': 100 - }, - /*tooltips: [ - //true, - { to: function(value) { return value.toFixed(0) + '%'; } } - ],*/ - }); - volumeSlider.allowSetValue = true; - volumeSlider.noUiSlider.on('update', function onUpdateUiSlider(values, handle) { - audioStream.volume = values[0]/100; - if (values[0] > 0 && !audioStream.muted) { - iconMute.innerHTML = 'volume_up'; - volumeSlider.classList.remove('noUi-mute'); - } else { - iconMute.innerHTML = 'volume_off'; - volumeSlider.classList.add('noUi-mute'); - } - //console.log("Audio volume slider event: 'update'"); - }); - volumeSlider.noUiSlider.on('end', function onEndUiSlider(values, handle) { - volumeSlider.allowSetValue = true; - //console.log("Audio volume slider event: 'end'"); - }); - volumeSlider.noUiSlider.on('start', function onStartUiSlider(values, handle) { - volumeSlider.allowSetValue = false; // Let's prohibit changing the Value using the "Set" method, otherwise there will be lags and collapse when directly moving the slider with the mouse... - //console.log("Audio volume slider event: 'start'"); - }); - volumeSlider.noUiSlider.on('set', function onSetUiSlider(values, handle) { - //console.log("Audio volume slider event: 'set'"); - }); - volumeSlider.noUiSlider.on('slide', function onSlideUiSlider(values, handle) { - if (audioStream.volume > 0 && audioStream.muted) { - iconMute.innerHTML = 'volume_up'; - audioStream.muted = false; - } - //console.log("Audio volume slider event: 'slide'"); - }); - if (volumeSlider.getAttribute("data-muted") !== "true") { - this.controlMute('off'); - } else { - this.controlMute('on'); - } + createVolumeSlider(volumeSlider, audioStream); + + //if (volumeSlider.getAttribute("data-muted") !== "true") { + // this.controlMute('off'); + //} else { + // this.controlMute('on'); + //} }; this.destroyVolumeSlider = function() { - $j('#volumeControls'+this.id).hide(); + //$j('#volumeControls'+this.id).hide(); + $j('#volumeControls'+this.id).addClass('disabled'); const volumeSlider = this.getVolumeSlider(); + destroyVolumeSlider(volumeSlider); //const iconMute = this.getIconMute(); //if (iconMute) iconMute.innerText = ""; - if (volumeSlider && volumeSlider.noUiSlider) { - volumeSlider.noUiSlider.destroy(); - volumeSlider.noUiSlider = null; - } }; /* * volume: on || off */ this.changeStateIconMute = function(volume) { - const volumeControls = this.getVolumeControls(); - const disabled = (volumeControls) ? volumeControls.classList.contains('disabled') : false; - const iconMute = this.getIconMute(); - if (!disabled && iconMute) { - iconMute.innerHTML = (volume == 'on')? 'volume_up' : 'volume_off'; - } - return iconMute; + return changeStateIconMute(this.id, volume); }; /* * volume: on || off */ this.changeVolumeSlider = function(volume) { - const volumeControls = this.getVolumeControls(); - //const controlMute = document.querySelector('[id ^= "controlMute'+this.id+'"]'); - const volumeSlider = this.getVolumeSlider(); - - if (volumeSlider) { - let disabled = false; - if (volumeControls) { - disabled = volumeControls.classList.contains('disabled'); - } - if (volume == 'on') { - volumeSlider.classList.remove('noUi-mute'); - } else if (volume == 'off') { - volumeSlider.classList.add('noUi-mute'); - } - if (volumeSlider.noUiSlider) { - (disabled) ? volumeSlider.noUiSlider.disable() : volumeSlider.noUiSlider.enable(); - } - } - return volumeSlider; + return changeVolumeSlider(this.id, volume); }; /* * mode: switch, on, off */ this.controlMute = function(mode = 'switch') { - let volumeSlider = this.getVolumeSlider(); const audioStream = this.getAVStream(); - const volumeControls = this.getVolumeControls(); - const disabled = (volumeControls) ? volumeControls.classList.contains('disabled') : false; - - if (volumeSlider && volumeSlider.noUiSlider) { - (disabled) ? volumeSlider.noUiSlider.disable() : volumeSlider.noUiSlider.enable(); - } - - if (disabled) { - console.log(`Volume control is disabled in controlMute for monitor ID=${this.id}`); - return; - } - if (!audioStream) { - console.log(`No audiostream! in controlMute for monitor ID=${this.id}`); - return; - } - - if (mode=='switch') { - if (audioStream.muted) { - audioStream.muted = this.muted = false; - this.changeStateIconMute('on'); - volumeSlider = this.changeVolumeSlider('on'); - if (volumeSlider && volumeSlider.noUiSlider) { - audioStream.volume = volumeSlider.noUiSlider.get() / 100; + controlMute(this.id, mode); + if (audioStream) { + if (mode=='switch') { + if (audioStream.muted) { + this.muted = false; + } else { + this.muted = true; } - } else { - audioStream.muted = this.muted = true; - this.changeStateIconMute('off'); - this.changeVolumeSlider('off'); - } - } else if (mode=='on') { - audioStream.muted = this.muted = true; - this.changeStateIconMute('off'); - this.changeVolumeSlider('off'); - } else if (mode=='off') { - audioStream.muted = this.muted = false; - this.changeStateIconMute('on'); - volumeSlider = this.changeVolumeSlider('on'); - if (volumeSlider && volumeSlider.noUiSlider) { - audioStream.volume = volumeSlider.noUiSlider.get() / 100; + } else if (mode=='on') { + this.muted = true; + } else if (mode=='off') { + this.muted = false; } } }; diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php index 8857eab90c..04e48fa37a 100644 --- a/web/lang/en_gb.php +++ b/web/lang/en_gb.php @@ -1030,6 +1030,11 @@ function zmVlang($langVarArray, $count) { is set to "my_camera", access the stream at rtsp://ZM_HOST:20006/my_camera ', ), + 'OPTIONS_WHATTODISPLAY' => array( + 'Help' => ' + On the Watch, Montage, Event page, you can display either a video stream, or an audio stream visualization, or both a video stream and an audio visualization. + ', + ), 'FUNCTION_ANALYSIS_ENABLED' => array( 'Help' => ' When to perform motion detection on the captured video. diff --git a/web/skins/classic/assets/audioMotion-analyzer/src/audioMotion-analyzer.js b/web/skins/classic/assets/audioMotion-analyzer/src/audioMotion-analyzer.js new file mode 100644 index 0000000000..abc6186a23 --- /dev/null +++ b/web/skins/classic/assets/audioMotion-analyzer/src/audioMotion-analyzer.js @@ -0,0 +1,2713 @@ +/**! + * audioMotion-analyzer + * High-resolution real-time graphic audio spectrum analyzer JS module + * + * @version 4.5.4 + * @author Henrique Avila Vianna + * @license AGPL-3.0-or-later + */ + +const VERSION = '4.5.4'; + +// 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().then( () => 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() { + this.toggleAnalyzer( true ); + } + + /** + * 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 = C_1; + for ( let octave = -1; octave < 11; octave++ ) { + for ( let note = 0; note < 12; note++ ) { + if ( freq >= this._minFreq && freq <= this._maxFreq ) { + const pitch = scale[ note ], + isC = pitch == 'C'; + if ( ( pitch && _noteLabels && ! _mirror && ! isDualHorizontal ) || isC ) + freqLabels.push( _noteLabels ? [ freq, pitch + ( isC ? octave : '' ) ] : freq ); + } + freq *= root12; + } + } + } + + // in radial dual-vertical layout, the scale is positioned exactly between both channels, by making the canvas a bit larger than the inner diameter + canvasR.width = canvasR.height = Math.max( minDimension * .15, ( innerRadius << 1 ) + ( isDualVertical * scaleHeight ) ); + + const centerR = canvasR.width >> 1, + radialY = centerR - scaleHeight * .7; // vertical position of text labels in the circular scale + + // helper function + const radialLabel = ( x, label ) => { + const angle = TAU * ( x / canvas.width ), + adjAng = angle - HALF_PI, // rotate angles so 0 is at the top + posX = radialY * Math.cos( adjAng ), + posY = radialY * Math.sin( adjAng ); + + _scaleR.save(); + _scaleR.translate( centerR + posX, centerR + posY ); + _scaleR.rotate( angle ); + _scaleR.fillText( label, 0, 0 ); + _scaleR.restore(); + } + + // clear scale canvas + canvasX.width |= 0; + + _scaleX.fillStyle = _scaleR.strokeStyle = SCALEX_BACKGROUND_COLOR; + _scaleX.fillRect( 0, 0, canvasX.width, canvasX.height ); + + _scaleR.arc( centerR, centerR, centerR - scaleHeight / 2, 0, TAU ); + _scaleR.lineWidth = scaleHeight; + _scaleR.stroke(); + + _scaleX.fillStyle = _scaleR.fillStyle = SCALEX_LABEL_COLOR; + _scaleX.font = `${ fontSizeX }px ${FONT_FAMILY}`; + _scaleR.font = `${ fontSizeR }px ${FONT_FAMILY}`; + _scaleX.textAlign = _scaleR.textAlign = 'center'; + + let prevX = -labelWidthX / 4, + prevR = -labelWidthR; + + for ( const item of freqLabels ) { + const [ freq, label ] = Array.isArray( item ) ? item : [ item, item < 1e3 ? item | 0 : `${ ( item / 100 | 0 ) / 10 }k` ], + x = unitWidth * ( this._freqScaling( freq ) - scaleMin ), + y = canvasX.height * .75, + isC = label[0] == 'C', + maxW = fontSizeX * ( _noteLabels && ! _mirror && ! isDualHorizontal ? ( isC ? 1.2 : .6 ) : 3 ); + + // set label color - no highlight when mirror effect is active (only Cs displayed) + _scaleX.fillStyle = _scaleR.fillStyle = isC && ! _mirror && ! isDualHorizontal ? SCALEX_HIGHLIGHT_COLOR : SCALEX_LABEL_COLOR; + + // prioritizes which note labels are displayed, due to the restricted space on some ranges/scales + if ( _noteLabels ) { + const isLog = _frequencyScale == SCALE_LOG, + isLinear = _frequencyScale == SCALE_LINEAR; + + let allowedLabels = ['C']; + + if ( isLog || freq > 2e3 || ( ! isLinear && freq > 250 ) || + ( ( ! _radial || isDualVertical ) && ( ! isLinear && freq > 125 || freq > 1e3 ) ) ) + allowedLabels.push('G'); + if ( isLog || freq > 4e3 || ( ! isLinear && freq > 500 ) || + ( ( ! _radial || isDualVertical ) && ( ! isLinear && freq > 250 || freq > 2e3 ) ) ) + allowedLabels.push('E'); + if ( isLinear && freq > 4e3 || + ( ( ! _radial || isDualVertical ) && ( isLog || freq > 2e3 || ( ! isLinear && freq > 500 ) ) ) ) + allowedLabels.push('D','F','A','B'); + if ( ! allowedLabels.includes( label[0] ) ) + continue; // skip this label + } + + // linear scale + if ( x >= prevX + labelWidthX / 2 && x <= analyzerWidth ) { + _scaleX.fillText( label, isDualHorizontal && _mirror == -1 ? analyzerWidth - x : initialX + x, y, maxW ); + if ( isDualHorizontal || ( _mirror && ( x > labelWidthX || _mirror == 1 ) ) ) + _scaleX.fillText( label, isDualHorizontal && _mirror != 1 ? analyzerWidth + x : ( initialX || canvas.width ) - x, y, maxW ); + prevX = x + Math.min( maxW, _scaleX.measureText( label ).width ) / 2; + } + + // radial scale + if ( x >= prevR + labelWidthR && x < analyzerWidth - labelWidthR ) { // avoid overlapping the last label over the first one + radialLabel( isDualHorizontal && _mirror == 1 ? analyzerWidth - x : x, label ); + if ( isDualHorizontal || ( _mirror && ( x > labelWidthR || _mirror == 1 ) ) ) // avoid overlapping of first labels on mirror mode + radialLabel( isDualHorizontal && _mirror != -1 ? analyzerWidth + x : -x, label ); + prevR = x; + } + } + } + + /** + * Redraw the canvas + * this is called 60 times per second by requestAnimationFrame() + */ + _draw( timestamp ) { + // schedule next canvas update + this._runId = requestAnimationFrame( timestamp => this._draw( timestamp ) ); + + // frame rate control + const elapsed = timestamp - this._time, // time since last FPS computation + frameTime = timestamp - this._last, // time since last rendered frame + targetInterval = this._maxFPS ? 975 / this._maxFPS : 0; // small tolerance for best results + + if ( frameTime < targetInterval ) + return; + + this._last = timestamp - ( targetInterval ? frameTime % targetInterval : 0 ); // thanks https://stackoverflow.com/a/19772220/2370385 + this._frames++; + + if ( elapsed >= 1000 ) { // update FPS every second + this._fps = this._frames / elapsed * 1000; + this._frames = 0; + this._time = timestamp; + } + + // initialize local constants + + const { isAlpha, + isBands, + isLeds, + isLumi, + isOctaves, + isOutline, + isRound, + noLedGap } = this._flg, + + { analyzerHeight, + centerX, + centerY, + channelCoords, + channelHeight, + channelGap, + initialX, + innerRadius, + outerRadius } = this._aux, + + { _bars, + canvas, + _canvasGradients, + _chLayout, + _colorMode, + _ctx, + _energy, + _fadePeaks, + fillAlpha, + _fps, + _linearAmplitude, + _lineWidth, + maxDecibels, + minDecibels, + _mirror, + _mode, + overlay, + _radial, + showBgColor, + showPeaks, + useCanvas, + _weightingFilter } = this, + + canvasX = this._scaleX.canvas, + canvasR = this._scaleR.canvas, + fadeFrames = _fps * this._peakFadeTime / 1e3, + fpsSquared = _fps ** 2, + gravity = this._gravity * 1e3, + holdFrames = _fps * this._peakHoldTime / 1e3, + isDualCombined = _chLayout == CHANNEL_COMBINED, + isDualHorizontal = _chLayout == CHANNEL_HORIZONTAL, + isDualVertical = _chLayout == CHANNEL_VERTICAL, + isSingle = _chLayout == CHANNEL_SINGLE, + isTrueLeds = isLeds && this._trueLeds && _colorMode == COLOR_GRADIENT, + analyzerWidth = _radial ? canvas.width : this._aux.analyzerWidth, + finalX = initialX + analyzerWidth, + showPeakLine = showPeaks && this._peakLine && _mode == MODE_GRAPH, + maxBarHeight = _radial ? outerRadius - innerRadius : analyzerHeight, + nominalMaxHeight = maxBarHeight / this._pixelRatio, // for consistent gravity on lo-res or hi-dpi + dbRange = maxDecibels - minDecibels, + [ ledCount, ledSpaceH, ledSpaceV, ledHeight ] = this._leds || []; + + if ( _energy.val > 0 && _fps > 0 ) + this._spinAngle += this._spinSpeed * TAU / 60 / _fps; // spinSpeed * angle increment per frame for 1 RPM + + /* HELPER FUNCTIONS */ + + // create Reflex effect + const doReflex = channel => { + if ( this._reflexRatio > 0 && ! isLumi && ! _radial ) { + let posY, height; + if ( this.reflexFit || isDualVertical ) { // always fit reflex in vertical stereo mode + posY = isDualVertical && channel == 0 ? channelHeight + channelGap : 0; + height = channelHeight - analyzerHeight; + } + else { + posY = canvas.height - analyzerHeight * 2; + height = analyzerHeight; + } + + _ctx.save(); + + // set alpha and brightness for the reflection + _ctx.globalAlpha = this.reflexAlpha; + if ( this.reflexBright != 1 ) + _ctx.filter = `brightness(${this.reflexBright})`; + + // create the reflection + _ctx.setTransform( 1, 0, 0, -1, 0, canvas.height ); + _ctx.drawImage( canvas, 0, channelCoords[ channel ].channelTop, canvas.width, analyzerHeight, 0, posY, canvas.width, height ); + + _ctx.restore(); + } + } + + // draw scale on X-axis + const drawScaleX = () => { + if ( this.showScaleX ) { + if ( _radial ) { + _ctx.save(); + _ctx.translate( centerX, centerY ); + if ( this._spinSpeed ) + _ctx.rotate( this._spinAngle + HALF_PI ); + _ctx.drawImage( canvasR, -canvasR.width >> 1, -canvasR.width >> 1 ); + _ctx.restore(); + } + else + _ctx.drawImage( canvasX, 0, canvas.height - canvasX.height ); + } + } + + // returns the gain (in dB) for a given frequency, considering the currently selected weighting filter + const weightingdB = freq => { + const f2 = freq ** 2, + SQ20_6 = 424.36, + SQ107_7 = 11599.29, + SQ158_5 = 25122.25, + SQ737_9 = 544496.41, + SQ12194 = 148693636, + linearTodB = value => 20 * Math.log10( value ); + + switch ( _weightingFilter ) { + case FILTER_A : // A-weighting https://en.wikipedia.org/wiki/A-weighting + const rA = ( SQ12194 * f2 ** 2 ) / ( ( f2 + SQ20_6 ) * Math.sqrt( ( f2 + SQ107_7 ) * ( f2 + SQ737_9 ) ) * ( f2 + SQ12194 ) ); + return 2 + linearTodB( rA ); + + case FILTER_B : + const rB = ( SQ12194 * f2 * freq ) / ( ( f2 + SQ20_6 ) * Math.sqrt( f2 + SQ158_5 ) * ( f2 + SQ12194 ) ); + return .17 + linearTodB( rB ); + + case FILTER_C : + const rC = ( SQ12194 * f2 ) / ( ( f2 + SQ20_6 ) * ( f2 + SQ12194 ) ); + return .06 + linearTodB( rC ); + + case FILTER_D : + const h = ( ( 1037918.48 - f2 ) ** 2 + 1080768.16 * f2 ) / ( ( 9837328 - f2 ) ** 2 + 11723776 * f2 ), + rD = ( freq / 6.8966888496476e-5 ) * Math.sqrt( h / ( ( f2 + 79919.29 ) * ( f2 + 1345600 ) ) ); + return linearTodB( rD ); + + case FILTER_468 : // ITU-R 468 https://en.wikipedia.org/wiki/ITU-R_468_noise_weighting + const h1 = -4.737338981378384e-24 * freq ** 6 + 2.043828333606125e-15 * freq ** 4 - 1.363894795463638e-7 * f2 + 1, + h2 = 1.306612257412824e-19 * freq ** 5 - 2.118150887518656e-11 * freq ** 3 + 5.559488023498642e-4 * freq, + rI = 1.246332637532143e-4 * freq / Math.hypot( h1, h2 ); + return 18.2 + linearTodB( rI ); + } + + return 0; // unknown filter + } + + // draws (stroke) a bar from x,y1 to x,y2 + const strokeBar = ( x, y1, y2 ) => { + _ctx.beginPath(); + _ctx.moveTo( x, y1 ); + _ctx.lineTo( x, y2 ); + _ctx.stroke(); + } + + // conditionally strokes current path on canvas + const strokeIf = flag => { + if ( flag && _lineWidth ) { + const alpha = _ctx.globalAlpha; + _ctx.globalAlpha = 1; + _ctx.stroke(); + _ctx.globalAlpha = alpha; + } + } + + // converts a value in [0;1] range to a height in pixels that fits into the current LED elements + const ledPosY = value => Math.max( 0, ( value * ledCount | 0 ) * ( ledHeight + ledSpaceV ) - ledSpaceV ); + + // update energy information + const updateEnergy = newVal => { + _energy.val = newVal; + if ( _energy.peak > 0 ) { + _energy.hold--; + if ( _energy.hold < 0 ) + _energy.peak += _energy.hold * gravity / fpsSquared / canvas.height * this._pixelRatio; + // TO-DO: replace `canvas.height * this._pixelRatio` with `maxNominalHeight` when implementing dual-channel energy + } + if ( newVal >= _energy.peak ) { + _energy.peak = newVal; + _energy.hold = holdFrames; + } + } + + /* MAIN FUNCTION */ + + if ( overlay ) + _ctx.clearRect( 0, 0, canvas.width, canvas.height ); + + let currentEnergy = 0; + + const nBars = _bars.length, + nChannels = isSingle ? 1 : 2; + + for ( let channel = 0; channel < nChannels; channel++ ) { + + const { channelTop, channelBottom, analyzerBottom } = channelCoords[ channel ], + channelGradient = this._gradients[ this._selectedGrads[ channel ] ], + colorStops = channelGradient.colorStops, + colorCount = colorStops.length, + bgColor = ( ! showBgColor || isLeds && ! overlay ) ? '#000' : channelGradient.bgColor, + radialDirection = isDualVertical && _radial && channel ? -1 : 1, // 1 = outwards, -1 = inwards + invertedChannel = ( ! channel && _mirror == -1 ) || ( channel && _mirror == 1 ), + radialOffsetX = ! isDualHorizontal || ( channel && _mirror != 1 ) ? 0 : analyzerWidth >> ( channel || ! invertedChannel ), + angularDirection = isDualHorizontal && invertedChannel ? -1 : 1; // 1 = clockwise, -1 = counterclockwise +/* + Expanded logic for radialOffsetX and angularDirection: + + let radialOffsetX = 0, + angularDirection = 1; + + if ( isDualHorizontal ) { + if ( channel == 0 ) { // LEFT channel + if ( _mirror == -1 ) { + radialOffsetX = analyzerWidth; + angularDirection = -1; + } + else + radialOffsetX = analyzerWidth >> 1; + } + else { // RIGHT channel + if ( _mirror == 1 ) { + radialOffsetX = analyzerWidth >> 1; + angularDirection = -1; + } + } + } +*/ + // draw scale on Y-axis (uses: channel, channelTop) + const drawScaleY = () => { + const scaleWidth = canvasX.height, + fontSize = scaleWidth >> 1, + max = _linearAmplitude ? 100 : maxDecibels, + min = _linearAmplitude ? 0 : minDecibels, + incr = _linearAmplitude ? 20 : 5, + interval = analyzerHeight / ( max - min ), + atStart = _mirror != -1 && ( ! isDualHorizontal || channel == 0 || _mirror == 1 ), + atEnd = _mirror != 1 && ( ! isDualHorizontal || channel != _mirror ); + + _ctx.save(); + _ctx.fillStyle = SCALEY_LABEL_COLOR; + _ctx.font = `${fontSize}px ${FONT_FAMILY}`; + _ctx.textAlign = 'right'; + _ctx.lineWidth = 1; + + for ( let val = max; val > min; val -= incr ) { + const posY = channelTop + ( max - val ) * interval, + even = ( val % 2 == 0 ) | 0; + + if ( even ) { + const labelY = posY + fontSize * ( posY == channelTop ? .8 : .35 ); + if ( atStart ) + _ctx.fillText( val, scaleWidth * .85, labelY ); + if ( atEnd ) + _ctx.fillText( val, ( isDualHorizontal ? analyzerWidth : canvas.width ) - scaleWidth * .1, labelY ); + _ctx.strokeStyle = SCALEY_LABEL_COLOR; + _ctx.setLineDash([2,4]); + _ctx.lineDashOffset = 0; + } + else { + _ctx.strokeStyle = SCALEY_MIDLINE_COLOR; + _ctx.setLineDash([2,8]); + _ctx.lineDashOffset = 1; + } + + _ctx.beginPath(); + _ctx.moveTo( initialX + scaleWidth * even * atStart, ~~posY + .5 ); // for sharp 1px line (https://stackoverflow.com/a/13879402/2370385) + _ctx.lineTo( finalX - scaleWidth * even * atEnd, ~~posY + .5 ); + _ctx.stroke(); + } + _ctx.restore(); + } + + // FFT bin data interpolation (uses fftData) + const interpolate = ( bin, ratio ) => { + const value = fftData[ bin ] + ( bin < fftData.length - 1 ? ( fftData[ bin + 1 ] - fftData[ bin ] ) * ratio : 0 ); + return isNaN( value ) ? -Infinity : value; + } + + // converts a given X-coordinate to its corresponding angle in radial mode (uses angularDirection) + const getAngle = ( x, dir = angularDirection ) => dir * TAU * ( ( x + radialOffsetX ) / canvas.width ) + this._spinAngle; + + // converts planar X,Y coordinates to radial coordinates (uses: getAngle(), radialDirection) + const radialXY = ( x, y, dir ) => { + const height = innerRadius + y * radialDirection, + angle = getAngle( x, dir ); + return [ centerX + height * Math.cos( angle ), centerY + height * Math.sin( angle ) ]; + } + + // draws a polygon of width `w` and height `h` at (x,y) in radial mode (uses: angularDirection, radialDirection) + const radialPoly = ( x, y, w, h, stroke ) => { + _ctx.beginPath(); + for ( const dir of ( _mirror && ! isDualHorizontal ? [1,-1] : [ angularDirection ] ) ) { + const [ startAngle, endAngle ] = isRound ? [ getAngle( x, dir ), getAngle( x + w, dir ) ] : []; + _ctx.moveTo( ...radialXY( x, y, dir ) ); + _ctx.lineTo( ...radialXY( x, y + h, dir ) ); + if ( isRound ) + _ctx.arc( centerX, centerY, innerRadius + ( y + h ) * radialDirection, startAngle, endAngle, dir != 1 ); + else + _ctx.lineTo( ...radialXY( x + w, y + h, dir ) ); + _ctx.lineTo( ...radialXY( x + w, y, dir ) ); + if ( isRound && ! stroke ) // close the bottom line only when not in outline mode + _ctx.arc( centerX, centerY, innerRadius + y * radialDirection, endAngle, startAngle, dir == 1 ); + } + strokeIf( stroke ); + _ctx.fill(); + } + + // set fillStyle and strokeStyle according to current colorMode (uses: channel, colorStops, colorCount) + const setBarColor = ( value = 0, barIndex = 0 ) => { + let color; + // for graph mode, always use the channel gradient (ignore colorMode) + if ( ( _colorMode == COLOR_GRADIENT && ! isTrueLeds ) || _mode == MODE_GRAPH ) + color = _canvasGradients[ channel ]; + else { + const selectedIndex = _colorMode == COLOR_BAR_INDEX ? barIndex % colorCount : colorStops.findLastIndex( item => isLeds ? ledPosY( value ) <= ledPosY( item.level ) : value <= item.level ); + color = colorStops[ selectedIndex ].color; + } + _ctx.fillStyle = _ctx.strokeStyle = color; + } + + // CHANNEL START + + if ( useCanvas ) { + // set transform (horizontal flip and translation) for dual-horizontal layout + if ( isDualHorizontal && ! _radial ) { + const translateX = analyzerWidth * ( channel + invertedChannel ), + flipX = invertedChannel ? -1 : 1; + + _ctx.setTransform( flipX, 0, 0, 1, translateX, 0 ); + } + + // fill the analyzer background if needed (not overlay or overlay + showBgColor) + if ( ! overlay || showBgColor ) { + if ( overlay ) + _ctx.globalAlpha = this.bgAlpha; + + _ctx.fillStyle = bgColor; + + // exclude the reflection area when overlay is true and reflexAlpha == 1 (avoids alpha over alpha difference, in case bgAlpha < 1) + if ( channel == 0 || ( ! _radial && ! isDualCombined ) ) + _ctx.fillRect( initialX, channelTop - channelGap, analyzerWidth, ( overlay && this.reflexAlpha == 1 ? analyzerHeight : channelHeight ) + channelGap ); + + _ctx.globalAlpha = 1; + } + + // draw dB scale (Y-axis) - avoid drawing it twice on 'dual-combined' channel layout + if ( this.showScaleY && ! isLumi && ! _radial && ( channel == 0 || ! isDualCombined ) ) + drawScaleY(); + + // set line width and dash for LEDs effect + if ( isLeds ) { + _ctx.setLineDash( [ ledHeight, ledSpaceV ] ); + _ctx.lineWidth = _bars[0].width; + } + else // for outline effect ensure linewidth is not greater than half the bar width + _ctx.lineWidth = isOutline ? Math.min( _lineWidth, _bars[0].width / 2 ) : _lineWidth; + + // set clipping region + _ctx.save(); + if ( ! _radial ) { + const region = new Path2D(); + region.rect( 0, channelTop, canvas.width, analyzerHeight ); + _ctx.clip( region ); + } + + } // if ( useCanvas ) + + // get a new array of data from the FFT + let fftData = this._fftData[ channel ]; + this._analyzer[ channel ].getFloatFrequencyData( fftData ); + + // apply weighting + if ( _weightingFilter ) + fftData = fftData.map( ( val, idx ) => val + weightingdB( this._binToFreq( idx ) ) ); + + // start drawing path (for graph mode) + _ctx.beginPath(); + + // store line graph points to create mirror effect in radial mode + let points = []; + + // draw bars / lines + + for ( let barIndex = 0; barIndex < nBars; barIndex++ ) { + + const bar = _bars[ barIndex ], + { posX, barCenter, width, freq, binLo, binHi, ratioLo, ratioHi } = bar; + + let barValue = Math.max( interpolate( binLo, ratioLo ), interpolate( binHi, ratioHi ) ); + + // check additional bins (if any) for this bar and keep the highest value + for ( let j = binLo + 1; j < binHi; j++ ) { + if ( fftData[ j ] > barValue ) + barValue = fftData[ j ]; + } + + // normalize bar amplitude in [0;1] range + barValue = this._normalizedB( barValue ); + + bar.value[ channel ] = barValue; + currentEnergy += barValue; + + // update bar peak + if ( bar.peak[ channel ] > 0 && bar.alpha[ channel ] > 0 ) { + bar.hold[ channel ]--; + // if hold is negative, start peak drop or fade out + if ( bar.hold[ channel ] < 0 ) { + if ( _fadePeaks && ! showPeakLine ) { + const initialAlpha = ! isAlpha || ( isOutline && _lineWidth > 0 ) ? 1 : isAlpha ? bar.peak[ channel ] : fillAlpha; + bar.alpha[ channel ] = initialAlpha * ( 1 + bar.hold[ channel ] / fadeFrames ); // hold is negative, so this is <= 1 + } + else + bar.peak[ channel ] += bar.hold[ channel ] * gravity / fpsSquared / Math.abs( nominalMaxHeight ); + // make sure the peak value is reset when using fadePeaks + if ( bar.alpha[ channel ] <= 0 ) + bar.peak[ channel ] = 0; + } + } + + // check if it's a new peak for this bar + if ( barValue >= bar.peak[ channel ] ) { + bar.peak[ channel ] = barValue; + bar.hold[ channel ] = holdFrames; + // check whether isAlpha or isOutline are active to start the peak alpha with the proper value + bar.alpha[ channel ] = ! isAlpha || ( isOutline && _lineWidth > 0 ) ? 1 : isAlpha ? barValue : fillAlpha; + } + + // if not using the canvas, move earlier to the next bar + if ( ! useCanvas ) + continue; + + // set opacity for bar effects + _ctx.globalAlpha = ( isLumi || isAlpha ) ? barValue : ( isOutline ) ? fillAlpha : 1; + + // set fillStyle and strokeStyle for the current bar + setBarColor( barValue, barIndex ); + + // compute actual bar height on screen + const barHeight = isLumi ? maxBarHeight : isLeds ? ledPosY( barValue ) : barValue * maxBarHeight | 0; + + // Draw current bar or line segment + + if ( _mode == MODE_GRAPH ) { + // compute the average between the initial bar (barIndex==0) and the next one + // used to smooth the curve when the initial posX is off the screen, in mirror and radial modes + const nextBarAvg = barIndex ? 0 : ( this._normalizedB( fftData[ _bars[1].binLo ] ) * maxBarHeight + barHeight ) / 2; + + if ( _radial ) { + if ( barIndex == 0 ) { + if ( isDualHorizontal ) + _ctx.moveTo( ...radialXY( 0, 0 ) ); + _ctx.lineTo( ...radialXY( 0, ( posX < 0 ? nextBarAvg : barHeight ) ) ); + } + // draw line to the current point, avoiding overlapping wrap-around frequencies + if ( posX >= 0 ) { + const point = [ posX, barHeight ]; + _ctx.lineTo( ...radialXY( ...point ) ); + points.push( point ); + } + } + else { // Linear + if ( barIndex == 0 ) { + // start the line off-screen using the previous FFT bin value as the initial amplitude + if ( _mirror == -1 && ! isDualHorizontal ) + _ctx.moveTo( initialX, analyzerBottom - ( posX < initialX ? nextBarAvg : barHeight ) ); + else { + const prevFFTData = binLo ? this._normalizedB( fftData[ binLo - 1 ] ) * maxBarHeight : barHeight; // use previous FFT bin value, when available + _ctx.moveTo( initialX - _lineWidth, analyzerBottom - prevFFTData ); + } + } + // draw line to the current point + // avoid X values lower than the origin when mirroring left, otherwise draw them for best graph accuracy + if ( isDualHorizontal || _mirror != -1 || posX >= initialX ) + _ctx.lineTo( posX, analyzerBottom - barHeight ); + } + } + else { + if ( isLeds ) { + // draw "unlit" leds - avoid drawing it twice on 'dual-combined' channel layout + if ( showBgColor && ! overlay && ( channel == 0 || ! isDualCombined ) ) { + const alpha = _ctx.globalAlpha; + _ctx.strokeStyle = LEDS_UNLIT_COLOR; + _ctx.globalAlpha = 1; + strokeBar( barCenter, channelTop, analyzerBottom ); + // restore properties + _ctx.strokeStyle = _ctx.fillStyle; + _ctx.globalAlpha = alpha; + } + if ( isTrueLeds ) { + // ledPosY() is used below to fit one entire led height into the selected range + const colorIndex = isLumi ? 0 : colorStops.findLastIndex( item => ledPosY( barValue ) <= ledPosY( item.level ) ); + let last = analyzerBottom; + for ( let i = colorCount - 1; i >= colorIndex; i-- ) { + _ctx.strokeStyle = colorStops[ i ].color; + let y = analyzerBottom - ( i == colorIndex ? barHeight : ledPosY( colorStops[ i ].level ) ); + strokeBar( barCenter, last, y ); + last = y - ledSpaceV; + } + } + else + strokeBar( barCenter, analyzerBottom, analyzerBottom - barHeight ); + } + else if ( posX >= initialX ) { + if ( _radial ) + radialPoly( posX, 0, width, barHeight, isOutline ); + else if ( isRound ) { + const halfWidth = width / 2, + y = analyzerBottom + halfWidth; // round caps have an additional height of half bar width + + _ctx.beginPath(); + _ctx.moveTo( posX, y ); + _ctx.lineTo( posX, y - barHeight ); + _ctx.arc( barCenter, y - barHeight, halfWidth, PI, TAU ); + _ctx.lineTo( posX + width, y ); + strokeIf( isOutline ); + _ctx.fill(); + } + else { + const offset = isOutline ? _ctx.lineWidth : 0; + _ctx.beginPath(); + _ctx.rect( posX, analyzerBottom + offset, width, -barHeight - offset ); + strokeIf( isOutline ); + _ctx.fill(); + } + } + } + + // Draw peak + const peakValue = bar.peak[ channel ], + peakAlpha = bar.alpha[ channel ]; + + if ( peakValue > 0 && peakAlpha > 0 && showPeaks && ! showPeakLine && ! isLumi && posX >= initialX && posX < finalX ) { + // set opacity for peak + if ( _fadePeaks ) + _ctx.globalAlpha = peakAlpha; + else if ( isOutline && _lineWidth > 0 ) // when lineWidth == 0 ctx.globalAlpha remains set to `fillAlpha` + _ctx.globalAlpha = 1; + else if ( isAlpha ) // isAlpha (alpha based on peak value) supersedes fillAlpha if lineWidth == 0 + _ctx.globalAlpha = peakValue; + + // select the peak color for 'bar-level' colorMode or 'trueLeds' + if ( _colorMode == COLOR_BAR_LEVEL || isTrueLeds ) + setBarColor( peakValue ); + + // render peak according to current mode / effect + if ( isLeds ) { + const ledPeak = ledPosY( peakValue ); + if ( ledPeak >= ledSpaceV ) // avoid peak below first led + _ctx.fillRect( posX, analyzerBottom - ledPeak, width, ledHeight ); + } + else if ( ! _radial ) + _ctx.fillRect( posX, analyzerBottom - peakValue * maxBarHeight, width, 2 ); + else if ( _mode != MODE_GRAPH ) { // radial (peaks for graph mode are done by the peakLine code) + const y = peakValue * maxBarHeight; + radialPoly( posX, y, width, ! this._radialInvert || isDualVertical || y + innerRadius >= 2 ? -2 : 2 ); + } + } + + } // for ( let barIndex = 0; barIndex < nBars; barIndex++ ) + + // if not using the canvas, move earlier to the next channel + if ( ! useCanvas ) + continue; + + // restore global alpha + _ctx.globalAlpha = 1; + + // Fill/stroke drawing path for graph mode + if ( _mode == MODE_GRAPH ) { + setBarColor(); // select channel gradient + + if ( _radial && ! isDualHorizontal ) { + if ( _mirror ) { + let p; + while ( p = points.pop() ) + _ctx.lineTo( ...radialXY( ...p, -1 ) ); + } + _ctx.closePath(); + } + + if ( _lineWidth > 0 ) + _ctx.stroke(); + + if ( fillAlpha > 0 ) { + if ( _radial ) { + // exclude the center circle from the fill area + const start = isDualHorizontal ? getAngle( analyzerWidth >> 1 ) : 0, + end = isDualHorizontal ? getAngle( analyzerWidth ) : TAU; + _ctx.moveTo( ...radialXY( isDualHorizontal ? analyzerWidth >> 1 : 0, 0 ) ); + _ctx.arc( centerX, centerY, innerRadius, start, end, isDualHorizontal ? ! invertedChannel : true ); + } + else { + // close the fill area + _ctx.lineTo( finalX, analyzerBottom ); + _ctx.lineTo( initialX, analyzerBottom ); + } + + _ctx.globalAlpha = fillAlpha; + _ctx.fill(); + _ctx.globalAlpha = 1; + } + + // draw peak line (and standard peaks on radial) + if ( showPeakLine || ( _radial && showPeaks ) ) { + points = []; // for mirror line on radial + _ctx.beginPath(); + _bars.forEach( ( b, i ) => { + let x = b.posX, + h = b.peak[ channel ], + m = i ? 'lineTo' : 'moveTo'; + if ( _radial && x < 0 ) { + const nextBar = _bars[ i + 1 ]; + h = findY( x, h, nextBar.posX, nextBar.peak[ channel ], 0 ); + x = 0; + } + h *= maxBarHeight; + if ( showPeakLine ) { + _ctx[ m ]( ...( _radial ? radialXY( x, h ) : [ x, analyzerBottom - h ] ) ); + if ( _radial && _mirror && ! isDualHorizontal ) + points.push( [ x, h ] ); + } + else if ( b.peak[ channel ] > 0 ) { // note: `h` is negative in inner radial + if ( _fadePeaks ) + _ctx.globalAlpha = b.alpha[ channel ]; + + radialPoly( x, h, 1, -2 ); // standard peaks (also does mirror) + } + }); + if ( showPeakLine ) { + let p; + while ( p = points.pop() ) + _ctx.lineTo( ...radialXY( ...p, -1 ) ); // mirror line points + _ctx.lineWidth = 1; + _ctx.stroke(); // stroke peak line + } + } + } + + _ctx.restore(); // restore clip region + + if ( isDualHorizontal && ! _radial ) + _ctx.setTransform( 1, 0, 0, 1, 0, 0 ); + + // create Reflex effect - for dual-combined and dual-horizontal do it only once, after channel 1 + if ( ( ! isDualHorizontal && ! isDualCombined ) || channel ) + doReflex( channel ); + + } // for ( let channel = 0; channel < nChannels; channel++ ) { + + updateEnergy( currentEnergy / ( nBars << ( nChannels - 1 ) ) ); + + if ( useCanvas ) { + // Mirror effect + if ( _mirror && ! _radial && ! isDualHorizontal ) { + _ctx.setTransform( -1, 0, 0, 1, canvas.width - initialX, 0 ); + _ctx.drawImage( canvas, initialX, 0, centerX, canvas.height, 0, 0, centerX, canvas.height ); + _ctx.setTransform( 1, 0, 0, 1, 0, 0 ); + } + + // restore solid lines + _ctx.setLineDash([]); + + // draw frequency scale (X-axis) + drawScaleX(); + } + + // display current frame rate + if ( this.showFPS ) { + const size = canvasX.height; + _ctx.font = `bold ${size}px ${FONT_FAMILY}`; + _ctx.fillStyle = FPS_COLOR; + _ctx.textAlign = 'right'; + _ctx.fillText( Math.round( _fps ), canvas.width - size, size * 2 ); + } + + // call callback function, if defined + if ( this.onCanvasDraw ) { + _ctx.save(); + _ctx.fillStyle = _ctx.strokeStyle = _canvasGradients[0]; + this.onCanvasDraw( this, { timestamp, canvasGradients: _canvasGradients } ); + _ctx.restore(); + } + } + + /** + * Return scaled frequency according to the selected scale + */ + _freqScaling( freq ) { + switch ( this._frequencyScale ) { + case SCALE_LOG : + return Math.log2( freq ); + case SCALE_BARK : + return ( 26.81 * freq ) / ( 1960 + freq ) - .53; + case SCALE_MEL : + return Math.log2( 1 + freq / 700 ); + case SCALE_LINEAR : + return freq; + } + } + + /** + * Return the FFT data bin (array index) which represents a given frequency + */ + _freqToBin( freq, method = 'round' ) { + const max = this._analyzer[0].frequencyBinCount - 1, + bin = Math[ method ]( freq * this.fftSize / this.audioCtx.sampleRate ); + + return bin < max ? bin : max; + } + + /** + * Generate currently selected gradient + */ + _makeGrad() { + if ( ! this._ready ) + return; + + const { canvas, _ctx, _radial, _reflexRatio } = this, + { analyzerWidth, centerX, centerY, initialX, innerRadius, outerRadius } = this._aux, + { isLumi } = this._flg, + isDualVertical = this._chLayout == CHANNEL_VERTICAL, + analyzerRatio = 1 - _reflexRatio, + gradientHeight = isLumi ? canvas.height : canvas.height * ( 1 - _reflexRatio * ( ! isDualVertical ) ) | 0; + // for vertical stereo we keep the full canvas height and handle the reflex areas while generating the color stops + + for ( const channel of [0,1] ) { + const currGradient = this._gradients[ this._selectedGrads[ channel ] ], + colorStops = currGradient.colorStops, + isHorizontal = currGradient.dir == 'h'; + + let grad; + + if ( _radial ) + grad = _ctx.createRadialGradient( centerX, centerY, outerRadius, centerX, centerY, innerRadius - ( outerRadius - innerRadius ) * isDualVertical ); + else + grad = _ctx.createLinearGradient( ...( isHorizontal ? [ initialX, 0, initialX + analyzerWidth, 0 ] : [ 0, 0, 0, gradientHeight ] ) ); + + if ( colorStops ) { + const dual = isDualVertical && ! this._splitGradient && ( ! isHorizontal || _radial ); + + for ( let channelArea = 0; channelArea < 1 + dual; channelArea++ ) { + const maxIndex = colorStops.length - 1; + + colorStops.forEach( ( colorStop, index ) => { + let offset = colorStop.pos; + + // in dual mode (not split), use half the original offset for each channel + if ( dual ) + offset /= 2; + + // constrain the offset within the useful analyzer areas (avoid reflex areas) + if ( isDualVertical && ! isLumi && ! _radial && ! isHorizontal ) { + offset *= analyzerRatio; + // skip the first reflex area in split mode + if ( ! dual && offset > .5 * analyzerRatio ) + offset += .5 * _reflexRatio; + } + + // only for dual-vertical non-split gradient (creates full gradient on both halves of the canvas) + if ( channelArea == 1 ) { + // add colors in reverse order if radial or lumi are active + if ( _radial || isLumi ) { + const revIndex = maxIndex - index; + colorStop = colorStops[ revIndex ]; + offset = 1 - colorStop.pos / 2; + } + else { + // if the first offset is not 0, create an additional color stop to prevent bleeding from the first channel + if ( index == 0 && offset > 0 ) + grad.addColorStop( .5, colorStop.color ); + // bump the offset to the second half of the gradient + offset += .5; + } + } + + // add gradient color stop + grad.addColorStop( offset, colorStop.color ); + + // create additional color stop at the end of first channel to prevent bleeding + if ( isDualVertical && index == maxIndex && offset < .5 ) + grad.addColorStop( .5, colorStop.color ); + }); + } // for ( let channelArea = 0; channelArea < 1 + dual; channelArea++ ) + } + + this._canvasGradients[ channel ] = grad; + } // for ( const channel of [0,1] ) + } + + /** + * Normalize a dB value in the [0;1] range + */ + _normalizedB( value ) { + const isLinear = this._linearAmplitude, + boost = isLinear ? 1 / this._linearBoost : 1, + clamp = ( val, min, max ) => val <= min ? min : val >= max ? max : val, + dBToLinear = val => 10 ** ( val / 20 ); + + let maxValue = this.maxDecibels, + minValue = this.minDecibels; + + if ( isLinear ) { + maxValue = dBToLinear( maxValue ); + minValue = dBToLinear( minValue ); + value = dBToLinear( value ) ** boost; + } + + return clamp( ( value - minValue ) / ( maxValue - minValue ) ** boost, 0, 1 ); + } + + /** + * Internal function to change canvas dimensions on demand + */ + _setCanvas( reason ) { + if ( ! this._ready ) + return; + + const { canvas, _ctx } = this, + canvasX = this._scaleX.canvas, + pixelRatio = window.devicePixelRatio / ( this._loRes + 1 ); + + let screenWidth = window.screen.width * pixelRatio, + screenHeight = window.screen.height * pixelRatio; + + // Fix for iOS Safari - swap width and height when in landscape + if ( Math.abs( window.orientation ) == 90 && screenWidth < screenHeight ) + [ screenWidth, screenHeight ] = [ screenHeight, screenWidth ]; + + const isFullscreen = this.isFullscreen, + isCanvasFs = isFullscreen && this._fsEl == canvas, + newWidth = isCanvasFs ? screenWidth : ( this._width || this._container.clientWidth || this._defaultWidth ) * pixelRatio | 0, + newHeight = isCanvasFs ? screenHeight : ( this._height || this._container.clientHeight || this._defaultHeight ) * pixelRatio | 0; + + // set/update object properties + this._pixelRatio = pixelRatio; + this._fsWidth = screenWidth; + this._fsHeight = screenHeight; + + // if this is not the constructor call and canvas dimensions haven't changed, quit + if ( reason != REASON_CREATE && canvas.width == newWidth && canvas.height == newHeight ) + return; + + // apply new dimensions + canvas.width = newWidth; + canvas.height = newHeight; + + // if not in overlay mode, paint the canvas black + if ( ! this.overlay ) { + _ctx.fillStyle = '#000'; + _ctx.fillRect( 0, 0, newWidth, newHeight ); + } + + // set lineJoin property for area fill mode (this is reset whenever the canvas size changes) + _ctx.lineJoin = 'bevel'; + + // update dimensions of the scale canvas + canvasX.width = newWidth; + canvasX.height = Math.max( 20 * pixelRatio, Math.min( newWidth, newHeight ) / 32 | 0 ); + + // calculate bar positions and led options + this._calcBars(); + + // (re)generate gradient + this._makeGrad(); + + // detect fullscreen changes (for Safari) + if ( this._fsStatus !== undefined && this._fsStatus !== isFullscreen ) + reason = REASON_FSCHANGE; + this._fsStatus = isFullscreen; + + // call the callback function, if defined + if ( this.onCanvasResize ) + this.onCanvasResize( reason, this ); + } + + /** + * Select a gradient for one or both channels + * + * @param {string} name gradient name + * @param [{number}] desired channel (0 or 1) - if empty or invalid, sets both channels + */ + _setGradient( name, channel ) { + if ( ! this._gradients.hasOwnProperty( name ) ) + throw new AudioMotionError( ERR_UNKNOWN_GRADIENT, name ); + + if ( ! [0,1].includes( channel ) ) { + this._selectedGrads[1] = name; + channel = 0; + } + + this._selectedGrads[ channel ] = name; + this._makeGrad(); + } + + /** + * Set object properties + */ + _setProps( options, useDefaults ) { + // callback functions properties + const callbacks = [ 'onCanvasDraw', 'onCanvasResize' ]; + + // properties not in the defaults (`stereo` is deprecated) + const extraProps = [ 'gradientLeft', 'gradientRight', 'stereo' ]; + + // build an array of valid properties; `start` is not an actual property and is handled after setting everything else + const validProps = Object.keys( DEFAULT_SETTINGS ).filter( e => e != 'start' ).concat( callbacks, extraProps ); + + if ( useDefaults || options === undefined ) + options = { ...DEFAULT_SETTINGS, ...options }; // merge options with defaults + + for ( const prop of Object.keys( options ) ) { + if ( callbacks.includes( prop ) && typeof options[ prop ] !== 'function' ) // check invalid callback + this[ prop ] = undefined; + else if ( validProps.includes( prop ) ) // set only valid properties + this[ prop ] = options[ prop ]; + } + + // deprecated - move this to the constructor in the next major release (`start` should be constructor-specific) + if ( options.start !== undefined ) + this.toggleAnalyzer( options.start ); + } + +} + +export { AudioMotionAnalyzer }; +export default AudioMotionAnalyzer; diff --git a/web/skins/classic/assets/version b/web/skins/classic/assets/version index 24493169a3..1085eb34c2 100644 --- a/web/skins/classic/assets/version +++ b/web/skins/classic/assets/version @@ -14,3 +14,6 @@ https://github.com/john-doherty/swiped-events *********************************** mb.extruder Oct 24, 2018 with magor changes IgorA100 https://github.com/pupunzi/jquery.mb.extruder +*********************************** +audioMotion-analyzer ver 4.5.4 09.01.2026 +https://github.com/hvianna/audioMotion-analyzer \ No newline at end of file diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index c93aac9740..9696751072 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -1589,6 +1589,48 @@ video-stream { padding:0; } +/* +++ Audio-motion +++ */ +audio-motion, audio-motion canvas { + display: block; + width: 100%; +} + +.imageFeed[data-not-display-video="true"], .imageFeed[data-not-display-video="true"] video-stream, .imageFeed[data-not-display-video="true"] video, /* monitor */ +*[id^="videoFeedStream"][data-not-display-video="true"] video { /* event */ + max-width:25px !important; /* Otherwise, the stream will not start playing, especially for RTSP2Web-MSE. You can't set it below 21px, as we have an analysis of 20px as a starting point for playback. */ + max-height: 25px !important; + border: none !important; + visibility: hidden !important; + position: absolute !important; + overflow: hidden !important; +} + +*[id^="videoFeedStream"][data-not-display-video="true"] .video-js{ /* event */ + height: 55px !important; +} + +*[id^="videoFeedStream"][data-not-display-video="true"] .vjs-control-bar { /* event */ + opacity: 1 !important; +} + +.montage-page .monitor { /* Experimental setting */ + border: 1px solid; + border-radius: 4px; +} + +.montage-page .monitor .imageFeed { /* Experimental setting */ + margin: -1px -1px 0 -1px; +} + +.audio-control-panel .volume { + display: inline-block; +} + +.audio-visualization .audio-control-panel { + float: right; +} +/* --- Audio-motion --- */ + /* .stream-info-status, .stream-info-mode { background-color: rgba(0,0,0,0.3); diff --git a/web/skins/classic/includes/config.php b/web/skins/classic/includes/config.php index 09824a86d5..43bf6a632a 100644 --- a/web/skins/classic/includes/config.php +++ b/web/skins/classic/includes/config.php @@ -18,6 +18,12 @@ // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. // +$whatDisplay = array( + 'OnlyVideo' => translate('Only video'), + 'OnlyAudioVisualization' => translate('Only audio visualization'), + 'VideoAudioVisualization' => translate('Video and audio visualization'), +); + $RTSP2WebTypes = array( 'HLS' => 'HLS', 'MSE' => 'MSE', diff --git a/web/skins/classic/includes/functions.php b/web/skins/classic/includes/functions.php index 312f6d67f8..b97c025a23 100644 --- a/web/skins/classic/includes/functions.php +++ b/web/skins/classic/includes/functions.php @@ -1786,6 +1786,9 @@ function xhtmlFooter() { $skinJsFile = getSkinFile('js/skin.js'); ?> + + + . + [this.infoIsAudio, this.infoIsVideo] = [1,2].map(() => document.createElement('i')); + this.infoIsAudio.setAttribute('id',"ama_is-audio" + this.mid); + this.infoIsAudio.setAttribute('class','material-icons md-18'); + this.infoIsAudio.innerText = 'music_off'; + this.infoIsVideo.setAttribute('id',"ama_is-video" + this.mid); + this.infoIsVideo.setAttribute('class','material-icons md-18'); + this.infoIsVideo.innerText = 'videocam_off'; + this.handlerEventListener = {}; + this.currentPlayer = null; // The current player during initialization + this.currentMediaStream = null; // The current MediaStream during initialization + + this.hide(); + } + + connectedCallback() { + //console.log('connectedCallback'); + } + + disconnectedCallback() { + //console.log('disconnectedCallback'); + } + + hide = function() { + this.classList.add('hidden'); + }; + + show = function() { + this.classList.remove('hidden'); + }; + + changeIconIsAudio = function(mode) { + const info = this.getInfoBlock(); + if (info) { + let isAudio = info.querySelector("#ama_is-audio" + this.mid); + if (!isAudio) { + info.appendChild(this.infoIsAudio); + isAudio = info.querySelector("#ama_is-audio" + this.mid); + } + if (isAudio) isAudio.innerHTML = (mode == 'off') ? 'music_off' : 'music_note'; + } + }; + + changeIconIsVideo = function(mode) { + const info = this.getInfoBlock(); + if (info) { + let isVideo = info.querySelector("#ama_is-video" + this.mid); + if (!isVideo) { + info.appendChild(this.infoIsVideo); + isVideo = info.querySelector("#ama_is-video" + this.mid); + } + if (isVideo) isVideo.innerHTML = (mode == 'off') ? 'videocam_off' : 'videocam'; + } + }; + + init = async function() { + //console.trace("<<<<>>", this.mid, "--", this.id, this.audioMotion); + const streamPlayer = this.getActivePlayer(); + const monitorStream = getMonitorStream(this.mid); + const mediaStream = monitorStream.mediaStream; + const audioTrack = monitorStream.audioTrack; + + if (this.currentPlayer !== null && streamPlayer === this.currentPlayer && this.currentMediaStream !== null && mediaStream.id === this.currentMediaStream.id) { + if (this.audioMotion && this.gainNode && mediaStream && mediaStream.active && audioTrack && !this.audioMotion.isOn) { + this.audioMotion.start(); + return; + } else { + console.log(`AudioMotion reinitialization for the same player "${streamPlayer}" is not allowed for monitor ID=${this.mid}`); + return; + } + } + + this.waitingGetTracksFromStream = true; + this.initCompleted = true; + + if (this.audioMotion) { + this.destroy(); + } + this.currentPlayer = streamPlayer; + this.currentMediaStream = monitorStream.mediaStream; + this.changeIconIsVideo('off'); + this.changeIconIsAudio('off'); + + if (!monitorStream.mediaStream) { + await this.getTracksFromStream(monitorStream); + } + this.createMotionAnalyzer(); + }; // END init = function() + + stop = function(force = true) { + if (this.audioMotion) { + this.audioMotion.stop(); + this.disconnectMediaStreamSource(); + this.initCompleted = false; + this.currentPlayer = this.currentMediaStream = null; + this.changeIconIsAudio('off'); + this.changeIconIsVideo('off'); + manageEventListener.removeEventListener(this.handlerEventListener['volumechange']); + } + }; // END stop = function() { + + pause = function() { + if (this.audioMotion && this.audioMotion.isOn) { + this.audioMotion.stop(); + } + }; + + start = function() { + if (this.audioMotion && this.audioMotion.isOn) return; + const monitorStream = getMonitorStream(this.mid); + const mediaStream = monitorStream.mediaStream; + const audioTrack = monitorStream.audioTrack; + if (this.audioMotion && this.gainNode && mediaStream && mediaStream.active && audioTrack) { + this.audioMotion.start(); + } else { + this.init(); + } + }; + + createMotionAnalyzer = function() { + const mid = this.mid; + const audioEl = this.getMediaStreamSource(); + const volumeControls = document.getElementById(`volumeControls${mid}`); + const monitorStream = getMonitorStream(mid) + if (!monitorStream) { + console.warn(`Audio visualization. Stream for monitor ID=${mid} not found.`); + return; + } + + this.audioMotion = new AudioMotionAnalyzer( + document.getElementById(`audioVisualization${mid}`), + { + //source: audioEl, // main audio source is the HTML