Skip to content

Source: src/visualizer/AnalyserAudio.js

import { extend } from '../../lib/util/object.js';
import Looper from '../../lib/util/Looper.js';

/**
 * The AnalyserAudio component forms the backbone for all audio visualizations in the player.
 * It uses the Web Audio API to perform real-time frequency and time-domain analysis on audio streams.
 * This base class is meant to be subclassed by visual components that make use of the analysis data to render visual effects.
 * It is not intended to be used directly.
 * @exports module:src/visualizer/AnalyserAudio
 * @requires lib/util/object
 * @requires lib/util/Looper
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license   MIT
 */
export default class AnalyserAudio {

    /**
     * Holds the instance configuration for this component.
     * Config object extended by subclasses for specific visualizer settings.
     * @type     {Object}
     * @property {boolean} [audioOnly=false]    If `true`, analysis is active only for audio streams.
     * @property {number}  [channels=1]         Number of audio channels.
     * @property {number}  [hiPass=0]           High-pass filter cutoff value.
     * @property {number}  [loPass=0]           Low-pass filter cutoff value.
     * @property {number}  [fftSize=512]        FFT size for frequency analysis.
     * @property {number}  [minDecibels=-120]   Minimum decibels for analysis.
     * @property {number}  [maxDecibels=0]      Maximum decibels for analysis.
     * @property {number}  [smoothingTime=0.8]  Smoothing time constant for analysis.
     * @property {number}  [stopDelay=1000]     Delay (in ms) after which the analysis loop is stopped when paused.
     */
    #config;

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

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

