widgets/dynamics.js

/*
 * This file is part of AUX.
 *
 * AUX is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * AUX is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General
 * Public License along with this program; if not, write to the
 * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
 * Boston, MA  02110-1301  USA
 */

import { Chart } from './chart.js';
import { addClass, removeClass } from '../utils/dom.js';
import { warn } from '../utils/log.js';
import { defineRender, defineMeasure } from '../renderer.js';
import { defineChildWidget } from '../child_widget.js';
import { ChartHandle } from './charthandle.js';
import { Graph } from './graph.js';

function rangeSet(value, key) {
  this.range_x.set(key, value);
  this.range_y.set(key, value);
}

function dragHandle(key, value) {
  if (key === 'z') {
    this.parent.userset('ratio', value);
    return true;
  }
  if (key === 'y') {
    this.parent.userset('threshold', value);
  }
  return false;
}

/**
 * Dynamics are based on {@link Chart} and display the characteristics of dynamic
 * processors. They are square widgets drawing a {@link Grid} automatically based on
 * the range.
 *
 * @class Dynamics
 *
 * @extends Chart
 *
 * @property {Number} [options.min=-96] - Minimum decibels to display.
 * @property {Number} [options.max=24] - Maximum decibels to display.
 * @property {String} [options.scale="linear"] - Scale of the display, see {@link Range} for details.
 * @property {String} [options.type=false] - Type of the dynamics: <code>compressor</code>, <code>expander</code>, <code>gate</code>, <code>limiter</code> or <code>false</code> to draw your own graph.
 * @property {Number} [options.threshold=0] - Threshold of the dynamics.
 * @property {Number} [options.ratio=1] - Ratio of the dynamics.
 * @property {Number} [options.makeup=0] - Makeup of the dynamics. This raises the whole graph after all other property are applied.
 * @property {Number} [options.range=0] - Range of the dynamics. Only used in type <code>expander</code>. The maximum gain reduction.
 * @property {Number} [options.gain=0] - Input gain of the dynamics.
 * @property {Number} [options.reference=0] - Input reference of the dynamics.
 * @property {Number} [options.knee=0] - Soft knee width of the compressor in dB.
 *   Replaces the hard knee of the compressor at the salient point by a
 *   quadratic curve.
 * @property {Function} [options.grid_labels=function (val) { return val + (!val ? "dB":""); }] - Callback to format the labels of the {@link Grid}.
 * @property {Number} [options.db_grid=12] - Draw a grid line every [n] decibels.
 * @property {Boolean} [options.show_handle=true] - Draw a handle to manipulate threshold and ratio.
 * @property {Boolean|Function} [options.format_label=false] - Function to format the handle label.
 */
export class Dynamics extends Chart {
  static get _options() {
    return {
      min: 'number',
      max: 'number',
      scale: 'string',
      type: 'string',
      threshold: 'number',
      ratio: 'number',
      makeup: 'number',
      range: 'number',
      gain: 'number',
      reference: 'number',
      knee: 'number',
      grid_labels: 'function',
      db_grid: 'number',
      show_handle: 'boolean',
      handle_label: 'boolean|function',
    };
  }

  static get options() {
    return {
      db_grid: 12,
      min: -96,
      max: 24,
      scale: 'linear',
      type: false,
      threshold: 0,
      ratio: 1,
      makeup: 0,
      range: -200,
      gain: 0,
      reference: 0,
      knee: 0,
      grid_labels: function (val) {
        return val + (!val ? 'dB' : '');
      },
      show_handle: true,
      handle_label: false,
      square: true,
      role: 'application',
    };
  }

  static get static_events() {
    return {
      set_min: rangeSet,
      set_max: rangeSet,
      set_scale: rangeSet,
    };
  }

  static get renderers() {
    return [
      defineRender('type', function (type) {
        const element = this.element;
        removeClass(
          element,
          'aux-compressor',
          'aux-expander',
          'aux-gate',
          'aux-limiter'
        );
        addClass(element, 'aux-' + type);
      }),
      defineMeasure(['min', 'max', 'grid_labels', 'db_grid'], function (
        min,
        max,
        grid_labels,
        db_grid
      ) {
        const grid_x = [];
        const grid_y = [];
        let cls;
        for (let i = min; i <= max; i += db_grid) {
          cls = i ? '' : 'aux-highlight';
          grid_x.push({
            pos: i,
            label: i === min ? '' : grid_labels(i),
            class: cls,
          });
          grid_y.push({
            pos: i,
            label: i === min ? '' : grid_labels(i),
            class: cls,
          });
        }
        if (this.grid) {
          this.grid.set('grid_x', grid_x);
          this.grid.set('grid_y', grid_y);
        }

        if (this.steady)
          this.steady.set('dots', [
            { x: min, y: min },
            { x: max, y: max },
          ]);
      }),
      defineMeasure(
        [
          'type',
          'min',
          'max',
          'range',
          'ratio',
          'threshold',
          'gain',
          'reference',
          'makeup',
          'knee',
        ],
        function (
          type,
          min,
          max,
          range,
          ratio,
          threshold,
          gain,
          reference,
          makeup,
          knee
        ) {
          this.drawGraph();
        }
      ),
    ];
  }

