Source: components/FrequencyCircle.js

import VisuComponentMono from '../utils/VisuComponentMono.js';
import CanvasUtils from '../utils/CanvasUtils.js';
import ColorUtils from '../utils/ColorUtils.js';


class FrequencyCircle extends VisuComponentMono {


  /** @summary FrequencyCircle displays a stylistic radial view in real time.
   * @author Arthur Beaulieu
   * @since 2020
   * @augments VisuComponentMono
   * @description <blockquote>This will display a single canvas with frequency displayed in. Inspired from
   * https://www.kkhaydarov.com/audio-visualizer/ and https://codepen.io/noeldelgado/pen/EaNjBy aka real mvps
   * that helped going through WebAudioAPI. It will combine a radial gradient in the background, a spinning logo
   * in the canvas center, radial frequency bars, radial oscilloscope, linear oscilloscope with it visibility handled
   * by real time audio intensity, as well as circular pulsing and glowing circle around the logo.</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 **/
  constructor(options) {
    super(options);
  }


  /*  --------------------------------------------------------------------------------------------------------------- */
  /*  --------------------------------------  VISUCOMPONENTMONO OVERRIDES  -----------------------------------------  */
  /*  --------------------------------------------------------------------------------------------------------------- */



  /** @method
   * @name _fillAttributes
   * @private
   * @override
   * @memberof FrequencyCircle
   * @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 {string} [options.image] - The image to put in center of canvas with a spinning animation **/
  _fillAttributes(options) {
    super._fillAttributes(options);
    // Frequency circle specific attributes
    this._imageSrc = null;
    this._centerX = null;
    this._centerY = null;
    this._radius = null;
    this._radialSection = null;
    this._barCount = null;
    this._barMaxHeight = null;
    this._circleStrokeWidth = null;
    this._stars = [];
    this._points = [];
    this._oscilloscopeRotation = null;
    // Dom specific elements for frequency circle
    this._dom.logo = null;
    // Intensity modifier
    this._averageBreakpoint = 132; // Putting breakpoint on mid amplitude [0, 255]
    this._averageHit = false;

    this._imageSrc = options.image;
    this._dom.logo = document.createElement('IMG');
    this._dom.logo.classList.add('paused');
    this._dom.logo.src = this._imageSrc ;
  }


  /** @method
   * @name _buildUI
   * @private
   * @override
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Create and configure canvas then append it to given DOM element.</blockquote> **/
  _buildUI() {
    super._buildUI();
    if (this._imageSrc) { this._dom.container.appendChild(this._dom.logo); }
    this._buildBackgroundBase();
  }


  /** @method
   * @name _play
   * @private
   * @override
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>On play event callback.</blockquote> **/
  _play() {
    super._play();
    this._dom.logo.classList.remove('paused'); // Resume scss animation
  }


  /** @method
   * @name _pause
   * @private
   * @override
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>On pause event callback.</blockquote> **/
  _pause() {
    super._pause();
    this._dom.logo.classList.add('paused'); // Pause scss animation
  }


  /** @method
   * @name _onResize
   * @private
   * @override
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>On resize event callback.</blockquote> **/
  _onResize() {
    super._onResize();
    this._circleStrokeWidth = 2;
    this._barCount = this._nodes.analyser.frequencyBinCount;
    this._centerX = this._canvas.width / 2;
    this._centerY = this._canvas.height / 2;
    this._barMaxHeight = this._canvas.height / 8;
    this._radius = (this._canvas.height / 4) - (this._canvas.height / 16);
    this._radialSection = (Math.PI * 2) / this._barCount;
    // Populating stars
    this._stars = [];
    for (let i = 0; i < 1500; ++i) {
      this._stars.push(new BackgroundStar(this._centerX, this._centerY, null, this._averageBreakpoint));
    }
    // Populating circular oscilloscope points
    this._points = [];
    for (let i = 0; i < (this._fftSize / 2); ++i) {
      this._points.push(new OscilloscopeRadialPoint({
        index: i,
        height: this._canvas.height,
        width: this._canvas.width,
        total: (this._fftSize / 2)
      }));
    }
    // Build canvas fixed base
    this._buildBackgroundBase();
  }


  /** @method
   * @name _processAudioBin
   * @private
   * @override
   * @memberof FrequencyCircle
   * @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();
      this._buildBackgroundBase();
      // Extract frequencies and times data
      const frequencies = new Uint8Array(this._nodes.analyser.frequencyBinCount);
      const times = new Uint8Array(this._nodes.analyser.frequencyBinCount);
      this._nodes.analyser.getByteFrequencyData(frequencies);
      this._nodes.analyser.getByteTimeDomainData(times);
      // Get average frequency for proccessed bin
      let average = this._getAverageFrequency(frequencies);
      this._averageHit = (average > this._averageBreakpoint);
      // Draw circle bars while retrieving aaverage amplitude
      this._animateCircleBars(frequencies);
      // Animate each star
      this._animateStars(average);
      // Draw average circle with its glow effect around center
      this._animateCircleGlow(average);
      // Draw circular oscilloscope and horizontal one if average hit
      this._animateOscilloscopes(times);
      // Request for next frame
      requestAnimationFrame(this._processAudioBin);
    }
  }


  /*  ----------  FrequencyCircle internal methods  ----------  */


