Skip to content

Source: src/settings/AudioControls.js

import DomSmith from '../../lib/dom/DomSmith.js';

/**
 * The AudioControls component provides an equalizer for adjusting multiple frequency bands of the audio output.
 * It integrates with the player’s internal audio processing chain and provides real-time feedback for all adjustments.
 * This component is part of the player’s extended audio feature set and attaches its UI to the 'controls' popup component.
 * @exports module:src/settings/AudioControls
 * @requires lib/dom/DomSmith
 * @author   Frank Kudermann - alphanull
 * @version  1.1.0
 * @license  MIT
 */
export default class AudioControls {

    /**
     * Holds the instance configuration for this component.
     * @type     {Object}
     * @property {number[]} [bands=[1, 1, 1, 1, 1]]  Default frequency band values. Each band controls a specific frequency range from low to high.
     */
    #config = {
        bands: Array(5).fill(1)
    };

    /**
     * Reference to the main player instance.
     * @type {module:src/core/Player}
     */
    #player;

    /**
     * Secret key only known to the player instance and initialized components.
     * Used to be able to restrict access to API methods in conjunction with secure mode.
     * @type {symbol}
     */
    #apiKey;

    /**
     * Holds tokens of subscriptions to player events, for later unsubscribe.
     * @type {number[]}
     */
    #subscriptions = [];

    /**
     * Reference to the DomSmith instance. Displays UI elements for the equalizer.
     * @type {module:lib/dom/DomSmith}
     */
    #dom;

    /**
     * Use the shared AudioContext from the player's Audio Manager.
     * @type {AudioContext}
     */
    #audioCtx;

    /**
     * Logarithmically spaced crossover frequencies.
     * @type {number[]}
     */
    #edges = [];

    /**
     * Stores all frequency bands.
     * @type {Array<{gainNode: GainNode, filters: BiquadFilterNode[]}>}
     */
    #eqBands = [];

    /**
     * Master output gain node.
     * @type {GainNode}
     */
    #output;

    /**
     * Gain node for the bypass path.
     * @type {GainNode}
     */
    #bypassGain;

    /**
     * Input gain node for the equalizer path.
     * @type {GainNode}
     */
    #eqInput;

    /**
     * Output gain node for the equalizer path.
     * @type {GainNode}
     */
    #eqOutput;

    /**
     * Main input gain node that connects to both bypass and EQ paths.
     * @type {GainNode}
     */
    #input;

    /**
     * Stores shared filter instances to optimize processing.
     * @type {Array<{low: BiquadFilterNode[], high: BiquadFilterNode[]}>|null}
     */
    #sharedFilters = null;

    /**
     * Creates an instance of the AudioControls Component.
     * @param {module:src/core/Player} player            Reference to the media player instance.
     * @param {module:src/ui/Popup}    parent            Reference to the parent instance (In this case the settings popup).
     * @param {Object}                 [options]         Additional options.
     * @param {symbol}                 [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, { apiKey }) {

        this.#config = player.initConfig('audioControls', this.#config);

        const audioContext = player?.audio?.getContext(apiKey);

        if (!this.#config || !audioContext) return [false];

        if (this.#config.bands.length < 2) this.#config.bands = Array(2).fill(1);
        if (this.#config.bands.length > 16) this.#config.bands = Array(16).fill(1);

        this.#player = player;
        this.#audioCtx = audioContext;
        this.#apiKey = apiKey;

        const standardFreqs = [20, 25, 31, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000];

        /**
         * Finds the nearest standard center frequency for a given value.
         * This is used to display user-friendly frequency labels (e.g. 40, 160, 630, 2.5K, 10K) instead of raw calculated values.
         * @param   {number} f  The frequency value to match (in Hz).
         * @returns {number}    The closest standard frequency from the standardFreqs array.
         */
        function findNearestStandardFreq(f) {
            return standardFreqs.reduce((prev, curr) => Math.abs(curr - f) < Math.abs(prev - f) ? curr : prev);
        }

        /**
         * Formats a frequency value for display as a label.
         * Values >= 1000 Hz are shown as "xK" (e.g. 2.5K), others as integer Hz (e.g. 160).
         * @param   {number} f  The frequency value in Hz.
         * @returns {string}    The formatted frequency label.
         */
        function formatFreq(f) {
            return f >= 1000 ? `${(f / 1000).toFixed(f % 1000 === 0 ? 0 : 1)}K` : f;
        }

        const minFreq = 20,
              maxFreq = 20000,
              bands = this.#config.bands.length,
              labels = Array.from({ length: bands }, (_, i) => formatFreq(findNearestStandardFreq(
                  Math.sqrt(
                      minFreq * (maxFreq / minFreq) ** (i / bands)
                      * (minFreq * (maxFreq / minFreq) ** ((i + 1) / bands))
                  )
              )));

        this.#dom = new DomSmith({
            _ref: 'wrapper',
            className: 'vip-audio-controls',
            _nodes: [{
                _tag: 'h3',
                _nodes: [
                    this.#player.locale.t('misc.audio'),
                    {
                        _tag: 'button',
                        _ref: 'reset',
                        className: 'icon reset',
                        ariaLabel: this.#player.locale.t('commands.reset'),
                        click: this.#reset,
                        $tooltip: { player, text: this.#player.locale.t('commands.reset') }
                    }
                ]
            }, {
                className: 'vip-audio-controls-wrapper',
                _nodes: this.#config.bands.map((value, index) => ({
                    className: 'vip-audio-control-wrapper',
                    _nodes: [
                        {
                            _tag: 'input',
                            _ref: `band-${index}`,
                            'data-ref': index,
                            orient: 'vertical',
                            min: 0,
                            max: 2,
                            step: 0.01,
                            value,
                            defaultValue: value,
                            ariaLabel: `${this.#player.locale.t('audioControls.freqBand')} ${index + 1}`,
                            className: 'vip-audio-control has-center-line',
                            type: 'range',
                            change: this.#applySettings,
                            input: this.#applySettings,
                            pointermove: event => event.preventDefault()
                        },
                        {
                            _tag: 'span',
                            _nodes: [labels[index].toString()]
                        }
                    ]
                }))
            }]
        }, parent.getElement('center'));

        this.#createSubgraph(); // Setup Audio Chain
        this.#player.audio.addNode(this.#input, this.#output, 10, this.#apiKey); // Register to the player's audio manager
        this.#applySettings(); // Initial Render
        this.#disable();

        this.#subscriptions = [
            this.#player.subscribe('player/engine/set', ({ from, to, options }) => {
                if (!from.capabilities.filterAudio && to.capabilities.filterAudio) this.#enable(options);
                if (from.capabilities.filterAudio && !to.capabilities.filterAudio) this.#disable(options);
            })
        ];
    }

    /**
     * Creates the audio processing subgraph for the equalizer.
     * Sets up gain nodes for master output, bypass, and EQ paths.
     */
    #createSubgraph() {

        this.#output = this.#audioCtx.createGain();

        // Normalize overall gain so total power across N bands remains constant
        this.#output.gain.value = 1 / Math.sqrt(this.#config.bands.length);

        this.#bypassGain = this.#audioCtx.createGain();
        this.#bypassGain.gain.value = 1.0;
        this.#bypassGain.connect(this.#output);

        this.#eqInput = this.#audioCtx.createGain();

        this.#eqOutput = this.#audioCtx.createGain();
        this.#eqOutput.connect(this.#output);

        this.#input = this.#audioCtx.createGain();
        this.#input.connect(this.#bypassGain);
        this.#input.connect(this.#eqInput);

        // Now build linkwitz-riley inside eqInput -> eqOutput
        this.#buildLinkwitzRileyChain();

    }

    /**
     * Builds a multi-band Linkwitz-Riley crossover filter chain.
     * This method divides the frequency spectrum into multiple bands
     * and applies separate gain nodes to each band.
     */
    #buildLinkwitzRileyChain() {

        const numberOfBands = this.#config.bands.length; // Number of equalizer bands.

        if (numberOfBands < 1) return;

        const numberOfEdges = numberOfBands - 1, // Number of crossover edges (one less than the number of bands).
              minFreq = 20, // Minimum frequency for the crossover.
              maxFreq = 20000; // Maximum frequency for the crossover.

        this.#edges = Array.from({ length: numberOfEdges }, (_, i) => {
            const ratio = (i + 1) / numberOfBands;
            return minFreq * (maxFreq / minFreq) ** ratio;
        }).sort((a, b) => a - b);

        // Optimize by reusing shared filters if available
        if (!this.#sharedFilters) {
            this.#sharedFilters = this.#edges.map(freq => this.#createLinkwitzRiley(freq));
        }

        const splitBand = (inputNode, idx) => {

            if (idx >= this.#edges.length) {
                // Last remaining band (everything above the last crossover frequency)
                const leftoverGain = this.#audioCtx.createGain();
                leftoverGain.gain.value = 1.0;
                inputNode.connect(leftoverGain);
                leftoverGain.connect(this.#eqOutput);

                this.#eqBands.push({
                    gainNode: leftoverGain,
                    name: `Band ${idx} (leftover > ${this.#edges[idx - 1] || minFreq} Hz)`,
                    filters: []
                });
                return;
            }

            const lr = this.#sharedFilters[idx]; // Use precomputed Linkwitz-Riley filter

            // Connect input to both low-pass and high-pass branches
            inputNode.connect(lr.low[0]);
            inputNode.connect(lr.high[0]);

            const lowOut = lr.low[lr.low.length - 1],
                  highOut = lr.high[lr.high.length - 1],
                  lowGain = this.#audioCtx.createGain(); // Create a gain node for the low-frequency band

            lowGain.gain.value = 1.0;
            lowOut.connect(lowGain);
            lowGain.connect(this.#eqOutput);

            this.#eqBands.push({
                gainNode: lowGain,
                name: `Band ${idx} (< ${this.#edges[idx]} Hz)`,
                filters: lr.low
            });

            splitBand(highOut, idx + 1); // Recursively process the high-frequency band
        };

        splitBand(this.#eqInput, 0);

    }

    /**
     * Creates a 4th-order Linkwitz-Riley crossover at a given frequency.
     * This method generates two cascaded biquad filters for both
     * low-pass and high-pass filtering, resulting in a smooth crossover.
     * @param   {number} freq  The crossover frequency in Hz.
     * @returns {Object}       An object containing low-pass and high-pass filter arrays.
     */
    #createLinkwitzRiley = freq => {

        const low1 = this.#audioCtx.createBiquadFilter(); // First-order low-pass filter.
        low1.type = 'lowpass';
        low1.frequency.value = freq;
        low1.Q.value = Math.SQRT1_2;

        const low2 = this.#audioCtx.createBiquadFilter(); // Second-order low-pass filter.
        low2.type = 'lowpass';
        low2.frequency.value = freq;
        low2.Q.value = Math.SQRT1_2;

        // Chain the low-pass filters
        low1.connect(low2);

        const high1 = this.#audioCtx.createBiquadFilter(); // First-order high-pass filter.
        high1.type = 'highpass';
        high1.frequency.value = freq;
        high1.Q.value = Math.SQRT1_2;

        const high2 = this.#audioCtx.createBiquadFilter(); // Second-order high-pass filter.
        high2.type = 'highpass';
        high2.frequency.value = freq;
        high2.Q.value = Math.SQRT1_2;

        high1.connect(high2); // Chain the high-pass filters

        return {
            low: [low1, low2],
            high: [high1, high2]
        };
    };

    /**
     * Updates the gains of the equalizer bands when a slider changes or during initialization.
     * If all sliders remain at neutral (1.0), the EQ is bypassed entirely.
     * Otherwise, the equalizer bands are adjusted dynamically based on user input.
     * @param {Event} [target]  The event object containing the modified slider reference.
     */
    #applySettings = ({ target } = {}) => {

        if (target) {
            const value = Number(target.value),
                  ref = Number(target.getAttribute('data-ref'));

            if (this.#config.bands[ref] === value) return; // Skip if no change
            this.#config.bands[ref] = value;
        }

        // Adjust individual band gains
        this.#eqBands.forEach((bandObj, i) => {
            const sliderVal = this.#config.bands[i] ?? 1,
                  mappedGain = sliderVal <= 1 ? sliderVal : 1 + (sliderVal - 1) * 2.2;

            this.#fadeGain(bandObj.gainNode.gain, mappedGain);
        });

        /**
         * Computes a normalization factor to balance EQ loudness.
         * @private
         * @type {number}
         */
        const avgGain = this.#config.bands.reduce((a, b) => a + b, 0) / this.#eqBands.length,
              baseEQGain = avgGain > 0 ? 1 / avgGain : 0;

        // Enable bypass mode if all bands are neutral
        if (this.#config.bands.every(v => v === 1)) {
            this.#fadeGain(this.#output.gain, 1);
            this.#fadeGain(this.#bypassGain.gain, 1);
            this.#fadeGain(this.#eqOutput.gain, 0);
        } else {
            this.#fadeGain(this.#output.gain, 1 / Math.sqrt(this.#config.bands.length));
            this.#fadeGain(this.#bypassGain.gain, 0);
            this.#fadeGain(this.#eqOutput.gain, baseEQGain);
        }
    };

    /**
     * Smoothly transitions the gain parameter to a target value.
     * Uses `setTargetAtTime` for a subtle and natural fade effect.
     * @param {AudioParam} gParam  The gain parameter to adjust.
     * @param {number}     value   The target gain value.
     */
    #fadeGain(gParam, value) {

        gParam.cancelScheduledValues(this.#audioCtx.currentTime);
        gParam.setTargetAtTime(value, this.#audioCtx.currentTime, 0.02);

    }

    /**
     * Resets all equalizer bands to their default values.
     * Updates the UI and applies the default band settings.
     */
    #reset = () => {

        this.#config.bands.forEach((value, index) => {
            this.#dom[`band-${index}`].value = this.#config.bands[index] = Number(this.#dom[`band-${index}`].defaultValue);
        });

        this.#applySettings();

    };

    /**
     * Disconnects and cleans up all audio nodes related to the equalizer.
     * Ensures that all gain nodes, filters, and input/output connections are properly removed.
     */
    #disconnectAudio() {

        this.#bypassGain.disconnect();
        this.#bypassGain = null;

        this.#eqInput.disconnect();
        this.#eqInput = null;

        this.#eqOutput.disconnect();
        this.#eqOutput = null;

        // eqBands etc. ...
        this.#eqBands.forEach(b => {
            b.gainNode.disconnect();
            b.filters.forEach(f => f.disconnect());
        });

        this.#eqBands = [];

        this.#input.disconnect();
        this.#input = null;

        this.#output.disconnect();
        this.#output = null;

    }

    /**
     * Enables the UI controls.
     * @param {Object}  options         The options for the enable method.
     * @param {boolean} options.resume  If `true`, the UI will be disabled instead of hidden.
     */
    #enable({ resume = false } = {}) {
        if (resume === false) {
            this.#dom.mount();
        } else {
            this.#dom.reset.disabled = false;
            this.#config.bands.forEach((value, index) => {
                this.#dom[`band-${index}`].disabled = false;
            });
        }

    }

    /**
     * Disables the UI controls, eg when another engine is used.
     * @param {Object}  options          The options for the disable method.
     * @param {boolean} options.suspend  If `true`, the UI will be disabled instead of hidden.
     */
    #disable({ suspend = false } = {}) {
        if (suspend === false) {
            this.#dom.unmount();
        } else {
            this.#dom.reset.disabled = true;
            this.#config.bands.forEach((value, index) => {
                this.#dom[`band-${index}`].disabled = true;
            });
        }
    }

    /**
     * This method removes all events, subscriptions and DOM nodes created by this component.
     */
    destroy() {

        this.#dom.destroy();
        this.#player.audio.removeNode(this.#input, this.#output, this.#apiKey);
        this.#disconnectAudio();
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#dom = this.#audioCtx = this.#apiKey = null;

    }

}