Source: components/Spectrum.js

import VisuComponentStereo from '../utils/VisuComponentStereo.js';


class Spectrum extends VisuComponentStereo {


  /** @summary Spectrum displays real time audio frequencies as vertical bars that scroll over time
   * @author Arthur Beaulieu
   * @since 2020
   * @augments VisuComponentStereo
   * @description <blockquote>.</blockquote>
   * @param {object} options - The spectrum 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 {boolean} [options.scale=false] - The peak meter legend
   * @param {boolean} [options.colorSmoothing=false] - Display color intensity with a gradient to next sample value **/
  constructor(options) {
    super(options);
    this._updateDimensions();
    this._createLogarithmicScaleHeights();
    // Update canvas CSS background color
    const bgColor = 'black';
    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 Spectrum
   * @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
   * @param {boolean} [options.scale=false] - The peak meter legend
   * @param {boolean} [options.colorSmoothing=false] - Display color intensity with a gradient to next sample value **/
  _fillAttributes(options) {
    super._fillAttributes(options);
    // Spectrum specific attributes
    this._scaleType = options.scale || 'linear';
    this._colorSmoothing = options.colorSmoothing || false;
    this._canvasSpeed = 1; // Canvas offset per bin
    // Used to animate canvas on audio bins analysis
    this._bufferCanvas = null;
    this._bufferCtx = null;
    // Display utils
    this._dom.settings = null;
    this._dom.settingsPanel = null;
    this._dimension = {
      height: null,
      canvasHeight: null,
      width: null
    };
    this._logScale = [];
    // Event binding
    this._settingsClicked = this._settingsClicked.bind(this);
    this._clickedElsewhere = this._clickedElsewhere.bind(this);
  }


  /** @method
   * @name _buildUI
   * @private
   * @override
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Create and configure canvas then append it to given DOM element.</blockquote> **/
  _buildUI() {
    super._buildUI();
    this._bufferCanvas = document.createElement('CANVAS');
    this._bufferCtx = this._bufferCanvas.getContext('2d');

    if (this._merged === true) {
      this._dom.container.removeChild(this._canvasR);
    }
    // Update canvas dimensions
    this._canvasL.width = this._dimension.width;
    this._canvasL.height = this._dimension.canvasHeight;
    this._canvasR.width = this._dimension.width;
    this._canvasR.height = this._dimension.canvasHeight;
    this._bufferCanvas.width = this._dimension.width;
    this._bufferCanvas.height = this._dimension.canvasHeight;
    // Create option button
    const parser = new DOMParser();
    this._dom.settings = parser.parseFromString(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M24 13.616v-3.232c-1.651-.587-2.694-.752-3.219-2.019v-.001c-.527-1.271.1-2.134.847-3.707l-2.285-2.285c-1.561.742-2.433 1.375-3.707.847h-.001c-1.269-.526-1.435-1.576-2.019-3.219h-3.232c-.582 1.635-.749 2.692-2.019 3.219h-.001c-1.271.528-2.132-.098-3.707-.847l-2.285 2.285c.745 1.568 1.375 2.434.847 3.707-.527 1.271-1.584 1.438-3.219 2.02v3.232c1.632.58 2.692.749 3.219 2.019.53 1.282-.114 2.166-.847 3.707l2.285 2.286c1.562-.743 2.434-1.375 3.707-.847h.001c1.27.526 1.436 1.579 2.019 3.219h3.232c.582-1.636.75-2.69 2.027-3.222h.001c1.262-.524 2.12.101 3.698.851l2.285-2.286c-.744-1.563-1.375-2.433-.848-3.706.527-1.271 1.588-1.44 3.221-2.021zm-12 2.384c-2.209 0-4-1.791-4-4s1.791-4 4-4 4 1.791 4 4-1.791 4-4 4z"/></svg>`, 'image/svg+xml').documentElement;
    this._dom.settings.classList.add('audio-spectrum-settings');
    this._dom.settingsPanel = document.createElement('DIV');
    this._dom.settingsPanel.classList.add('audio-spectrum-settings-panel');
    this._dom.settingsPanel.innerHTML = `
      <h3>Settings</h3>
      <form>
        <p class="legend">Scale:</p>
        <label for="linear">Linear</label>
        <input type="radio" id="id-linear" name="scale" value="linear" ${this._scaleType === 'linear' ? 'checked' : ''}>
        <label for="logarithmic">Logarithmic</label>
        <input type="radio" id="id-logarithmic" name="scale" value="logarithmic" ${this._scaleType === 'logarithmic' ? 'checked' : ''}>
        <p class="smooth-color">
          <label for="smoothColor">Smooth colors</label>
          <input type="checkbox" id="smoothColor" name="smoothColor" ${this._colorSmoothing ? 'checked' : ''}>
        </p>
      </form>
    `;
    const form = this._dom.settingsPanel.querySelector('form');
    form.addEventListener('change', event => {
      event.preventDefault(); // Prevent location redirection with params
      const data = new FormData(form);
      const output = [];
      // Iterate over radios to extract values
      for (const entry of data) {
        output.push(entry[1]);
      }
      // Update canvas scale
      this._scaleType = output[0];
      // Set color smoothing from checkbox
      this._colorSmoothing = (output[1] === 'on');
    }, false);
    // Add display canvas to renderTo parent
    this._dom.container.appendChild(this._dom.settingsPanel); // Append panel before to emulate z-index under settings button w/ no scss rules of z-index
    this._dom.container.appendChild(this._dom.settings);
    this._dom.settings.addEventListener('click', this._settingsClicked, false);
  }


  /** @method
   * @name _removeEvents
   * @private
   * @override
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Add component events (resize, play, pause, dbclick).</blockquote> **/
  _removeEvents() {
    super._removeEvents();
    document.body.removeEventListener('click', this._clickedElsewhere, false);
  }


  /** @method
   * @name _onResize
   * @private
   * @override
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>On resize event callback.</blockquote> **/
  _onResize() {
    super._onResize();
    this._updateDimensions();
    this._createLogarithmicScaleHeights();
    // Update canvas dimensions
    this._canvasL.width = this._dimension.width;
    this._canvasL.height = this._dimension.canvasHeight;

    if (this._merged === false) {
      this._canvasR.width = this._dimension.width;
      this._canvasR.height = this._dimension.canvasHeight;
    }

    this._bufferCanvas.width = this._dimension.width;
    this._bufferCanvas.height = this._dimension.canvasHeight;
  }


  /** @method
   * @name _processAudioBin
   * @private
   * @override
   * @memberof Spectrum
   * @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.</blockquote> **/
  _processAudioBin() {
    if (this._isPlaying === true) {
      if (this._merged === true) {
        this._mergedStereoAnalysis();
      } else {
        this._stereoAnalysis();
      }

      requestAnimationFrame(this._processAudioBin);
    }
  }


  /*  ----------  Spectrum internal methods  ----------  */


  /** @method
   * @name _mergedStereoAnalysis
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Perform a merged Left and Right analysis with 32 bit time domain data.</blockquote> **/
  _mergedStereoAnalysis() {
    const frequencies = new Uint8Array(this._nodes.analyser.frequencyBinCount);
    this._nodes.analyser.getByteFrequencyData(frequencies);
    this._drawSpectrogramForFrequencyBin(this._canvasL, frequencies);
  }


  /** @method
   * @name _stereoAnalysis
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Perform a separated Left and Right analysis with 32 bit time domain data.</blockquote> **/
  _stereoAnalysis() {
    const frequenciesL = new Uint8Array(this._nodes.analyserL.frequencyBinCount);
    const frequenciesR = new Uint8Array(this._nodes.analyserR.frequencyBinCount);
    this._nodes.analyserL.getByteFrequencyData(frequenciesL);
    this._nodes.analyserR.getByteFrequencyData(frequenciesR);
    this._drawSpectrogramForFrequencyBin(this._canvasL, frequenciesL);
    this._drawSpectrogramForFrequencyBin(this._canvasR, frequenciesR);
  }


  /** @method
   * @name _updateDimensions
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Usually called on resize event, update canvas dimension to fit render to DOM object.</blockquote> **/
  _updateDimensions() {
    this._dimension.width = this._renderTo.offsetWidth - 2;  // 2px borders

    if (this._merged === true) {
      this._dimension.height = this._renderTo.offsetHeight - 2; // 2px borders
      this._dimension.canvasHeight = this._dimension.height;
    } else {
      this._dimension.height = this._renderTo.offsetHeight - 4; // 2px borders times two channels
      this._dimension.canvasHeight = this._dimension.height / 2;
    }
  }


  /** @method
   * @name _settingsClicked
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Spectrum settings button callback.</blockquote> **/
  _settingsClicked() {
    const opened = this._dom.settingsPanel.classList.contains('opened');
    if (opened === false) { // If opened, settings closure will be handled in clickedElsewhere
      this._dom.settings.classList.add('opened');
      this._dom.settingsPanel.classList.add('opened');
      document.body.addEventListener('click', this._clickedElsewhere, false);
    }
  }


  /** @method
   * @name _clickedElsewhere
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Callback when a clicked is detected and settings context is open.</blockquote>
   * @param {object} event - The click event **/
  _clickedElsewhere(event) {
    if (!event.target.closest('.audio-spectrum-settings') && !event.target.closest('.audio-spectrum-settings-panel')) {
      this._dom.settings.classList.remove('opened');
      this._dom.settingsPanel.classList.remove('opened');
      document.body.removeEventListener('click', this._clickedElsewhere, false);
    }
  }


  /** @method
   * @name _drawSpectrogramForFrequencyBin
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Draw a vertical ray representing the audio frequencies at process time.</blockquote>
   * @param {object} canvas - The canvas to draw spectrum ray into
   * @param {Uint8Array} frequencies - The frequencies for a given audio bin **/
  _drawSpectrogramForFrequencyBin(canvas, frequencies) {
    const ctx = canvas.getContext('2d');
    // Copy previous image
    this._bufferCtx.drawImage(canvas, 0, 0, this._dimension.width, this._dimension.canvasHeight);
    // Array length is always (fftSize / 2)
    for (let i = 0; i < frequencies.length; ++i) {
      if (this._scaleType === 'linear') {
        this._fillRectLinear(ctx, frequencies, i);
      } else {
        this._fillRectLogarithm(ctx, frequencies, i);
      }
    }
    // Offset canvas to the left and paste stored image
    ctx.translate(-this._canvasSpeed, 0);
    ctx.drawImage(this._bufferCanvas, 0, 0, this._dimension.width, this._dimension.canvasHeight, 0, 0, this._dimension.width, this._dimension.canvasHeight);
    ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset the transformation matrix
  }


  /** @method
   * @name _drawSpectrogramForFrequencyBin
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Draw the vertical ray with a linear scale.</blockquote>
   * @param {object} ctx - The canvas context
   * @param {Uint8Array} frequencies - The frequencies for a given audio bin
   * @param {number} i - The index to scale linearly **/
  _fillRectLinear(ctx, frequencies, i) {
    const scaledHeight = this._scaleLinearIndexToHeight(i);
    const frequencyHeight = this._dimension.canvasHeight / frequencies.length;
    if (i === 0 || !this._colorSmoothing) {
      ctx.fillStyle = `rgb(${frequencies[i]}, ${frequencies[i]}, ${frequencies[i]})`;
    } else {
      const gradient = ctx.createLinearGradient(
        0, this._dimension.canvasHeight - scaledHeight - frequencyHeight, // X0/Y0
        0, this._dimension.canvasHeight - scaledHeight // X1/Y1
      );
      // Add color stops from current color to previous sample color
      gradient.addColorStop(0, `rgb(${frequencies[i]}, ${frequencies[i]}, ${frequencies[i]})`);
      gradient.addColorStop(1, `rgb(${frequencies[i - 1]}, ${frequencies[i - 1]}, ${frequencies[i - 1]})`);
      ctx.fillStyle = gradient;
    }
    // Linear scale
    ctx.fillRect(
      this._dimension.width - this._canvasSpeed, // X pos
      this._dimension.canvasHeight - scaledHeight - frequencyHeight, // Y pos
      this._canvasSpeed, // Width is speed value
      frequencyHeight // Height depends on canvas height
    );
  }


  /** @method
   * @name _drawSpectrogramForFrequencyBin
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Draw the vertical ray with a logarithm scale.</blockquote>
   * @param {object} ctx - The canvas context
   * @param {Uint8Array} frequencies - The frequencies for a given audio bin
   * @param {number} i - The index to scale logarithmically **/
  _fillRectLogarithm(ctx, frequencies, i) {
    if (i === 0 || i === frequencies.length - 1 || !this._colorSmoothing) {
      ctx.fillStyle = `rgb(${frequencies[i]}, ${frequencies[i]}, ${frequencies[i]})`;
    } else {
      const gradient = ctx.createLinearGradient(
        0, this._logScale[i], // X0/Y0
        0, this._logScale[i - 1] // X1/Y1
      );
      // Add color stops from current color to previous sample color
      gradient.addColorStop(0, `rgb(${frequencies[i]}, ${frequencies[i]}, ${frequencies[i]})`);
      gradient.addColorStop(1, `rgb(${frequencies[i - 1]}, ${frequencies[i - 1]}, ${frequencies[i - 1]})`);
      ctx.fillStyle = gradient;
    }
    // Log scale
    ctx.fillRect(
      this._dimension.width - this._canvasSpeed, // X pos
      this._logScale[i - 1], // Y pos
      this._canvasSpeed, // Width is speed value
      this._logScale[i] - this._logScale[i - 1] // Height is computed with previous sample offset
    );
  }


  /** @method
   * @name _scaleLinearIndexToHeight
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Convert linear value to logarithmic value.</blockquote>
   * @param {number} index - The canvas context **/
  _scaleLinearIndexToHeight(index) {
    // Convert a range to another, maintaining ratio
    // oldRange = (oldMax - oldMin)
    // newRange = (newMax - newMin)
    // newValue = (((oldValue - oldMin) * newRange) / oldRange) + NewMin */
    // Convert from [0, (this._fftSize / 2)] to [0, this._dimension.canvasHeight] (frequency array length scale to canvas height scale)
    const oldRange = this._fftSize / 2;
    const newRange = this._dimension.canvasHeight;
    return (index * newRange) / oldRange;
  }


  /** @method
   * @name _createLogarithmicScaleHeights
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Pre-compute samples height on a logarithmic scale to avoid computation on render process.</blockquote> **/
  _createLogarithmicScaleHeights() {
    return new Promise(resolve => {
      this._logScale = [this._dimension.canvasHeight]; // Reset previously made scale
      for (let i = 1; i < (this._fftSize / 2); ++i) { // Log(0) forbidden, we offset
        this._logScale.push(this._computeLogSampleHeight(i)); // For each frequency sample, compute its log height offset from origin
      }
      resolve();
    });
  }


  /** @method
   * @name _computeLogSampleHeight
   * @private
   * @memberof Spectrum
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Compute log sample height in canvas.</blockquote>
   * @param {number} sample - The sample to compute its log height **/
  _computeLogSampleHeight(sample) {
    return this._dimension.canvasHeight - (((Math.log(sample) / Math.log(10)) / (Math.log(this._fftSize / 2) / Math.log(10))) * this._dimension.canvasHeight);
  }


}


export default Spectrum;