implements/ranged.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
 */

/* jshint -W018 */

import { defineClass } from './../widget_helpers.js';
import {
  dBToScale,
  scaleToDB,
  freqToScale,
  scaleToFreq,
} from '../utils/audiomath.js';
import { error, warn } from './../utils/log.js';

function LinearSnapModule(stdlib, foreign) {
  var min = +foreign.min;
  var max = +foreign.max;
  var step = +foreign.step;
  var base = +foreign.base;
  const clip = !!foreign.clip;

  var floor = stdlib.Math.floor;
  var ceil = stdlib.Math.ceil;

  function lowSnap(v, direction) {
    v = +v;
    direction = +direction;
    var n = 0.0;
    var t = 0.0;

    if (clip) {
      if (!(v > min)) {
        v = min;
        direction = 1.0;
      } else if (!(v < max)) {
        v = max;
        direction = +1.0;
      }
    }

    t = (v - base) / step;

    if (direction > 0.0) n = ceil(t);
    else if (direction < 0.0) n = floor(t);
    else {
      if (t - floor(t) < 0.5) {
        n = floor(t);
      } else {
        n = ceil(t);
      }
    }

    return base + step * n;
  }

  /**
   * Returns the nearest value on the grid which is bigger than <code>value</code>.
   *
   * @method Ranged#snapUp
   *
   * @param {number} value - The value to snap.
   *
   * @returns {number} The snapped value.
   */
  function snapUp(v) {
    v = +v;
    return +lowSnap(v, 1.0);
  }

  /**
   * Returns the nearest value on the grid which is smaller than <code>value</code>.
   *
   * @method Ranged#snapDown
   *
   * @param {number} value - The value to snap.
   *
   * @returns {number} The snapped value.
   */
  function snapDown(v) {
    v = +v;
    return +lowSnap(v, -1.0);
  }

  /**
   * Returns the nearest value on the grid. Its rounding behavior is similar to that
   * of <code>Math.round</code>.
   *
   * @method Ranged#snap
   *
   * @param {number} value - The value to snap.
   *
   * @returns {number} The snapped value.
   */
  function snap(v) {
    v = +v;
    return +lowSnap(v, 0.0);
  }

  return {
    snapUp: snapUp,
    snapDown: snapDown,
    snap: snap,
  };
}