  /** @method
   * @name _processAudioBin
   * @private
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Draw radial gradient background and circle that surround image.</blockquote> **/
  _buildBackgroundBase() {
    // Build background radial gradient
    // Color value according to ManaZeak's linear background colors
    ColorUtils.drawRadialGradient(this._canvas, {
      x0: this._centerX,
      y0: this._centerY,
      r0: this._radius,
      x1: this._centerX,
      y1: this._centerY,
      r1: this._canvas.width / 2.66,
      colors: [
        { color: '#3C405D', index: 0 },
        { color: '#060609', index: 1 }
      ]
    });
    // Build logo circle border
    CanvasUtils.drawCircle(this._canvas, {
      centerX: this._centerX,
      centerY: this._centerY,
      radius: this._radius,
      radStart: 0,
      radEnd: Math.PI * 2,
      width: this._circleStrokeWidth * 2 // Times two because stroke is centered on circle
    });
  }


  /** @method
   * @name _animateCircleBars
   * @private
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Compute the frequency circle shape according to audio BIN frequency array.</blockquote>
   * @param {number[]} frequencies - The frequency array for a given audio bin **/
  _animateCircleBars(frequencies) {
    // Compute radial width for each circular bar
    const barWidth = Math.round(this._radialSection * this._radius);
    // Iterate over frequencies to draw each matching frequency bin
    for (let i = 0; i < frequencies.length; ++i) {
      // Compute current bar height depending on intensity
      const barHeight = (frequencies[i] / 255) * this._barMaxHeight;
      // Use CanvasUtils to draw bar
      CanvasUtils.drawRadialBar(this._canvas, {
        frequencyValue: frequencies[i],
        x0: this._centerX + Math.cos(this._radialSection * i - (Math.PI / 2)) * (this._radius + this._circleStrokeWidth),
        y0: this._centerY + Math.sin(this._radialSection * i - (Math.PI / 2)) * (this._radius + this._circleStrokeWidth),
        x1: this._centerX + Math.cos(this._radialSection * i - (Math.PI / 2)) * (this._radius + this._circleStrokeWidth + barHeight),
        y1: this._centerY + Math.sin(this._radialSection * i - (Math.PI / 2)) * (this._radius + this._circleStrokeWidth + barHeight),
        width: barWidth,
        color: this._averageHit ? '#56D45B' : '#37C340'
      });
    }
  }


  /** @method
   * @name _animateStars
   * @private
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Animate background points to match intensity with color and radius.</blockquote>
   * @param {number} average - The average value that acts like a breakpoint for intensity **/
  _animateStars(average) {
    let tick = this._averageHit ? average / 20 : average / 60;
    for (let i = 0; i < this._stars.length; ++i) {
      let star = this._stars[i];
      // Update star position and variation
      star.updatePosition(tick, 0.6);
      // Replace star with new one if it went out canvas
      if (star.x < -this._centerX || star.x > this._centerX || star.y < -this._centerY || star.y > this._centerY) {
        star = new BackgroundStar(this._centerX, this._centerY, average, this._averageBreakpoint); // Update local variable
        this._stars[i] = star; // Save new reference
      }
      // Use CanvasUtils to draw star disc
      CanvasUtils.drawDisc(this._canvas, {
        centerX: star.x + this._centerX,
        centerY: star.y + this._centerY,
        radius: star.radius,
        radStart: Math.PI * 2,
        radEnd: false,
        color: star.color
      });
    }
  }


  /** @method
   * @name _animateCircleGlow
   * @private
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Animate the glowing circle around centred logo.</blockquote>
   * @param {number} average - The average value that acts like a breakpoint for intensity **/
  _animateCircleGlow(average) {
    // Build average amplitude glow with color change when average breakpoint is hit
    CanvasUtils.drawCircleGlow(this._canvas, {
      centerX: this._centerX,
      centerY: this._centerY,
      radius: ((this._radius * 1.33) + average) * 2, // Glow need twice radius to properly display gradient
      radStart: 0,
      radEnd: Math.PI * 2,
      colors: [
        { color: 'rgba(0, 0, 0, 0)', index: 0.48 },
        { color: (this._averageHit ? /* Green */ '#56D45B' : /* Blue */ '#48ABAF'), index: 0.5 },
        { color: 'rgba(0, 0, 0, 0)', index: 0.52 }
      ]
    });
  }


