widgets/crossover.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 { addClass } from '../utils/dom.js';
import { error } from '../utils/log.js';
import { sprintf } from '../utils/sprintf.js';
import { EqualizerGraph, Equalizer } from './equalizer.js';
import { EqBand } from './eqband.js';
import { Filter } from '../modules/filter.js';

/**
 * CrossoverBand is a {@link EqBand} with an additional filter.
 *
 * @param {Object} [options={ }] - An object containing additional options.
 *
 * @property {String|Function} [lower="lowpass3"] - The type of filter for the range below cutoff frequency. See {@link EqBand} for more information.
 * @property {String|Function} [upper="highpass3"] - The type of filter for the range above cutoff frequency. See {@link EqBand} for more information.
 * @property {Function} [format_label=function (t, x, y, z) { return sprintf("%.2f Hz", x); }] - The function formatting the handles label.
 *
 * @class CrossoverBand
 *
 * @extends EqBand
 */
export class CrossoverBand extends EqBand {
  static get _options() {
    return {
      lower: 'string|function',
      upper: 'string|function',
    };
  }

  static get options() {
    return {
      lower: 'lowpass3',
      upper: 'highpass3',
      format_label: function (t, x /*, y, z*/) {
        return sprintf('%.2f Hz', x);
      },
      mode: 'line-vertical', // undocumented, just a default differing from ChartHandle
      preferences: [
        'top-right',
        'right',
        'bottom-right',
        'top-left',
        'left',
        'bottom-left',
      ], // undocumented, just a default differing from ChartHandle
    };
  }

  static get static_events() {
    return {
      set_lower: function (val) {
        this.lower.set('type', val);
        this.set('mode', 'line-vertical');
      },
      set_upper: function (val) {
        this.upper.set('type', val);
        this.set('mode', 'line-vertical');
      },
    };
  }

  initialize(options) {
    super.initialize(options);
    /**
     * @member {Filter} CrossoverBand#upper - The filter providing the graphical calculations for the upper graph.
     */
    this.upper = new Filter();
    /**
     * @member {Filter} CrossoverBand#lower - The filter providing the graphical calculations for the lower graph.
     */
    this.lower = this.filter;
    this.filter.on('set_freq', (v) => {
      this.upper.set('freq', v);
    });
    this.filter.on('set_gain', (v) => {
      this.upper.set('gain', v);
    });
    this.filter.on('set_q', (v) => {
      this.upper.set('q', v);
    });
    this.upper.set('freq', this.lower.get('freq'));
    this.upper.set('gain', this.lower.get('gain'));
    this.upper.set('q', this.lower.get('q'));
    /**
     * @member {HTMLDivElement} CrossoverBand#element - The main SVG group.
     *   Has class <code>.aux-crossoverband</code>.
     */

    this.set('lower', this.options.lower);
    this.set('upper', this.options.upper);
  }

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

    super.draw(O, element);
  }
}

export class CrossoverGraph extends EqualizerGraph {
  static get _options() {
    return {
      index: 'number',
    };
  }

  static get options() {
    return {
      index: -1,
    };
  }

  getFilterFunctions() {
    const { bands, index } = this.options;

    // sort bands by frequency
    bands.sort(function (a, b) {
      return a.options.freq - b.options.freq;
    });

    const filters = [];

    if (index < bands.length) {
      filters.push(bands[index].lower.getFrequencyToGain());
    }

    if (index > 0) {
      filters.push(bands[index - 1].upper.getFrequencyToGain());
    }

    return filters;
  }
}

function sortBands() {
  this.getBands().sort(function (a, b) {
    return a.options.freq - b.options.freq;
  });
}

function unlimitBands() {
  this.getBands().forEach((band) => {
    band.set('x_min', false);
    band.set('x_max', false);
  });
}

/*
 * Limit the movement of bands based on the frequency in a given band.
 */
function limitBand(bands, i, distance) {
  const band = bands[i];
  const prev = i ? bands[i - 1] : null;
  const next = i + 1 < bands.length ? bands[i + 1] : null;

  if (prev) {
    const x_distance = distance * band.get('freq');
    const x_max = band.get('freq') - x_distance;
    prev.set('x_max', x_max);
    band.set('x_min', prev.get('freq') + x_distance);
  } else {
    band.set('x_min', false);
  }

  if (!next) {
    band.set('x_max', false);
  }
}