  initialize(options) {
    super.initialize(options, true);
    const O = this.options;
    /**
     * @member {HTMLDivElement} Dynamics#element - The main DIV container.
     *   Has class <code>.aux-dynamics</code>.
     */
    this.set('scale', O.scale);
    this.set('min', O.min);
    this.set('max', O.max);

    this.set('handle_label', this.options.handle_label);
    this.set('show_handle', this.options.show_handle);
    this.set('ratio', this.options.ratio);
    this.set('threshold', this.options.threshold);
  }

  draw(O, element) {
    addClass(element, 'aux-dynamics');

    super.draw(O, element);
  }

  drawGraph() {
    if (!this.response) return;
    const {
      type,
      min,
      max,
      range,
      ratio,
      threshold,
      gain,
      reference,
      makeup,
      knee,
    } = this.options;
    if (type === false) return;
    const curve = [];
    let slope;
    if (reference === 0) {
      slope = 0;
    } else if (!isFinite(ratio)) {
      slope = reference;
    } else {
      slope = (1 / (Math.max(ratio, 1.001) - 1)) * ratio * reference;
    }
    const l = 5; // estimated width of line. dirty workaround for
    // keeping the line end out of sight in case
    // salient point is outside the visible area
    switch (type) {
      case 'compressor': {
        const sx = threshold + gain - slope;
        const sy = threshold + makeup - slope + reference;

        // entry point
        curve.push({ x: min - l, y: min + makeup - gain + reference - l });
        if (knee > 0) {
          const dy0 = 1;
          const dy1 = isFinite(ratio) ? 1 / ratio : 0;
          const w = knee / 2;

          curve.push({
            x: Math.max(min, sx - w),
            y: sy - w * dy0,
          });

          curve.push({
            type: 'Q',
            x1: sx,
            y1: sy,
            x: Math.min(max, sx + w),
            y: sy + w * dy1,
          });
        } else {
          // salient point
          curve.push({
            x: sx,
            y: sy,
          });
        }
        // exit point
        if (isFinite(ratio) && ratio > 0) {
          curve.push({
            x: max,
            y: threshold + makeup + (max - threshold - gain) / ratio,
          });
        } else if (ratio === 0) {
          curve.push({ x: threshold, y: max });
        } else {
          curve.push({ x: max, y: threshold + makeup });
        }

        break;
      }
      case 'limiter':
        curve.push({ x: min, y: min + makeup - gain });
        curve.push({ x: threshold + gain, y: threshold + makeup });
        curve.push({ x: max, y: threshold + makeup });
        break;
      case 'gate':
        curve.push({ x: threshold, y: min });
        curve.push({ x: threshold, y: threshold + makeup });
        curve.push({ x: max, y: max + makeup });
        break;
      case 'expander':
        if (ratio !== 1) {
          curve.push({ x: min, y: min + makeup + range });

          const y = (ratio * range + (ratio - 1) * threshold) / (ratio - 1);
          curve.push({ x: y - range, y: y + makeup });
          curve.push({ x: threshold, y: threshold + makeup });
        } else curve.push({ x: min, y: min + makeup });
        curve.push({ x: max, y: max + makeup });
        break;
      default:
        warn('Unsupported type', type);
    }
    this.response.set('dots', curve);
  }

  set(key, val) {
    if (key === 'type') this.options._last_type = this.options.type;
    if (key === 'ratio') val = this.range_z.snap(val);
    return super.set(key, val);
  }
}

/**
 * @member {ChartHandle} Dynamics#handle - The handle to set threshold. Has class <code>.aux-handle</code>
 */
defineChildWidget(Dynamics, 'handle', {
  create: ChartHandle,
  show: true,
  map_options: {
    threshold: ['x', 'y'],
    ratio: 'z',
    handle_label: 'format_label',
    show_handle: 'visible',
  },
  default_options: {
    class: 'aux-handle',
    min_size: 24,
    max_size: 80,
  },
  static_events: {
    userset: dragHandle,
  },
});
/**
 * @member {Graph} Dynamics#steady - The graph drawing the zero line. Has class <code>.aux-steady</code>
 */
defineChildWidget(Dynamics, 'steady', {
  create: Graph,
  show: true,
  default_options: {
    class: 'aux-steady',
    mode: 'line',
  },
});
/**
 * @member {Graph} Dynamics#response - The graph drawing the dynamics response. Has class <code>.aux-response</code>
 */
defineChildWidget(Dynamics, 'response', {
  create: Graph,
  show: true,
  default_options: {
    class: 'aux-response',
  },
});

function dragRatio(key, y) {
  if (key !== 'y') return;

  const parent = this.parent;
  const thres = parent.get('threshold');
  const ratio_x = parent.get('ratio_x');
  const max = parent.get('max');

  const num = max - thres - (max - ratio_x);
  const R = num / (y - thres);

  const r_min = parent.range_z.get('min');
  const r_max = parent.range_z.get('max');

  parent.userset('ratio', Math.min(r_max, Math.max(r_min, R)));

  return false;
}

