import VisuComponentStereo from '../utils/VisuComponentStereo.js';
import CanvasUtils from '../utils/CanvasUtils.js';
import ColorUtils from '../utils/ColorUtils.js';
class PeakMeter extends VisuComponentStereo {
/** @summary PeakMeter displays a splited or merged peak meter for audio signal
* @author Arthur Beaulieu
* @since 2020
* @augments VisuComponentStereo
* @description <blockquote>This component display a peak meter in several configuration. It can include a scale and its legend
* and be oriented vertically or horizontally. Modified https://github.com/esonderegger/web-audio-peak-meter</blockquote>
* @param {object} options - The peak meter options
* @param {string} options.type - The component type as string
* @param {object} options.player - The player to take as processing input (if inputNode is given, player source will be ignored)
* @param {object} options.renderTo - The DOM element to render canvas in
* @param {number} options.fftSize - The FFT size for analysis. Must be a power of 2. High values may lead to heavy CPU cost
* @param {object} [options.audioContext=null] - The audio context to base analysis from
* @param {object} [options.inputNode=null] - The audio node to take source instead of player's one
* @param {boolean} [options.merged=false] - Merge left and right channel into one output
* @param {object} [options.legend] - The peak meter legend options
* @param {number} [options.legend.dbScaleMin=60] - The min scale value
* @param {number} [options.legend.dbScaleTicks=6] - The tick distance, must be a multiple of scale min
* @param {object} [options.colors] - The oscilloscope background and signal color
* @param {string} [options.colors.background=ColorUtils.defaultPrimaryColor] - The background color
* @param {string} [options.colors.min=#56D45B] - The gradient min value
* @param {string} [options.colors.step0=#AFF2B3] - The gradient second value
* @param {string} [options.colors.step1=#FFAD67] - The gradient third value
* @param {string} [options.colors.step2=#FF6B67] - The gradient fourth value
* @param {string} [options.colors.max=#FFBAB8] - The gradient max value **/
constructor(options) {
super(options);
// Peak gradient
if (!options.colors || !options.colors.gradient) {
this._peakGradient = ColorUtils.defaultAudioGradient;
} else {
this._peakGradient = options.colors.gradient;
}
// Update canvas CSS background color
const bgColor = (options.colors ? options.colors.background || ColorUtils.defaultBackgroundColor : ColorUtils.defaultBackgroundColor);
if (this._merged === true) {
this._canvasL.style.backgroundColor = bgColor;
} else {
this._canvasL.style.backgroundColor = bgColor;
this._canvasR.style.backgroundColor = bgColor;
}
}
/* --------------------------------------------------------------------------------------------------------------- */
/* ------------------------------------- VISUCOMPONENTSTEREO OVERRIDES ---------------------------------------- */
/* --------------------------------------------------------------------------------------------------------------- */
/** @method
* @name _fillAttributes
* @private
* @override
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Internal method to fill internal properties from options object sent to constructor.</blockquote>
* @param {object} options - The frequency circle options
* @param {string} options.type - The component type as string
* @param {object} options.player - The player to take as processing input (if inputNode is given, player source will be ignored)
* @param {object} options.renderTo - The DOM element to render canvas in
* @param {number} options.fftSize - The FFT size for analysis. Must be a power of 2. High values may lead to heavy CPU cost
* @param {object} [options.audioContext=null] - The audio context to base analysis from
* @param {object} [options.inputNode=null] - The audio node to take source instead of player's one **/
_fillAttributes(options) {
super._fillAttributes(options);
this._orientation = options.orientation || 'horizontal';
this._legend = options.legend || null;
if (this._legend) {
this._dbScaleMin = options.legend.dbScaleMin || 60;
this._dbScaleTicks = options.legend.dbScaleTicks || 15;
} else {
this._dbScaleMin = 60;
this._dbScaleTicks = 15;
}
this._amplitudeL = 0;
this._amplitudeR = 0;
this._peakL = 0;
this._peakR = 0;
this._peakSetTimeL = null;
this._peakSetTimeR = null;
this._dom.scaleContainer = null;
this._dom.labels = [];
}
/** @method
* @name _buildUI
* @private
* @override
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Create and configure canvas then append it to given DOM element.</blockquote> **/
_buildUI() {
super._buildUI();
if (this._orientation === 'horizontal') {
this._dom.container.classList.add('horizontal-peakmeter');
}
if (this._legend) {
this._dom.scaleContainer = document.createElement('DIV');
this._dom.scaleContainer.classList.add('scale-container');
this._dom.container.insertBefore(this._dom.scaleContainer, this._dom.container.firstChild);
}
if (this._merged === true) {
this._dom.container.removeChild(this._canvasR);
}
this._updateDimensions();
if (this._legend) {
this._createPeakLabel();
this._createScaleTicks();
}
}
/** @method
* @name _setAudioNodes
* @private
* @override
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Build audio chain with source.</blockquote> **/
_setAudioNodes() {
super._setAudioNodes();
this._peakSetTimeL = this._audioCtx.currentTime;
this._peakSetTimeR = this._audioCtx.currentTime;
}
/** @method
* @name _pause
* @private
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>On pause event callback.</blockquote> **/
_pause() {
super._pause();
if (this._legend) {
this._dom.labels[0].textContent = '-∞';
this._dom.labels[1].textContent = '-∞';
}
}
/** @method
* @name _onResize
* @private
* @override
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>On resize event callback.</blockquote> **/
_onResize() {
super._onResize();
this._updateDimensions();
if (this._legend) {
this._createPeakLabel();
this._createScaleTicks();
}
}
/** @method
* @name _processAudioBin
* @private
* @override
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Real time method called by WebAudioAPI to process PCM data. Here we make a 8 bit frequency
* and time analysis. Then we use utils method to draw radial oscilloscope, linear point oscilloscope, background points
* and radial frequency bars.</blockquote> **/
_processAudioBin() {
if (this._isPlaying === true) {
this._clearCanvas();
if (this._merged === true) {
this._mergedStereoAnalysis();
} else {
this._stereoAnalysis();
}
// Draw next frame
requestAnimationFrame(this._processAudioBin);
}
}
/* ---------- PeakMeter internal methods ---------- */
/** @method
* @name _mergedStereoAnalysis
* @private
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Perform a merged Left and Right analysis with 32 bit time domain data.</blockquote> **/
_mergedStereoAnalysis() {
const data = new Float32Array(this._fftSize);
this._nodes.analyser.getFloatTimeDomainData(data);
// Compute average power over the interval and average power attenuation in DB
let sumOfSquares = 0;
for (let i = 0; i < data.length; i++) {
sumOfSquares += data[i] * data[i];
}
const avgPowerDecibels = 10 * Math.log10(sumOfSquares / data.length);
// Compure amplitude from width or height depending on orientation
const dbScaleBound = this._dbScaleMin * -1;
if (this._orientation === 'horizontal') {
this._amplitudeL = Math.floor((avgPowerDecibels * this._canvasL.width) / dbScaleBound);
} else if (this._orientation === 'vertical') {
this._amplitudeL = Math.floor((avgPowerDecibels * this._canvasL.height) / dbScaleBound);
}
// Left channel
// Found a new max value (peak) [-this._dbScaleMin, 0] interval
if (this._peakL > this._amplitudeL) {
this._peakL = this._amplitudeL;
this._peakSetTimeL = this._audioCtx.currentTime;
// Update peak label
if (this._legend) {
avgPowerDecibels !== -Infinity ? this._dom.labels[0].textContent = CanvasUtils.precisionRound(avgPowerDecibels, 1) : null;
}
} else if (this._audioCtx.currentTime - this._peakSetTimeL > 1) {
this._peakL = this._amplitudeL;
this._peakSetTimeL = this._audioCtx.currentTime;
// Update peak label
if (this._legend) {
avgPowerDecibels !== -Infinity ? this._dom.labels[0].textContent = CanvasUtils.precisionRound(avgPowerDecibels, 1) : null;
}
}
// Draw left and right peak meters
CanvasUtils.drawPeakMeter(this._canvasL, {
amplitude: this._amplitudeL,
peak: this._peakL,
orientation: this._orientation,
colors: this._peakGradient
});
}
/** @method
* @name _stereoAnalysis
* @private
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Perform a separated Left and Right analysis with 32 bit time domain data.</blockquote> **/
_stereoAnalysis() {
const dataL = new Float32Array(this._fftSize);
const dataR = new Float32Array(this._fftSize);
this._nodes.analyserL.getFloatTimeDomainData(dataL);
this._nodes.analyserR.getFloatTimeDomainData(dataR);
// Compute average power over the interval and average power attenuation in DB
let sumOfSquaresL = 0;
let sumOfSquaresR = 0;
for (let i = 0; i < dataL.length; i++) {
sumOfSquaresL += dataL[i] * dataL[i];
sumOfSquaresR += dataR[i] * dataR[i];
}
const avgPowerDecibelsL = 10 * Math.log10(sumOfSquaresL / dataL.length);
const avgPowerDecibelsR = 10 * Math.log10(sumOfSquaresR / dataR.length);
// Compute amplitude from width or height depending on orientation
const dbScaleBound = this._dbScaleMin * -1;
if (this._orientation === 'horizontal') {
this._amplitudeL = Math.floor((avgPowerDecibelsL * this._canvasL.width) / dbScaleBound);
this._amplitudeR = Math.floor((avgPowerDecibelsR * this._canvasR.width) / dbScaleBound);
} else if (this._orientation === 'vertical') {
this._amplitudeL = Math.floor((avgPowerDecibelsL * this._canvasL.height) / dbScaleBound);
this._amplitudeR = Math.floor((avgPowerDecibelsR * this._canvasR.height) / dbScaleBound);
}
// Left channel
// Found a new max value (peak) [-this._dbScaleMin, 0] interval
if (this._peakL > this._amplitudeL) {
this._peakL = this._amplitudeL;
this._peakSetTimeL = this._audioCtx.currentTime;
// Update peak label
if (this._legend) {
avgPowerDecibelsL !== -Infinity ? this._dom.labels[0].textContent = CanvasUtils.precisionRound(avgPowerDecibelsL, 1) : null;
}
} else if (this._audioCtx.currentTime - this._peakSetTimeL > 1) {
this._peakL = this._amplitudeL;
this._peakSetTimeL = this._audioCtx.currentTime;
// Update peak label
if (this._legend) {
avgPowerDecibelsL !== -Infinity ? this._dom.labels[0].textContent = CanvasUtils.precisionRound(avgPowerDecibelsL, 1) : null;
}
}
// Right channel
// Found a new max value (peak) [-this._dbScaleMin, 0] interval
if (this._peakR > this._amplitudeR) {
this._peakR = this._amplitudeR;
this._peakSetTimeR = this._audioCtx.currentTime;
// Update peak label
if (this._legend) {
avgPowerDecibelsR !== -Infinity ? this._dom.labels[1].textContent = CanvasUtils.precisionRound(avgPowerDecibelsR, 1) : null;
}
} else if (this._audioCtx.currentTime - this._peakSetTimeR > 1) {
this._peakR = this._amplitudeL;
this._peakSetTimeR = this._audioCtx.currentTime;
// Update peak label
if (this._legend) {
avgPowerDecibelsR !== -Infinity ? this._dom.labels[1].textContent = CanvasUtils.precisionRound(avgPowerDecibelsR, 1) : null;
}
}
// Draw left and right peak meters
CanvasUtils.drawPeakMeter(this._canvasL, {
amplitude: this._amplitudeL,
peak: this._peakL,
orientation: this._orientation,
colors: this._peakGradient
});
CanvasUtils.drawPeakMeter(this._canvasR, {
amplitude: this._amplitudeR,
peak: this._peakR,
orientation: this._orientation,
colors: this._peakGradient
});
}
/** @method
* @name _createScaleTicks
* @private
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Build the scale tick depending on component orientation.</blockquote> **/
_createScaleTicks() {
const numTicks = Math.floor(this._dbScaleMin / this._dbScaleTicks);
let dbTickLabel = 0;
this._dom.scaleContainer.innerHTML = '';
if (this._orientation === 'horizontal') {
const tickWidth = this._canvasL.width / numTicks;
for (let i = 0; i < numTicks; ++i) {
const dbTick = document.createElement('DIV');
this._dom.scaleContainer.appendChild(dbTick);
dbTick.style.width = `${tickWidth}px`;
dbTick.textContent = `${dbTickLabel}`;
dbTickLabel -= this._dbScaleTicks;
}
} else {
const tickHeight = this._canvasL.height / numTicks;
for (let i = 0; i < numTicks; ++i) {
const dbTick = document.createElement('DIV');
this._dom.scaleContainer.appendChild(dbTick);
dbTick.style.height = `${tickHeight}px`;
dbTick.textContent = `${dbTickLabel}`;
dbTickLabel -= this._dbScaleTicks;
}
}
}
/** @method
* @name _createPeakLabel
* @private
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Build the scale legend depending on component orientation.</blockquote> **/
_createPeakLabel() {
if (this._dom.labels.length === 2) {
this._dom.container.removeChild(this._dom.labels[0]);
this._dom.container.removeChild(this._dom.labels[1]);
this._dom.labels = [];
}
const peakLabelL = document.createElement('DIV');
const peakLabelR = document.createElement('DIV');
peakLabelL.classList.add('peak-value-container');
peakLabelR.classList.add('peak-value-container');
peakLabelL.textContent = '-∞';
peakLabelR.textContent = '-∞';
if (this._orientation === 'horizontal') {
peakLabelL.style.width = '28px';
peakLabelL.style.height = `${this._canvasL.height + 2}px`; // 2 px borders
peakLabelL.style.top = '14px';
peakLabelR.style.width = '28px';
peakLabelR.style.height = `${this._canvasL.height + 2}px`; // 2 px borders
peakLabelR.style.top = `${this._canvasL.height + 16}px`; // 2px borders + 14px height
} else {
peakLabelL.style.width = `${this._canvasL.width + 2}px`; // 2 px borders
peakLabelL.style.left = '18px';
peakLabelR.style.width = `${this._canvasL.width + 2}px`; // 2 px borders
peakLabelR.style.left = `${this._canvasL.width + 20}px`; // 2px borders + 18px width
}
this._dom.labels.push(peakLabelL);
this._dom.labels.push(peakLabelR);
this._dom.container.appendChild(this._dom.labels[0]);
this._dom.container.appendChild(this._dom.labels[1]);
}
/** @method
* @name _updateDimensions
* @private
* @memberof PeakMeter
* @author Arthur Beaulieu
* @since 2020
* @description <blockquote>Usually called on resize event, update canvas dimension to fit render to DOM object.</blockquote> **/
_updateDimensions() {
let widthOffset = 0;
let heightOffset = 0;
if (this._orientation === 'horizontal') {
if (this._legend) {
widthOffset = 30;
heightOffset = 14;
}
this._canvasL.width = this._renderTo.offsetWidth - widthOffset; // 2px borders + 28 px with for label
if (this._merged === true) {
this._canvasL.height = (this._renderTo.offsetHeight - heightOffset) - 2; // 2px border + scale height 14px
this._canvasR.height = (this._renderTo.offsetHeight - heightOffset) - 2; // 2px border + scale height 14px
} else {
this._canvasR.width = this._renderTo.offsetWidth - widthOffset; // 2px borders + 28 px with for label
this._canvasL.height = (this._renderTo.offsetHeight - heightOffset) / 2 - 2; // 2px border + scale height 14px
this._canvasR.height = (this._renderTo.offsetHeight - heightOffset) / 2 - 2; // 2px border + scale height 14px
}
if (this._legend) {
this._dom.scaleContainer.style.width = `${this._canvasL.width}px`;
}
} else if (this._orientation === 'vertical') {
if (this._legend) {
widthOffset = 18;
heightOffset = 16;
} else {
this._canvasL.style.left = '0'; // Remove left offset for legend
}
this._canvasL.height = this._renderTo.offsetHeight - heightOffset - 2; // 2px borders + 16px height for label
if (this._merged === true) {
this._canvasL.width = (this._renderTo.offsetWidth - widthOffset) - 2; // 2px border + scale width 18px
this._canvasR.width = (this._renderTo.offsetWidth - widthOffset) - 2; // 2px border + scale width 18px
} else {
this._canvasR.height = this._renderTo.offsetHeight - heightOffset - 2; // 2px borders + 16px height for label
this._canvasL.width = (this._renderTo.offsetWidth - widthOffset) / 2 - 2; // 2px border + scale width 18px
this._canvasR.width = (this._renderTo.offsetWidth - widthOffset) / 2 - 2; // 2px border + scale width 18px
}
if (this._legend) {
this._dom.scaleContainer.style.height = `${this._canvasL.height}px`;
this._dom.scaleContainer.style.width = '18px';
}
}
}
}
export default PeakMeter;