function limitBands() {
  if (this.options.leap) return;
  sortBands.call(this);

  const distance = Math.abs(this.get('distance'));
  const bands = this.getBands();

  for (let i = 0; i < bands.length; i++) limitBand(bands, i, distance);
}

function setFreq(band) {
  if (this.options.leap) return;
  const bands = this.getBands();
  const i = bands.indexOf(band);
  if (i < 0) {
    error('Band no member of crossover');
    return;
  }
  const distance = Math.abs(this.get('distance'));
  limitBand(bands, i, distance);
}

/**
 * Crossover is a {@link Equalizer} displaying the response
 * of a multi-band crossover filter. Crossover  uses {@link CrossoverBand}
 * as response handles.
 *
 * @class Crossover
 *
 * @extends Equalizer
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {Boolean} [options.leap=true] - Define if bands are allowed to leap over each other.
 * @property {Number} [options.distance=0] - Set a minimal distance between bands. This has no effect
 *   is `leap=true`. The value is interpreted as a factor of the frequency
 *   of the next band, e.g. if distance is `0.2` and a band is at `1kHz`, then a second lower
 *   band cannot be moved beyond `800Hz`.
 */
export class Crossover extends Equalizer {
  static get _options() {
    return Object.assign({}, Equalizer.getOptionTypes(), {
      leap: 'boolean',
      distance: 'number',
    });
  }

  static get options() {
    return {
      range_y: { min: -60, max: 12, scale: 'linear' },
      leap: true,
      distance: 0,
    };
  }

  static get static_events() {
    return {
      set_leap: function (v) {
        (v ? unlimitBands : limitBands).call(this);
      },
      set_distance: limitBands,
      initialized: function () {
        this.set('leap', this.options.leap);
        this.set('distance', this.options.distance);
      },
    };
  }

  initialize(options) {
    super.initialize(options);
    /**
     * @member {HTMLDivElement} Equalizer#element - The main DIV container.
     *   Has class <code>.aux-crossover</code>.
     */

    const self = this;
    this.set_freq_cb = function () {
      setFreq.call(self, this);
    };
    this.removeChild(this.baseline);
    this.addGraph(new CrossoverGraph());
  }

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

    super.draw(O, element);
  }

  getCrossoverBands() {
    return this.getChildren().filter((child) => child instanceof CrossoverBand);
  }

  getCrossoverGraphs() {
    return this.getChildren().filter(
      (child) => child instanceof CrossoverGraph
    );
  }

  addChild(child) {
    super.addChild(child);
    if (child instanceof CrossoverBand) {
      // add this band to all crossover graphs
      const bands = this.getCrossoverBands();
      this.getCrossoverGraphs().forEach((graph) => graph.set('bands', bands));
      child.on('set_freq', this.set_freq_cb);
      limitBands.call(this);
      // add an additional crossover graph
      this.addGraph(new CrossoverGraph());
    } else if (child instanceof CrossoverGraph) {
      child.set('bands', this.getCrossoverBands());
      child.set('index', this.getCrossoverGraphs().indexOf(child));
    }
  }

  removeChild(child) {
    if (this.isDestructed()) return;
    super.removeChild(child);
    if (child instanceof CrossoverBand) {
      const graphs = this.getCrossoverGraphs();
      const bands = this.getCrossoverBands();

      // Adjust the number of graphs to match.
      if (graphs.length > bands.length + 1) {
        const count = graphs.length - bands.length - 1;

        for (let i = graphs.length - count; i < graphs.length; i++) {
          this.removeChild(graphs[i]);
        }
      }

      child.off('set_freq', this.set_freq_cb);
      limitBands.call(this);
      this.getCrossoverGraphs().forEach((graph) => graph.set('bands', bands));
    } else if (child instanceof CrossoverGraph) {
      child.set('bands', []);
      child.set('index', this.getCrossoverGraphs().length - 1);
      this.getCrossoverGraphs().forEach((graph, index) =>
        graph.set('index', index)
      );
    }
  }

  addBand(options, type) {
    let band;

    if (options instanceof CrossoverBand) {
      band = options;
    } else {
      type = type || CrossoverBand;
      band = new type(options);
    }
    this.addChild(band);
    return band;
  }

  empty() {
    this.getCrossoverBands().forEach((band) => this.removeChild(band));
    super.empty();
    this.addGraph(new CrossoverGraph());
  }
}