  /** @method
   * @name _animateOscilloscopes
   * @private
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Draw both radial and linear point oscilloscopes.</blockquote>
   * @param {number[]} times - The time domain for a given audio bin **/
  _animateOscilloscopes(times) {
    let tick = 0.05;
    let color = '#FFF';
    if (this._averageHit) {
      this._oscilloscopeRotation += tick;
      color = 'rgba(255, 193, 140, .7)'; // Orange
    } else {
      this._oscilloscopeRotation += -tick;
      color = 'rgba(125, 228, 132, 0.25)'; // Green
    }
    // Update radial oscilloscope with time values
    CanvasUtils.drawRadialOscilloscope(this._canvas, {
      points: this._points,
      times: times,
      length: this._fftSize / 2,
      centerX: this._centerX,
      centerY: this._centerY,
      rotation: this._oscilloscopeRotation,
      color: color
    });
    // If breakpoint is reached, we draw stillized horizontal oscilloscope
    if (this._averageHit) {
      CanvasUtils.drawPointsOscilloscope(this._canvas, {
        times: times,
        length: this._fftSize / 2,
        color: 'rgba(113, 201, 205, .7)'
      });
    }
  }


  /** @method
   * @name _getAverageFrequency
   * @private
   * @memberof FrequencyCircle
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Compute the average value for a given audio bin.</blockquote>
   * @param {number[]} frequencies - The frequency array for a given audio bin
   * @return {number} - The average value for a frequency bin **/
  _getAverageFrequency(frequencies) {
    let average = 0; // Output average value
    for (let i = 0; i < frequencies.length; ++i) {
      // Update average amplitude value
      average += frequencies[i];
    }
    // Return average value of frequencies
    return average / frequencies.length;
  }


}


/*  ----------  Utils class for this visualisation  ----------  */


class BackgroundStar {


  /** @summary BackgroundStar handle stars in frequency circle.
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>This will display a single canvas with frequency displayed with.</blockquote>
   * @param {number} centerX - The start x origin
   * @param {number} centerY - The start y origin
   * @param {number} [average=0] - The audio bin average value
   * @param {number} [breakpoint=132] - The size and color breakpoint value to be compared with average **/
  constructor(centerX, centerY, average = 0, breakpoint = 132) {
    // Public attributes
    this.radius = 0.4;
    this.color = '#0F8489'; // Dark blue
    this.x = Math.random() * (centerX * 2) - centerX;
    this.y = Math.random() * (centerY * 2) - centerY;
    // Private attributes
    this._z = Math.max((centerX * 2) / (centerY * 2));
    this._maxDepth = Math.max((centerX * 2) / (centerY * 2));
    // Set star variation in space
    if (Math.abs(this.x) > Math.abs(this.y)) {
      this._dx = 1.0;
      this._dy = Math.abs(this.y / this.x);
    } else {
      this._dx = Math.abs(this.x / this.y);
      this._dy = 1.0;
    }
    // Set variation relative to center
    this._dx *= (this.x > 0) ? 1 : -1;
    this._dy *= (this.y > 0) ? 1 : -1;
    this._dz = -0.1;
    // Determine color according to center or average intensity
    if (this.y > (centerY / 2)) {
      this.color = '#71C9CD'; // Light Blue
    } else if (average > breakpoint) {
      this.color = '#FF6B67'; // Red
    }
  }


  /** @method
   * @name updatePosition
   * @public
   * @memberof BackgroundStar
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Update the background star position.</blockquote>
   * @param {number} tick - The multiplier value for position variation
   * @param {number} radiusFactor - The star radius variation factor **/
  updatePosition(tick, radiusFactor) {
    // Update position
    this.x += this._dx * tick;
    this.y += this._dy * tick;
    this._z += this._dz; // Constant z variation
    // Update variation
    this._dx += this._dx * .001;
    this._dy += this._dy * .001;
    this.radius = radiusFactor + ((this._maxDepth - this._z) * .1);
  }


}


class OscilloscopeRadialPoint {


  /** @summary OscilloscopeRadialPoint handle each point in circular oscilloscope.
   * @author Arthur Beaulieu
   * @since 2020
   * @description <blockquote>Create a container for oscilloscope point. Edit radius, x and y public attributes.</blockquote>
   * @param {object} options - The oscilloscope radial point option
   * @param {number} options.height - The point height
   * @param {number} options.width - The point width
   * @param {number} options.total - The divider value for angle
   * @param {number} options.index - The numerator value for angle **/
  constructor(options) {
    this._height = options.height;
    this._width = options.width;
    this._total = options.total;
    this._index = options.index;
    this._value = Math.random() * 256;
    this._radius = Math.abs(this._width) / 8;
    // Public attributes
    this.angle = (this._index * 360) / this._total;
    this.x = (this._width / 2) + this._radius * Math.sin((Math.PI / 180) * this.angle);
    this.y = (this._height / 2) + this._radius * Math.cos((Math.PI / 180) * this.angle);
    this.dx = this.x + this._value * Math.sin((Math.PI / 180) * this.angle);
    this.dy = this.y + this._value * Math.cos((Math.PI / 180) * this.angle);
  }


}


export default FrequencyCircle;