function ArraySnapModule(stdlib, foreign, heap) {
  var values = new stdlib.Float64Array(heap);
  var len = (heap.byteLength >> 3) | 0;
  var min = +(foreign.min !== void 0 ? foreign.min : values[0]);
  var max = +(foreign.max !== void 0 ? foreign.max : values[len - 1]);
  const clip = !!foreign.clip;

  function lowSnap(v, direction) {
    v = +v;
    direction = +direction;
    var a = 0;
    var mid = 0;
    var b = 0;
    var t = 0.0;

    b = len - 1;

    if (clip) {
      if (!(v > min)) v = min;
      if (!(v < max)) v = max;
    }

    if (!(v < +values[(b << 3) >> 3])) return +values[(b << 3) >> 3];
    if (!(v > +values[0])) return +values[0];

    do {
      mid = (a + b) >>> 1;
      t = +values[(mid << 3) >> 3];
      if (v > t) a = mid;
      else if (v < t) b = mid;
      else return t;
    } while (((b - a) | 0) > 1);

    if (direction > 0.0) return +values[(b << 3) >> 3];
    else if (direction < 0.0) return +values[(a << 3) >> 3];

    if (values[(b << 3) >> 3] - v <= v - values[(a << 3) >> 3])
      return +values[(b << 3) >> 3];
    return +values[(a << 3) >> 3];
  }

  function snapUp(v) {
    v = +v;
    return +lowSnap(v, 1.0);
  }

  function snapDown(v) {
    v = +v;
    return +lowSnap(v, -1.0);
  }

  function snap(v) {
    v = +v;
    return +lowSnap(v, 0.0);
  }

  return {
    snapUp: snapUp,
    snapDown: snapDown,
    snap: snap,
  };
}
function NullSnapModule(stdlib, foreign) {
  var min = +foreign.min;
  var max = +foreign.max;
  const clip = !!foreign.clip;

  function snap(v) {
    v = +v;
    if (clip) {
      if (!(v < max)) v = max;
      if (!(v > min)) v = min;
    }
    return v;
  }

  return {
    snap: snap,
    snapUp: snap,
    snapDown: snap,
  };
}
function numSort(arr) {
  arr = arr.slice(0);
  arr.sort(function (a, b) {
    return a - b;
  });
  return arr;
}
function updateSnap() {
  var O = this.options;
  // Notify that the ranged options have been modified
  if (Array.isArray(O.snap)) {
    Object.assign(
      this,
      ArraySnapModule(window, O, new Float64Array(numSort(O.snap)).buffer)
    );
  } else if (typeof O.snap === 'number' && O.snap > 0.0) {
    Object.assign(
      this,
      LinearSnapModule(window, {
        min: Math.min(O.min, O.max),
        max: Math.max(O.min, O.max),
        step: O.snap,
        base: O.base || 0,
        clip: O.clip,
      })
    );
  } else if (O.min < Infinity && O.max > -Infinity) {
    Object.assign(
      this,
      NullSnapModule(window, {
        min: Math.min(O.min, O.max),
        max: Math.max(O.min, O.max),
        clip: O.clip,
      })
    );
  } else {
    Object.assign(this, {
      snap: function (v) {
        return +v;
      },
      snapUp: function (v) {
        return +v;
      },
      snapDown: function (v) {
        return +v;
      },
    });
  }
}
function TRAFO_PIECEWISE(stdlib, foreign, heap) {
  var reverse = foreign.reverse | 0;
  var l = heap.byteLength >> 4;
  var X = new Float64Array(heap, 0, l);
  var Y = new Float64Array(heap, l * 8, l);
  var basis = +foreign.basis;

  if (!(l >= 2))
    throw new TypeError(
      'piece-wise linear transformations need at least 2 entries.'
    );

  function valueToBased(coef, size) {
    var a = 0,
      b = (l - 1) | 0,
      mid = 0,
      t = 0.0;

    coef = +coef;
    size = +size;

    if (!(coef > +Y[0])) return +X[0] * size;
    if (!(coef < +Y[(b << 3) >> 3])) return +X[(b << 3) >> 3] * size;

    do {
      mid = (a + b) >>> 1;
      t = +Y[(mid << 3) >> 3];
      if (coef > t) a = mid;
      else if (coef < t) b = mid;
      else return +X[(mid << 3) >> 3] * size;
    } while (((b - a) | 0) > 1);

    /* value lies between a and b */

    t =
      (+X[(b << 3) >> 3] - +X[(a << 3) >> 3]) /
      (+Y[(b << 3) >> 3] - +Y[(a << 3) >> 3]);

    t = +X[(a << 3) >> 3] + (coef - +Y[(a << 3) >> 3]) * t;

    t *= size;

    if (reverse) t = size - t;

    return t;
  }
  function basedToValue(coef, size) {
    var a = 0,
      b = (l - 1) | 0,
      mid = 0,
      t = 0.0;

    coef = +coef;
    size = +size;
    if (reverse) coef = size - coef;
    coef /= size;

    if (!(coef > 0)) return Y[0];
    if (!(coef < 1)) return Y[(b << 3) >> 3];

    do {
      mid = (a + b) >>> 1;
      t = +X[(mid << 3) >> 3];
      if (coef > t) a = mid;
      else if (coef < t) b = mid;
      else return +Y[(mid << 3) >> 3];
    } while (((b - a) | 0) > 1);

    /* value lies between a and b */

    t =
      (+Y[(b << 3) >> 3] - +Y[(a << 3) >> 3]) /
      (+X[(b << 3) >> 3] - +X[(a << 3) >> 3]);

    return +Y[(a << 3) >> 3] + (coef - +X[(a << 3) >> 3]) * t;
  }
  function valueToPixel(n) {
    return valueToBased(n, basis || 1);
  }
  function pixelToValue(n) {
    return basedToValue(n, basis || 1);
  }
  function valueToCoef(n) {
    return valueToBased(n, 1);
  }
  function coefToValue(n) {
    return basedToValue(n, 1);
  }
  return {
    valueToBased: valueToBased,
    basedToValue: basedToValue,
    valueToPixel: valueToPixel,
    pixelToValue: pixelToValue,
    valueToCoef: valueToCoef,
    coefToValue: coefToValue,
  };
}
function TRAFO_FUNCTION(stdlib, foreign) {
  var reverse = foreign.reverse | 0;
  var scale = foreign.scale;
  var basis = +foreign.basis;
  function valueToBased(value, size) {
    value = +value;
    size = +size;
    value = scale(value, foreign, false) * size;
    if (reverse) value = size - value;
    return value;
  }
  function basedToValue(coef, size) {
    coef = +coef;
    size = +size;
    if (reverse) coef = size - coef;
    coef = scale(coef / size, foreign, true);
    return coef;
  }
  function valueToPixel(n) {
    return valueToBased(n, basis || 1);
  }
  function pixelToValue(n) {
    return basedToValue(n, basis || 1);
  }
  function valueToCoef(n) {
    return valueToBased(n, 1);
  }
  function coefToValue(n) {
    return basedToValue(n, 1);
  }
  return {
    valueToBased: valueToBased,
    basedToValue: basedToValue,
    valueToPixel: valueToPixel,
    pixelToValue: pixelToValue,
    valueToCoef: valueToCoef,
    coefToValue: coefToValue,
  };
}
function TRAFO_LINEAR(stdlib, foreign) {
  var reverse = foreign.reverse | 0;
  var min = +foreign.min;
  var max = +foreign.max;
  var basis = +foreign.basis;
  function valueToBased(value, size) {
    value = +value;
    size = +size;
    value = ((value - min) / (max - min)) * size;
    if (reverse) value = size - value;
    return value;
  }
  function basedToValue(coef, size) {
    coef = +coef;
    size = +size;
    if (reverse) coef = size - coef;
    coef = (coef / size) * (max - min) + min;
    return coef;
  }
  // just a wrapper for having understandable code and backward
  // compatibility
  function valueToPixel(n) {
    n = +n;
    if (basis == 0.0) basis = 1.0;
    return +valueToBased(n, basis);
  }
  // just a wrapper for having understandable code and backward
  // compatibility
  function pixelToValue(n) {
    n = +n;
    if (basis == 0.0) basis = 1.0;
    return +basedToValue(n, basis);
  }
  // calculates a coefficient for the value
  function valueToCoef(n) {
    n = +n;
    return +valueToBased(n, 1.0);
  }
  // calculates a value from a coefficient
  function coefToValue(n) {
    n = +n;
    return +basedToValue(n, 1.0);
  }
  return {
    /**
     * Transforms a value from the coordinate system to the interval <code>0</code>...<code>basis</code>.
     *
     * @method Ranged#valueToBased
     *
     * @param {number} value
     * @param {number} [basis=1]
     *
     * @returns {number}
     */
    valueToBased: valueToBased,
    /**
     * Transforms a value from the interval <code>0</code>...<code>basis</code> to the coordinate system.
     *
     * @method Ranged#basedToValue
     *
     * @param {number} value
     * @param {number} [basis=1]
     *
     * @returns {number}
     */
    basedToValue: basedToValue,
    /**
     * This is an alias for {@link Ranged#valueToPixel}.
     *
     * @method Ranged#valueToPixel
     *
     * @param {number} value
     *
     * @returns {number}
     */
    valueToPixel: valueToPixel,
    /**
     * This is an alias for {@link Ranged#pixelToValue}.
     *
     * @method Ranged#pixelToValue
     *
     * @param {number} value
     *
     * @returns {number}
     */
    pixelToValue: pixelToValue,
    /**
     * Calls {@link basedToValue} with <code>basis = 1</code>.
     *
     * @method Ranged#valueToCoef
     *
     * @param {number} value
     *
     * @returns {number}
     */
    valueToCoef: valueToCoef,
    /**
     * Calls {@link basedToValue} with <code>basis = 1</code>.
     *
     * @method Ranged#coefToValue
     *
     * @param {number} value
     *
     * @returns {number}
     */
    coefToValue: coefToValue,
  };
}
function TRAFO_LOG(stdlib, foreign) {
  var reverse = foreign.reverse | 0;
  var min = +foreign.min;
  var max = +foreign.max;
  var log_factor = +foreign.log_factor;
  var trafo_reverse = foreign.trafo_reverse | 0;
  var basis = +foreign.basis;
  function valueToBased(value, size) {
    value = +value;
    size = +size;
    value = +dBToScale(value, min, max, size, trafo_reverse, log_factor);
    if (reverse) value = size - value;
    return value;
  }
  function basedToValue(coef, size) {
    coef = +coef;
    size = +size;
    if (reverse) coef = size - coef;
    coef = +scaleToDB(coef, min, max, size, trafo_reverse, log_factor);
    return coef;
  }
  function valueToPixel(n) {
    return valueToBased(n, basis || 1);
  }
  function pixelToValue(n) {
    return basedToValue(n, basis || 1);
  }
  function valueToCoef(n) {
    return valueToBased(n, 1);
  }
  function coefToValue(n) {
    return basedToValue(n, 1);
  }
  return {
    valueToBased: valueToBased,
    basedToValue: basedToValue,
    valueToPixel: valueToPixel,
    pixelToValue: pixelToValue,
    valueToCoef: valueToCoef,
    coefToValue: coefToValue,
  };
}
function TRAFO_FREQ(stdlib, foreign) {
  var reverse = foreign.reverse | 0;
  var min = +foreign.min;
  var max = +foreign.max;
  var trafo_reverse = foreign.trafo_reverse | 0;
  var basis = +foreign.basis;
  function valueToBased(value, size) {
    value = +value;
    size = +size;
    value = +freqToScale(value, min, max, size, trafo_reverse);
    if (reverse) value = size - value;
    return value;
  }
  function basedToValue(coef, size) {
    coef = +coef;
    size = +size;
    if (reverse) coef = size - coef;
    coef = +scaleToFreq(coef, min, max, size, trafo_reverse);
    return coef;
  }
  function valueToPixel(n) {
    return valueToBased(n, basis || 1);
  }
  function pixelToValue(n) {
    return basedToValue(n, basis || 1);
  }
  function valueToCoef(n) {
    return valueToBased(n, 1);
  }
  function coefToValue(n) {
    return basedToValue(n, 1);
  }
  return {
    valueToBased: valueToBased,
    basedToValue: basedToValue,
    valueToPixel: valueToPixel,
    pixelToValue: pixelToValue,
    valueToCoef: valueToCoef,
    coefToValue: coefToValue,
  };
}
function updateTransformation() {
  var O = this.options;
  var scale = O.scale;

  var module;

  if (typeof scale === 'function') {
    module = TRAFO_FUNCTION(window, O);
  } else if (Array.isArray(scale)) {
    var i = 0;
    if (scale.length % 2) {
      error('Malformed piecewise-linear scale.');
    }

    for (i = 0; i < scale.length / 2 - 1; i++) {
      if (!(scale[i] >= 0 && scale[i] <= 1))
        error('piecewise-linear x value not in [0,1].');
      if (!(scale[i] < scale[i + 1]))
        error('piecewise-linear array not sorted.');
    }
    for (i = scale.length / 2; i < scale.length - 1; i++) {
      if (!(scale[i] < scale[i + 1]))
        error('piecewise-linear array not sorted.');
    }

    module = TRAFO_PIECEWISE(window, O, new Float64Array(scale).buffer);
  } else
    switch (scale) {
      case 'linear':
        module = TRAFO_LINEAR(window, O);
        break;
      case 'decibel':
        O.trafo_reverse = 1;
        module = TRAFO_LOG(window, O);
        break;
      case 'log2':
        O.trafo_reverse = 0;
        module = TRAFO_LOG(window, O);
        break;
      case 'frequency':
        O.trafo_reverse = 0;
        module = TRAFO_FREQ(window, O);
        break;
      case 'frequency-reverse':
        O.trafo_reverse = 1;
        module = TRAFO_FREQ(window, O);
        break;
      default:
        warn('Unsupported scale', scale);
    }

  Object.assign(this, module);
}
function setCallback(key) {
  switch (key) {
    case 'min':
    case 'max':
    case 'snap':
    case 'clip':
      updateSnap.call(this);
    /* fall through */
    case 'log_factor':
    case 'scale':
    case 'reverse':
    case 'basis':
      updateTransformation.call(this);
      this.emit('rangedchanged');
      break;
  }
}
/**
 * @callback Ranged~scale_cb
 *
 * This is the interface for functions implementing custom coordinate
 * transformations. The transformation is required to map the interval
 * `[min, max]` onto `[0,1]`.
 *
 * @param {number} value - The coordinate value to be transformed.
 * @param {Object} [options={ }] - The options of the
 *      corresponding {@link Ranged} object.
 * @param {boolean} [inverse=false] - If true, the function should return the
 *      inverse transform.
 *
 * @returns {number} The transformed value.
 */