function setRatio() {
  if (!this.ratio) return;

  const thres = this.get('threshold');
  const ratio = this.get('ratio');
  const ratio_x = this.get('ratio_x');
  const max = this.get('max');

  const num = max - thres - (max - ratio_x);
  const Y = thres + num / ratio;
  this.ratio.set('y', Y);
}

function setRatioLimits() {
  if (!this.ratio) return;

  const thres = this.get('threshold');
  const ratio_x = this.get('ratio_x');
  const max = this.get('max');
  const r_min = this.range_z.get('min');
  const r_max = this.range_z.get('max');

  const num = max - thres - (max - ratio_x);

  this.ratio.set('y_max', thres + num / r_min);
  this.ratio.set('y_min', thres + num / r_max);
}

/**
 * Compressor is a pre-configured {@link Dynamics} widget.
 * @extends Dynamics
 * @class Compressor
 * @property {Boolean} [options.show_ratio=true] - Show the ratio handle.
 * @property {Boolean|Function} [options.ratio_label=false] - Function to format the label of the ratio. False for no label.
 * @property {Number} [options.ratio_x=12] - X position of the ratio handle.
 */
export class Compressor extends Dynamics {
  static get _options() {
    return {
      show_ratio: 'boolean',
      ratio_label: 'boolean|function',
      ratio_x: 'number',
    };
  }

  static get options() {
    return {
      type: 'compressor',
      show_ratio: true,
      ratio_label: false,
      ratio_x: 12,
    };
  }

  static get static_events() {
    return {
      set_ratio_x: function (v) {
        setRatio.call(this);
        setRatioLimits.call(this);
      },
      set_threshold: function (v) {
        setRatio.call(this);
        setRatioLimits.call(this);
      },
      set_ratio: function (v) {
        setRatio.call(this);
      },
      set_min: function (v) {
        setRatio.call(this);
        setRatioLimits.call(this);
      },
      set_max: function (v) {
        setRatio.call(this);
        setRatioLimits.call(this);
      },
    };
  }

  initialize(options) {
    super.initialize(options);
    this.set('ratio_label', this.options.ratio_label);
    this.set('show_ratio', this.options.show_ratio);
    this.set('ratio_x', this.options.ratio_x);
    setRatio.call(this);
  }
  draw(O, element) {
    /**
     * @member {HTMLDivElement} Compressor#element - The main DIV container.
     *   Has class <code>.aux-compressor</code>.
     */
    addClass(element, 'aux-compressor');
    super.draw(O, element);
  }
}

/**
 * @member {ChartHandle} Compressor#ratio - The handle to set ratio. Has class <code>.aux-ratio</code>
 */
defineChildWidget(Compressor, 'ratio', {
  create: ChartHandle,
  show: true,
  map_options: {
    ratio_label: 'format_label',
    show_ratio: 'visible',
    ratio_x: ['x_min', 'x_max'],
  },
  default_options: {
    class: 'aux-ratio',
    min_size: 24,
    max_size: 24,
    range_z: { min: 1, max: 1 },
  },
  static_events: {
    userset: dragRatio,
  },
});

/**
 * Expander is a pre-configured {@link Dynamics} widget.
 * @extends Dynamics
 * @class Expander
 */
export class Expander extends Dynamics {
  static get _options() {
    return Dynamics.getOptionTypes();
  }
  static get options() {
    return { type: 'expander' };
  }

  draw(O, element) {
    /**
     * @member {HTMLDivElement} Expander#element - The main DIV container.
     *   Has class <code>.aux-expander</code>.
     */
    addClass(element, 'aux-expander');
    super.draw(O, element);
  }
}
/**
 * Gate is a pre-configured {@link Dynamics} widget.
 * @extends Dynamics
 * @class Gate
 */
export class Gate extends Dynamics {
  static get _options() {
    return Dynamics.getOptionTypes();
  }
  static get options() {
    return { type: 'gate', range_z: { min: 1, max: 1 } };
  }

  draw(O, element) {
    /**
     * @member {HTMLDivElement} Gate#element - The main DIV container.
     *   Has class <code>.aux-gate</code>.
     */
    addClass(element, 'aux-gate');
    super.draw(O, element);
  }
}
/**
 * Limiter is a pre-configured {@link Dynamics} widget.
 * @extends Dynamics
 * @class Limiter
 */
export class Limiter extends Dynamics {
  static get _options() {
    return Dynamics.getOptionTypes();
  }
  static get options() {
    return { type: 'limiter', range_z: { min: 1, max: 1 } };
  }

  draw(O, element) {
    /**
     * @member {HTMLDivElement} Limiter#element - The main DIV container.
     *   Has class <code>.aux-limiter</code>.
     */
    addClass(element, 'aux-limiter');
    super.draw(O, element);
  }
}