    /**
     * 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;

    /**
     * Audio analysis data.
     * @property {ChannelSplitterNode} splitter       The channel splitter node used to divide the audio signal into channels.
     * @property {AudioNode[]}         analysers      Array of AnalyserNode objects for each channel.
     * @property {Uint8Array[]}        frequencyData  Array of Uint8Array storing frequency data for each channel.
     * @property {Uint8Array[]}        waveformData   Array of Uint8Array storing time-domain data for each channel.
     */
    #audio = {
        splitter: null,
        analysers: [],
        frequencyData: [],
        waveformData: []
    };

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

    /**
     * Flag indicating if the analyser loop is running.
     * @type {boolean}
     */
    #isActive = false;

    /**
     * Render Loop Instance, used for updating the scrubber.
     * @type {module:lib/util/Looper}
     */
    #renderLoop;

    /**
     * ID of the stop delay timeout.
     * @type {number}
     */
    #stopDelayId = 0;

    /**
     * Creates an instance of the AnalyserAudio component.
     * @param {module:src/core/Player} player  Reference to the main player instance.
     * @param {Object}                 config  Configuration object for the AnalyserAudio component. This is passed from the subclass.
     * @param {symbol}                 apiKey  Token for extended access to the player API.
     */
    constructor(player, config = {}, apiKey) {

        const audioCtx = player.audio.getContext(apiKey);

        if (!config || !audioCtx) return [false];

        const configExt = extend({
            audioOnly: true,
            channels: 1,
            hiPass: 0,
            loPass: 0,
            fftSize: 512,
            minDecibels: -120,
            maxDecibels: 0,
            smoothingTime: 0.8,
            stopDelay: 1000
        }, config);

        this.#player = player;
        this.#apiKey = apiKey;
        this.#config = configExt;
        this.#audioCtx = audioCtx;
        this.#renderLoop = new Looper(() => this.analyseLoop());

        this.#subscriptions = [
            this.#player.subscribe('data/ready', this.#onDataReady),
            this.#player.subscribe('media/play', this.startLoop.bind(this)),
            this.#player.subscribe('media/pause', this.stopLoop.bind(this))
        ];

        this.#initAnalysers();
        this.#player.audio.addNode(this.#audio.splitter, null, 99, apiKey);

    }

    /**
     * Handles "data/ready" events to activate audio analysis based on media type.
     * @param {module:src/core/Data~mediaItem} mediaItem            Object containing media type info.
     * @param {string}                         mediaItem.mediaType  Type of the media ('video' or 'audio').
     * @listens module:src/core/Data#data/ready
     */
    #onDataReady = ({ mediaType }) => {

        this.#isActive = this.#config.audioOnly ? mediaType === 'audio' : true;
        this.#player.dom.getElement(this.#apiKey).classList.toggle('has-audio-analyser', this.#isActive);
        if (!this.#isActive) this.stopLoop();

    };

    /**
     * Initializes the audio analysers for each channel.
     * Creates a ChannelSplitter and an Analyser for each channel, and connects them.
     */
    #initAnalysers() {

        this.#audio.splitter = this.#audioCtx.createChannelSplitter(this.#config.channels);

        for (let i = 0; i < this.#config.channels; i += 1) {
            const analyser = this.#audioCtx.createAnalyser();
            analyser.fftSize = this.#config.fftSize;
            analyser.minDecibels = this.#config.minDecibels;
            analyser.maxDecibels = this.#config.maxDecibels;
            analyser.smoothingTime = this.#config.smoothingTime;

            this.#audio.frequencyData.push(new Uint8Array(analyser.frequencyBinCount));
            this.#audio.waveformData.push(new Uint8Array(analyser.frequencyBinCount));
            this.#audio.analysers.push(analyser);
        }

        this.#audio.analysers.forEach((analyser, index) => {
            this.#audio.splitter.connect(analyser, index);
        });

    }

    /**
     * Starts the audio analysis loop.
     * @listens module:src/core/Media#media/play
     */
    startLoop() {

        if (!this.#isActive) return;

        clearTimeout(this.#stopDelayId);
        this.#renderLoop.start();

    }

    /**
     * Stops the audio analysis loop after a configured delay.
     * The delay is used to that the visualization can "cool down" without being frozen inplace.
     * @listens module:src/core/Media#media/pause
     */
    stopLoop() {

        clearTimeout(this.#stopDelayId);
        this.#stopDelayId = setTimeout(() => this.#renderLoop.stop(), this.#config.stopDelay);

    }

    /**
     * Main loop for audio analysis. Retrieves frequency and time domain data, then schedules the next iteration.
     * Used by subclasses in their own rendering loops for collecting data from the analyser.
     * @returns {Object} Object containing both `frequencyData` and `waveformData`.
     */
    analyseLoop() {

        this.#audio.analysers.forEach((analyser, index) => {
            analyser.getByteFrequencyData(this.#audio.frequencyData[index]);
            analyser.getByteTimeDomainData(this.#audio.waveformData[index]);
        });

        return {
            frequencyData: this.#audio.frequencyData,
            waveformData: this.#audio.waveformData
        };

    }

    /**
     * Tears down the AnalyserAudio component.
     * Disconnects audio nodes, cancels timeouts/animation frames, unsubscribes from events, and releases references.
     */
    destroy() {

        clearTimeout(this.#stopDelayId);
        this.#renderLoop.destroy();
        this.#audio.splitter.disconnect();
        this.#audio.analysers.forEach(analyser => analyser.disconnect());
        this.#player.audio.removeNode(this.#audio.splitter, null, this.#apiKey);
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#audio = this.#audioCtx = this.#apiKey = null;

    }

    /**
     * Reduces the size of the frequency array to the target size by averaging groups of frequencyData.
     * @param   {number[]} inputArray  Original frequency data array.
     * @param   {number}   targetSize  Desired size of the output array.
     * @returns {number[]}             The reduced frequency data array.
     */
    static reduceFrequencies(inputArray, targetSize) {
        const inputSize = inputArray.length;

        // If the target size is equal to or larger than the input size, return a copy of the array
        if (inputSize <= targetSize) return inputArray.slice(); // Return a copy of the array

        const groupSize = Math.floor(inputSize / targetSize);

        // Array with the averaged values, using map and slice
        return Array.from({ length: targetSize }, (_, i) => {
            const start = i * groupSize,
                  end = i === targetSize - 1 ? inputSize : (i + 1) * groupSize,
                  group = inputArray.slice(start, end);

            // Calculate the average of the current group
            return group.reduce((sum, value) => sum + value, 0) / group.length;
        });
    }
}