Source: client/sink/VuMeterDisplay.js

import BaseDisplay from './BaseDisplay';
import Rms from '../../common/operator/Rms';

const log10 = Math.log10;

const definitions = {
  offset: {
    type: 'float',
    default: -14,
    metas: { kind: 'dyanmic' },
  },
  min: {
    type: 'float',
    default: -80,
    metas: { kind: 'dynamic' },
  },
  max: {
    type: 'float',
    default: 6,
    metas: { kind: 'dynamic' },
  },
  width: {
    type: 'integer',
    default: 6,
    metas: { kind: 'dynamic' },
  }
}

/**
 * Simple VU-Meter to used on a `signal` stream.
 *
 * @memberof module:client.sink
 *
 * @param {Object} options - Override defaults parameters.
 * @param {Number} [options.offset=-14] - dB offset applied to the signal.
 * @param {Number} [options.min=-80] - Minimum displayed value (in dB).
 * @param {Number} [options.max=6] - Maximum displayed value (in dB).
 * @param {Number} [options.width=6] - Width of the display (in pixels).
 * @param {Number} [options.height=150] - Height of the canvas.
 * @param {Element|CSSSelector} [options.container=null] - Container element
 *  in which to insert the canvas.
 * @param {Element|CSSSelector} [options.canvas=null] - Canvas element
 *  in which to draw.
 *
 * @example
 * import * as lfo from 'waves-lfo/client';
 *
 * const audioContext = new window.AudioContext();
 *
 * navigator.mediaDevices
 *   .getUserMedia({ audio: true })
 *   .then(init)
 *   .catch((err) => console.error(err.stack));
 *
 * function init(stream) {
 *   const source = audioContext.createMediaStreamSource(stream);
 *
 *   const audioInNode = new lfo.source.AudioInNode({
 *     audioContext: audioContext,
 *     sourceNode: source,
 *   });
 *
 *   const vuMeter = new lfo.sink.VuMeterDisplay({
 *     canvas: '#vu-meter',
 *   });
 *
 *   audioInNode.connect(vuMeter);
 *   audioInNode.start();
 * }
 */
class VuMeterDisplay extends BaseDisplay {
  constructor(options = {}) {
    super(definitions, options, false);

    this.rmsOperator = new Rms();

    this.lastDB = 0;
    this.peak = {
      value: 0,
      time: 0,
    }

    this.peakLifetime = 1; // sec
  }

  /** @private */
  processStreamParams(prevStreamParams) {
    this.prepareStreamParams(prevStreamParams);

    this.rmsOperator.initStream(this.streamParams);

    this.propagateStreamParams();
  }

  /** @private */
  processSignal(frame) {
    const now = new Date().getTime() / 1000; // sec
    const offset = this.params.get('offset'); // offset zero of the vu meter
    const height = this.canvasHeight;
    const width = this.canvasWidth;
    const ctx = this.ctx;

    const lastDB = this.lastDB;
    const peak = this.peak;

    const red = '#ff2121';
    const yellow = '#ffff1f';
    const green = '#00ff00';

    // handle current db value
    const rms = this.rmsOperator.inputSignal(frame.data);
    let dB = 20 * log10(rms) - offset;

    // slow release (could probably be improved)
    if (lastDB > dB)
      dB = lastDB - 6;

    // handle peak
    if (dB > peak.value || (now - peak.time) > this.peakLifetime) {
      peak.value = dB;
      peak.time = now;
    }

    const y0 = this.getYPosition(0);
    const y = this.getYPosition(dB);
    const yPeak = this.getYPosition(peak.value);

    ctx.save();

    ctx.fillStyle = '#000000';
    ctx.fillRect(0, 0, width, height);

    const gradient = ctx.createLinearGradient(0, height, 0, 0);
    gradient.addColorStop(0, green);
    gradient.addColorStop((height - y0) / height, yellow);
    gradient.addColorStop(1, red);

    // dB
    ctx.fillStyle = gradient;
    ctx.fillRect(0, y, width, height - y);

    // 0 dB marker
    ctx.fillStyle = '#dcdcdc';
    ctx.fillRect(0, y0, width, 2);

    // peak
    ctx.fillStyle = gradient;
    ctx.fillRect(0, yPeak, width, 2);

    ctx.restore();

    this.lastDB = dB;
  }
}

export default VuMeterDisplay;