export const Ranged = defineClass({
  /**
   * Ranged combines functionality for two distinct purposes.
   * Firstly, Ranged can be used to snap values to a virtual grid.
   * This grid is defined by the options <code>snap</code>,
   * <code>step</code>, <code>min</code>, <code>max</code> and <code>base</code>.
   * The second feature of anged is that it allows transforming values between coordinate systems.
   * This can be used to transform values from and to linear scales in which they are displayed on the
   * screen. It is used inside of AUX to translate values (e.g. in Hz or dB) to pixel positions or
   * percentages, for instance in widgets such as {@link Scale}, {@link Meter} or
   * {@link Graph}.
   *
   * Ranged features several types of coordinate systems which are often used in audio applications.
   * They can be configured using the <code>options.scale</code> option, possible values are:
   * <ul>
   *  <li><code>linear</code> for linear coordinates,
   *  <li><code>decibel</code> for linear coordinates,
   *  <li><code>log2</code> for linear coordinates,
   *  <li><code>frequency</code> for linear coordinates or
   *  <li><code>frequency-reverse"</code> for linear coordinates.
   * </ul>
   * If <code>options.scale</code> is a function, it is used as the coordinate transformation.
   * Its signature is {@link Ranged~scale_cb}. This allows the definition of custom
   * coordinate transformations, which go beyond the standard types.
   *
   * @param {Object} [options={ }] - An object containing initial options.
   *
   * @property {String|Array<Number>|Function} [options.scale="linear"] -
   *  The type of the scale. Either one of <code>linear</code>, <code>decibel</code>, <code>log2</code>,
   *  <code>frequency</code> or <code>frequency-reverse</code>; or an array containing a
   *  piece-wise linear scale;
   *  or a callback function of type {@link Ranged~scale_cb}.
   * @property {Boolean} [options.reverse=false] - Reverse the scale of the range.
   * @property {Number} [options.basis=1] - The size of the linear scale. Set to pixel width or height
   * if used for drawing purposes or to 100 for percentages.
   * @property {Boolean} [options.clip=true] - If true, snap() will clip values
   *  into the interval between min and max.
   * @property {Number} [options.min=0] - Minimum value of the range.
   * @property {Number} [options.max=1] - Maximum value of the range.
   * @property {Number} [options.log_factor=1] - Used to overexpand logarithmic curves. 1 keeps the
   *  natural curve while values above 1 will overbend.
   * @property {Number|Array.<number>} [options.snap=0] -
   *  Defines a virtual grid.
   *  If <code>options.snap</code> is a positive number, it is interpreted as the distance of
   *  grid points.
   *  Then, inside of the interval <code>options.min</code> ... <code>options.max</code> the grid
   *  points are <code> options.base + n * options.snap </code> where <code>n</code> is any
   *  integer. Any values outside of that interval are rounded to the biggest or smallest grid
   *  point, respectively.
   *  In order to define grids with non-uniform spacing, set <code>options.snap</code> to an Array
   *  of grid points.
   * @property {Number} [options.base=0] - Base point. Used e.g. to mark 0dB on a fader from -96dB to 12dB.
   * @property {Number} [options.step=0] - Step size. Used for instance by {@link ScrollValue}
   *  as the step size.
   * @property {Number} [options.shift_up=4] - Multiplier for increased stepping speed, e.g. used by
   *  {@link ScrollValue} when simultaneously pressing 'shift'.
   * @property {Number} [options.shift_down=0.25] - Multiplier for decreased stepping speed, e.g. used by
   *  {@link ScrollValue} when simultaneously pressing 'shift' and 'ctrl'.
   *
   * @mixin Ranged
   */

  options: {
    scale: 'linear',
    reverse: false,
    basis: 1,
    clip: true,
    min: 0,
    max: 1,
    base: 0,
    step: 0,
    shift_up: 4,
    shift_down: 0.25,
    snap: 0,
    round: true /* default for Range, no dedicated option */,
    log_factor: 1,
    trafo_reverse: false /* used internally, no documentation */,
  },
  _options: {
    scale: 'string|array|function',
    reverse: 'boolean',
    basis: 'number',
    clip: 'boolean',
    min: 'number',
    max: 'number',
    base: 'number',
    step: 'number',
    shift_up: 'number',
    shift_down: 'number',
    snap: 'number|array',
    round: 'boolean',
    log_factor: 'number',
    trafo_reverse: 'boolean',
  },
  static_events: {
    set: setCallback,
    initialized: function () {
      var O = this.options;
      if (!(O.min <= O.max))
        warn(
          'Ranged needs min <= max. min: ',
          O.min,
          ', max:',
          O.max,
          ', options:',
          O
        );
      updateSnap.call(this);
      updateTransformation.call(this);
    },
  },
});