# -*- coding: utf-8 -*-
# src\electroacoustics\electroacoustics.py

# Electroacoustics-AUTH Python distribution package
# Copyright (C) 2026 Christos Sevastiadis <csevast@auth.gr>
#
# This file is part of Electroacoustics-AUTH Python distribution package.
#
# Electroacoustics-AUTH 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.
#
# This program 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, see <https://www.gnu.org/licenses/>.
"""
    Electroacoustics-AUTH package main module.

    Provides constants, functions, and other Python objects used in lectures, exercises,
    and teaching materials, primarily within Jupyter Notebooks.

    Provided EN ISO 61260-1:2014 constants:

    =======================  ==================================================
    ``OCT_FRQ_RAT``          Octave reference ratio G
    ``FRQ_REF``              Reference frequency of 1000 Hz
    ``OCT_BND_NUM_NOM``      Octave-band numbers array
    ``OCT_BND_FRQ_NOM``      Nominal mid-band frequencies for octave-band
                             filters array
    ``OCT_MDB_FRQ_NOM_SER``  Nominal mid-band frequencies for octave-band
                             filters series
    ``OCT_MDB_FRQ_NOM_DIC``  Nominal mid-band frequencies for octave-band
                             filters dictionary
    ``OC3_BND_NUM_NOM``      One-third-octave-band numbers array
    ``OC3_BND_FRQ_NOM``      Nominal mid-band frequencies for
                             one-third-octave-band filters array
    ``OC3_MDB_FRQ_NOM_DIC``  Nominal mid-band frequencies for
                             one-third-octave-band filters dictionary
    ``OC3_MDB_FRQ_NOM_SER``  Nominal mid-band frequencies for
                             one-third-octave-band filters series
    =======================  ==================================================

"""

__author__ = "Christos Sevastiadis <csevast@ece.auth.gr>"
__all__ = [
    "C_NORMAL",
    "DRY_AIR_GAS_CONSTANT",
    "FRQ_REF",
    "OC3_BND_FRQ_NOM",
    "OC3_BND_NUM_NOM",
    "OC3_MDB_FRQ_NOM_DIC",
    "OC3_MDB_FRQ_NOM_SER",
    "OCT_BND_FRQ_NOM",
    "OCT_BND_NUM_NOM",
    "OCT_FRQ_RAT",
    "OCT_MDB_FRQ_NOM_DIC",
    "OCT_MDB_FRQ_NOM_SER",
    "STATIC_PRESSURE_REF",
    "TEMPERATURE_REF",
    "a_filter",
    "a_filter_s",
    "air_density",
    "average_level",
    "c_filter",
    "c_filter_s",
    "characteristic_impedance",
    "create_arg_tme",
    "frq_vec",
    "inverse_law",
    "iso226_lf",
    "iso226_ln",
    "line_rad_lvl_dif",
    "line_src_rad_lvl",
    "oct_frq",
    "point_rad_lvl_dif",
    "point_src_rad_lvl",
    "prepare_storage_directory",
    "prn_oct_frq",
    "r_a",
    "r_c",
    "r_h",
    "r_h_max",
    "r_h_theta",
    "reverberation_time",
    "round_to_sig_figs_fp",
    "save_fig_images",
    "sound_celerity",
    "specific_impedance_cylindrical",
    "specific_impedance_planar",
    "specific_impedance_spherical",
    "src_ang_dir_ratio",
    "total_level",
]

import decimal as decim
from glob import glob
from pathlib import Path
import numpy as np
import pandas as pd
from numpy import ndarray
from scipy.special import hankel2

# Code from Sean Lake, odysseus9672
__context = decim.getcontext()
__context.prec = 64
decim.setcontext(__context)

# Code from Sean Lake, odysseus9672
__logBase10of2_decim = decim.Decimal(2).log10()
__logBase10of2 = float(__logBase10of2_decim)
__logBase10ofe_decim = 1 / decim.Decimal(10).ln()
__logBase10ofe = float(__logBase10ofe_decim)


STATIC_PRESSURE_REF = 1e5  # The environment normal static air pressure, in Pa.
TEMPERATURE_REF = 22  # The environment normal temperature, in Celsius degrees.
DRY_AIR_GAS_CONSTANT = 287.05  # J/kg*K


def air_density(temperature=TEMPERATURE_REF, static_pressure=STATIC_PRESSURE_REF):
    """
    Calculate the air density due to temperature.

    Parameters
    ----------
    temperature : float
        The environment temperature, in Celsius degrees. Default value is 22 Celsius
        degrees
    static_pressure : float
        The environment static pressure, in Pa. Default value is 100 kPa.

    Returns
    -------
    air_density : float
        The air density, in kg/m^3.

    """

    return static_pressure / (DRY_AIR_GAS_CONSTANT * (273 + temperature))


def sound_celerity(theta=TEMPERATURE_REF):
    """
    Return the sound celerity (speed).

    Parameters
    ----------
    theta : float
        The environment temperature in Celsius degrees. Default value is 22
        Celsius degrees.

    Returns
    -------
    float
        The sound celerity (speed) in meters per second.

    """

    return 331.4 * np.sqrt(1 + theta / 273)


# The approximate normal sound celerity (propagation speed) in 22 Celsius degrees
C_NORMAL = round(sound_celerity(), 0)


def characteristic_impedance(temperature=TEMPERATURE_REF):
    """
    Calculate the characteristic impedance of the air due to temperature.

    Parameters
    ----------
    theta : float
        The environment temperature in Celsius degrees. Default value is 22
        Celsius degrees.

    Returns
    -------
    float
        The characteristic impedance of the air, in rayls.

    """

    return air_density(temperature) * sound_celerity(temperature)


def reverberation_time(volume, area, celerity=C_NORMAL, m=0):
    """
    Return the reverberation time by reverberation equation.

    Parameters
    ----------
    volume : float
        The room volume in cubic meters.
    area : float
        The room sound absorption in square meters (Sabine).
    celerity : float
        The speed of sound in meters per second. Default value is the normal
        sound celerity, approximately 344 m/s.
    m : float
        The air attenuation factor in inverse meters. Default value is 0.

    Returns
    float
        The reverberation time of the room in seconds.

    """

    return (
        round(24 / (celerity * np.log10(np.exp(1))), 3)
        * volume / (area - 4 * m * volume)
    )


def r_h(rev_time, volume, celerity=C_NORMAL):
    """
    Return the reverberation distance of an omni directional source.

    Parameters
    ----------
    rev_time : float
        The room reverberation time in seconds.
    volume : float
        The room volume in cubic meters.
    celerity : float
        The speed of sound in meters per second. Default value is the normal
        sound celerity, approximately 344 m/s.

    Returns
    -------
    float
        The reverberation distance in meters.

    """

    return np.sqrt(3 / (2 * celerity * np.log10(np.exp(1)) * np.pi) * volume / rev_time)


def r_h_max(rev_time, volume, celerity=C_NORMAL, q=1):
    """
    Return the maximum reverberation distance of directional source.

    Parameters
    ----------
    rev_time : float
        The room reverberation time in seconds.
    volume : float
        The room volume in cubic meters.
    celerity : float
        The speed of sound in meters per second. Default value is the normal
        sound celerity, approximately 344 m/s.
    q : float
        The directivity factor of the source.

    Returns
    -------
    float
        The maximum reverberation distance in meters.

    """

    return np.sqrt(q) * r_h(rev_time, volume, celerity)


def src_ang_dir_ratio(src_type="omni", theta=0):
    """
    Return the directivity of an omni directional source.

    Parameters
    ----------
    src_type : ['omni']
        Select the source type. Default values is 'omni' for omni directive
        sounde source. Other source types not implemented yet. If not in the
        list the function returns 0 value.
    theta : float
        The angle against the reference axis of the source

    Returns
    -------
    float
        The source angular directivity ratio.

    """

    if src_type == "omni":
        return 1
    else:
        return 0


def r_h_theta(rev_time, volume, celerity=C_NORMAL, q=1,
              src_ang_dir_ratio=src_ang_dir_ratio, theta=0):
    """
    Return the maximum reverberation distance from a directive sound source.

    Parameters
    ----------
    volume : float
        The room volume in cubic meters.
    rev_time : float
        The room reverberation time in seconds.
    celerity : float
        The speed of sound in meters per second. Default value is the normal
        sound celerity, approximately 345.34 m/s.
    q : float
        The directivity ratio of the source. Default value is 1.
    src_ang_dir_ratio : function
        The angular directivity ratio function of the source. Default
        function is for an omni directional source.
    theta:
        The angle against the reference axis of the source. Default value is
        0 degrees.

    Returns
    -------
    float
        The reverberation distance in meters.

    """

    return src_ang_dir_ratio(theta) * r_h_max(rev_time, volume, celerity, q)


def total_level(lvs):
    r"""Add acoustic levels.

    Parameters
    ----------
    lvs : ndarray-like of floats
        The levels to be added.

    Returns
    -------
    ndarray-like of floats
        Total level :math:`L_\mathsf{tot}`.

    Notes
    -----
    :math:`L_\mathsf{tot} = 10\lg\left(\sum_{i}^{N} 10^{\frac{L_i}{10}}\right)=10\lg
           \left(\sum_{i}^{N}\frac{I_i}{I_{\mathrm{ref}}}\right)`

    """

    return 10 * np.log10(sum(10 ** (lvl / 10) for lvl in lvs))


def average_level(lvs):
    r"""Average acoustic levels.

    Parameters
    ----------
    lvs : ndarray-like of floats
        The levels to be averaged.

    Returns
    -------
    ndarray-like of floats
        Averaged level :math:`L_\mathsf{avg}`.

    Notes
    -----
    :math:`L_\mathsf{avg} = 10\lg\left(\frac{\sum_{i}^{N} 10^{\frac{L_i}{10}}}{N}
           \right)=10\lg\left(\frac{\sum_{i}^{N}\frac{I_i}{I_{\mathrm{ref}}}}{N}\right)`

    """

    return 10 * np.log10(sum(10 ** (lvl / 10) for lvl in lvs) / len(lvs))


def point_rad_lvl_dif(r, r_0=1):
    r"""Return the level difference at a radiance from a radiating point source.

    Parameters
    ----------
    r : float
        Radiance from the point source the level difference to be calculated in
        deciBell, $\mathsf{dB}$.
    r_0 : float
        Radiance from the point source of the level of radiation in deciBell,
        $\mathsf{dB}$.

    Returns
    -------
    float
        The level difference in deciBell, $\mathsf{dB}$.

    """

    return 20 * np.log10(r_0 / r)


def point_src_rad_lvl(lvl_r_0, r, r_0=1):
    r"""Return the level at a radiance from a radiating point source.

    Parameters
    ----------
    lvl_r_0 : float
        Level at the r_0 distance from the point source.
    r : float
        Radiance from the point source the level difference to be calculated in deci
        Bell, $\mathsf{dB}$.
    r_0 : float
        Radiance from the point source of the level of radiation in, deciBell,
        $\mathsf{dB}$.

    Returns
    -------
    float
        The level in deciBell, $\mathsf{dB}$.

    """

    return lvl_r_0 + point_rad_lvl_dif(r, r_0)


def line_rad_lvl_dif(r, r_0=1):
    r"""Return the level difference at a radiance from a radiating line source.

    Parameters
    ----------
    r : float
        Radiance from the line source the level difference to be calculated in deci
        Bell, $\mathsf{dB}$.
    r_0 : float
        Radiance from the line source of the level of radiation in, deciBell,
        $\mathsf{dB}$.

    Returns
    -------
    float
        The level difference in deciBell, $\mathsf{dB}$.

    """

    return 10 * np.log10(r_0 / r)


def line_src_rad_lvl(lvl_r_0, r, r_0=1):
    r"""Return the level at a radiance from a radiating line source.

    Parameters
    ----------
    lvl_r_0 : float
        Level at the r_0 distance from the line source.
    r : float
        Radiance from the line source the level difference to be calculated in deci
        Bell, $\mathsf{dB}$.
    r_0 : float
        Radiance from the line source of the level of radiation in, deciBell,
        $\mathsf{dB}$.

    Returns
    -------
    float
        The level in deciBell, $\mathsf{dB}$.

    """

    return lvl_r_0 + line_rad_lvl_dif(r, r_0)


def inverse_law(lvl_0, dst, dst_0=1, srctyp="point"):
    r"""
    Calculate the sound level radiated from a point source applying reverse-squared law.

    Parameters
    ----------
    lvl_0 : float
        Known level in [dB] at distance `dst_0`.
    dst : float
        Distance from the point source to be calculated the level.
    dst_0 : float
        Distance from the point source for the known level `lvl_0`.
    srctyp : {'point', 'line'}
        Source type.

    Returns
    -------
    fload
        The level value.

    Notes
    -----
    :math:`L(d)=L(d_0)+20\lg\left(\frac{d_0}{d}\right)`

    """

    if srctyp == "line":
        return lvl_0 + 10 * np.log10(dst_0 / dst)
    else:
        return lvl_0 + 20 * np.log10(dst_0 / dst)


def specific_impedance_planar(temperature=TEMPERATURE_REF,
                              static_pressure=STATIC_PRESSURE_REF):
    """Calculate the acoustic specific impedance of a planar sound wave.

    Parameters
    ----------
    temperature : float
        The temperature of the air, in Celsius degrees.
    static_pressure : float
        The static pressure of the air, in Pa.

    Returns
    -------
    specific_impdeance_planar : float
        The acoustic specific impedance, in rayls.
    
    """
    
    c = sound_celerity(temperature)
    rho = air_density(temperature, static_pressure)
    
    return rho * c


def specific_impedance_cylindrical(frequency, radial_distance,
                                   temperature=TEMPERATURE_REF,
                                   static_pressure=STATIC_PRESSURE_REF):
    """Calculate the acoustic specific impedance of a cylindrical sound wave.
    
    Parameters
    ----------
    frequency : float
        The frequency of the wave, in Hz.
    radial_distance : float
        The distance from the axis of symmetry of the cylindrical wave.
    temperature : float
        The temperature of the air, in Celsius degrees.
    static_pressure : float
        The static pressure of the air, in Pa.
    
    Returns
    -------
    specific_impdeance_cylindrical : float
        The acoustic specific impedance, in rayls.
    
    """
    
    c = sound_celerity(temperature)
    rho = air_density(temperature, static_pressure)
    wavenumber = 2*np.pi*frequency/c
    kw = wavenumber*radial_distance
    
    return 1j * rho * c * hankel2(0, kw) / hankel2(1, kw)


def specific_impedance_spherical(frequency, radius, temperature=TEMPERATURE_REF,
                                 static_pressure=STATIC_PRESSURE_REF):
    """Calculate the acoustic specific impedance of a spherical sound wave.
    
    Parameters
    ----------
    frequency : float
        The frequency of the wave, in Hz.
    radius : float
        The distance from the center of the spherical wave, or the point source.
    temperature : float
        The temperature of the air, in Celsius degrees.
    static_pressure : float
        The static pressure of the air, in Pa.
    
    Returns
    -------
    specific_impdeance_spherical : float
        The acoustic specific impedance, in rayls.
    
    """
    
    rho = air_density(temperature, static_pressure)
    c = sound_celerity(temperature)
    kr = 2*np.pi*frequency/c*radius
    
    return rho * c * (1j * kr / (1 + 1j * kr))


def round_to_sig_figs_fp(x, sigfigs):
    """
    Round the value(s) in x to the number of significant figures in sigfigs.

    Return value has the same type as x.

    Restrictions:
    sigfigs must be an integer type and store a positive value.
    x must be a real value or an array like object containing only real values.

    Notes
    -----
    Code from Sean Lake, odysseus9672

    """
    if not isinstance(sigfigs, (int, np.integer)):
        raise TypeError("round_to_sig_figs_fp: sigfigs must be an integer.")

    if sigfigs <= 0:
        raise ValueError("round_to_sig_figs_fp: sigfigs must be positive.")

    if not np.all(np.isreal(x)):
        raise TypeError("round_to_sig_figs_fp: all x must be real.")

    # temporarily suppres floating point errors
    errhanddict = np.geterr()
    np.seterr(all="ignore")

    matrixflag = False
    if isinstance(x, np.matrix):  # Convert matrices to arrays
        matrixflag = True
        x = np.asarray(x)

    xsgn = np.sign(x)
    absx = xsgn * x
    mantissas, binary_exponents = np.frexp(absx)

    decimal_exponents = __logBase10of2 * binary_exponents
    omags = np.floor(decimal_exponents)

    mantissas *= 10.0 ** (decimal_exponents - omags)

    if isinstance(mantissas, (float, np.floating)):
        if mantissas < 1.0:
            mantissas *= 10.0
            omags -= 1.0

    else:
        fixmsk = (mantissas < 1.0,)
        mantissas[fixmsk] *= 10.0
        omags[fixmsk] -= 1.0

    result = xsgn * np.around(mantissas, decimals=sigfigs - 1) * 10.0**omags
    if matrixflag:
        result = np.matrix(result, copy=False)

    np.seterr(**errhanddict)
    return result


# Supporting rounding functions
def _round_to_n(x, n):
    """
    Round a number in n significant digits.
    
    Parameters
    ----------
    x : float
        The number to be rounded.
    n : int
        The number of significant digits.

    Returns
    -------
    float
        The rounded number.

    """
    if x == 0:
        return x
    else:
        return np.round(x, -int(np.floor(np.log10(np.abs(x)))) + (n - 1))


def _mst_sgn_dig(x):
    """
    Return the most significant digit of a number.
    
    Parameters
    ----------
    x : float
        The number.

    Returns
    -------
    int
        The most significant digit.
    
    """
    
    return int(x / 10 ** int(np.floor(np.log10(np.abs(x)))))


# EN ISO 61260-1:2014 constants
_OCT_FRQ_RAT_ = 10 ** (3 / 10)
OCT_FRQ_RAT = _round_to_n(_OCT_FRQ_RAT_, 6)
FRQ_REF = 1000
OCT_BND_NUM_NOM = np.arange(-5, 5)
OCT_BND_FRQ_NOM = np.array([31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000])
OCT_MDB_FRQ_NOM_SER = pd.Series(OCT_BND_FRQ_NOM, index=OCT_BND_NUM_NOM)
OCT_MDB_FRQ_NOM_DIC = dict(zip(OCT_BND_NUM_NOM, OCT_BND_FRQ_NOM))
OC3_BND_NUM_NOM = np.arange(-17, 14)
OC3_BND_FRQ_NOM = np.array(
    [
        20,
        25,
        31.5,
        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,
    ]
)
OC3_MDB_FRQ_NOM_DIC = dict(zip(OC3_BND_NUM_NOM, OC3_BND_FRQ_NOM))
OC3_MDB_FRQ_NOM_SER = pd.Series(OC3_BND_FRQ_NOM, index=OC3_BND_NUM_NOM)


def oct_frq(x, b=1, ret_type="numpy"):
    r"""
    Calculate the exact mid-band frequencies of oct.-band and fract.-oct.-band filters.

    Parameters
    ----------
    x : int
        Band number,
    b : int
        Denominator of the bandwidth designator :math:`\frac{1}{b}`.
    ret_type : {'numpy', 'pandas'}
        Type of return arrays.

    Returns
    -------
    f_n : int
        Nominal mid-band frequency,
    f_m : float
        Exact mid-band frequency,
    f_1 : float
        Lower band-edge frequency,
    f_2 : float
        Upper band-edge frequency,
    bw : float
        Bandwidth of the filter,
    bw_r : float
        Normalized bandwidth of the filter.

    Notes
    -----
    Calculations follows the standard ISO EN 61260-1:2014 —
    Electroacoustics — Octave-band and fractional-octave-band
    filters, Part 1: Specifications.

    An exact mid-band frequency is the geometric mean of the
    corresponding band-edge frequencies as given by

    :math:`f_m=\sqrt{f_1f_2}`

    """

    f_m = (FRQ_REF * _OCT_FRQ_RAT_ ** ((2 * x + 1) / (2 * b)) if b % 2 == 0
           else FRQ_REF * _OCT_FRQ_RAT_ ** (x / b))

    f_1 = round_to_sig_figs_fp(f_m * _OCT_FRQ_RAT_ ** (-1 / (2 * b)), 5)
    f_2 = round_to_sig_figs_fp(f_m * _OCT_FRQ_RAT_ ** (+1 / (2 * b)), 5)
    bw = f_2 - f_1
    bw_r = round_to_sig_figs_fp(1 / b * np.log(_OCT_FRQ_RAT_), 6)
    if isinstance(x, np.ndarray):
        bw_r = np.full(len(x), bw_r)

    f_m = round_to_sig_figs_fp(f_m, 5)

    if isinstance(x, np.ndarray):
        if b == 1:
            tmp = OCT_MDB_FRQ_NOM_SER
            f_n = tmp.reindex(x).to_numpy()
        elif b == 3:
            tmp = OC3_MDB_FRQ_NOM_SER
            f_n = tmp.reindex(x).to_numpy()
        elif b > 3 and b < 25:
            f_n = round_to_sig_figs_fp(f_m, 3)
        else:
            f_n = round_to_sig_figs_fp(f_m, 3)
    else:
        if b == 1:
            tmp = OCT_MDB_FRQ_NOM_SER
            f_n = tmp.reindex([x]).to_numpy()[0]
        elif b == 3:
            tmp = OC3_MDB_FRQ_NOM_SER
            f_n = tmp.reindex([x]).to_numpy()[0]
        elif b > 3 and b < 25:
            f_m_mst_sgn_dig = _mst_sgn_dig(f_m)
            if f_m_mst_sgn_dig > 0 and f_m_mst_sgn_dig < 5:
                f_n = round_to_sig_figs_fp(f_m, 3)
            else:
                f_n = round_to_sig_figs_fp(f_m, 2)
        else:
            # TODO: implement as in the standard
            f_m_mst_sgn_dig = _mst_sgn_dig(f_m)
            if f_m_mst_sgn_dig > 0 and f_m_mst_sgn_dig < 5:
                f_n = round_to_sig_figs_fp(f_m, 3)
            else:
                f_n = round_to_sig_figs_fp(f_m, 3)
    if b == 2:
        f_n = round_to_sig_figs_fp(f_m, 3)

    if ret_type == "pandas":
        pd_columns = [
            r"Index $x$",
            r"Exact $f_\mathsf{m}$ caclulated in $\mathrm{Hz}$",
            r"Nominal mid-band frequency in $\mathrm{Hz}$",
            r"f_1",
            r"f_2",
            r"B",
            r"B_r",
        ]
        if not isinstance(x, np.ndarray):
            pd_index = np.array([x])
            pd_data = dict(zip(pd_columns, [x, f_m, f_n, f_1, f_2, bw, bw_r]))
        else:
            pd_index = x
            pd_data = np.array([x, f_m, f_n, f_1, f_2, bw, bw_r]).transpose()
        returns = pd.DataFrame(pd_data, index=pd_index, columns=pd_columns)
        returns = (
            returns.style
            .hide(axis=0)
            .format(
                {
                    returns.columns[0]: "{:.0f}",
                    returns.columns[1]: "{:.2f}",
                    returns.columns[2]: "{:.1f}",
                    returns.columns[3]: "{:.2f}",
                    returns.columns[4]: "{:.2f}",
                    returns.columns[5]: "{:.1f}",
                    returns.columns[6]: "{:.3g}",
                }
            )
        )
    else:
        returns = (x, f_m, f_n, f_1, f_2, bw, bw_r)

    return returns


def prn_oct_frq(x_s, x_e, b=1):
    r"""Print the octave-band and octave fraction-band frequencies.

    Parameters
    ----------
    x_s : int
        Start band,
    x_e : int
        End band,
    b : int
        Denominator of the bandwidth designator :math:`\frac{1}{b}`.

    """

    print("   x,      f_m,      f_n,      f_1,      f_2,       B_r")
    for x in range(x_s, x_e + 1):
        _, f_m, f_n, f_1, f_2, _, bw_r = oct_frq(x, b)
        print(f"{x:4}, {f_m:8.2f}, {f_n:8.2f}, {f_1:8.2f}, {f_2:8.2f}, {bw_r:9.6f}")


def r_a(f):
    r"""Calculate the frequency response of the A-weighting filter.

    Parameters
    ----------
    f : array-like
        Frequency vector.

    Returns
    -------
    a_response : ndarray of float
        The frequenccy response of the A-weighted filter.

    Notes
    -----
    :math:`R_\mathsf{A}(f)=\frac{12194^{2}f^4}{\left(f^2+20.6^2\right)\sqrt{\left(f^2+107.7^2\right)\left(f^2+737.9^2\right)}\left(f^2+12194^2\right)}`

    """

    if not isinstance(f, ndarray):
        f = np.array(f, dtype="float")
    a_response = (
        12194**2
        * f**4
        / (f**2 + 20.6**2)
        / np.sqrt((f**2 + 107.7**2) * (f**2 + 737.9**2))
        / (f**2 + 12194**2)
    )
    return a_response


def a_filter(f):
    r"""Calculate the frequency response levels of the A-weighting filter.

    Parameters
    ----------
    f : array-like
        Frequency vector.

    Returns
    -------
    ndarray of float

    Notes
    -----
    :math:`R_\mathsf{A}(f)=\frac{12194^{2}f^4}{\left(f^2+20.6^2\right)\sqrt{\left(f^2+107.7^2\right)\left(f^2+737.9^2\right)}\left(f^2+12194^2\right)}`
    :math:`A(f)=20\lg\left(R_\mathsf{A}(f)\right)+2.00 \mathsf{dB(A)}`

    """

    return 20 * np.log10(r_a(f)) + 2


def a_filter_s(s):
    r"""Calculate the frequency response of the A-weighting filter in the s domain.

    Parameters
    ----------
    s : ndarray
        s vector.

    Returns
    -------
    ndarray

    Notes
    -----
    :math:`H_\mathsf{A}(s)=\frac{k_\mathsf{A}s^4}{(s+129.4)^2(s+676.7)(s+4636)(s+76655)^2}`
    :math:`k_\mathsf{A}\approx7.39705\times10^9`

    """

    k_a = 7.39705e9
    return k_a * s**4 / (s + 129.4) ** 2 / (s + 676.7) / (s + 4636) / (s + 76655) ** 2


def r_c(f):
    r"""Calculate the frequency response of the C-weighting filter.

    Parameters
    ----------
    f : array-like
        Frequency vector.

    Returns
    -------
    ndarray of float

    Notes
    -----
    :math:`R_\mathsf{C}(f)=\frac{12194^{2}f^2}{\left(f^2+20.6^2\right)\left(f^2+12194^2\right)}`

    """

    if not isinstance(f, ndarray):
        f = np.array(f, dtype="float")
    return 12194**2 * f**2 / (f**2 + 20.6**2) / (f**2 + 12194**2)


def c_filter(f):
    r"""Calculate the frequency response levels of the C-weighting filter.

    Parameters
    ----------
    f : array-like
        Frequency vector.

    Returns
    -------
    float or ndarray

    Notes
    -----
    :math:`R_\mathsf{C}(f)=\frac{12194^{2}f^2}{\left(f^2+20.6^2\right)\left(f^2+12194^2\right)}`
    :math:`C(f)=20\lg\left(R_\mathsf{C}(f)\right)+0.06 \mathsf{dB(C)}`

    """

    return 20 * np.log10(r_c(f)) + 0.06


def c_filter_s(s):
    r"""Calculate the frequency response of the C-weighting filter in the s domain.

    Parameters
    ----------
    s : float or ndarray
        s vector.

    Returns
    -------
    float or ndarray

    Notes
    -----
    :math:`H_\mathsf{C}(s)=\frac{k_\mathsf{C}s^2}{(s+129.4)^2(s+76655)^2}`
    :math:`k_\mathsf{C}\approx5.91797\times10^9`

    """

    k_c = 7.39705e9
    return k_c * s**2 / (s + 129.4) ** 2 / (s + 76655) ** 2


def iso226_lf(ln, iso=2023):
    """Generate an Equal Loudness Contour as described in ISO 226.

    This function will return the equal loudness contour for your desired phon
    level. The frequencies evaulated in this function only span from
    20Hz - 12.5kHz, and only 29 selective frequencies are covered. This is the
    limitation of the ISO standard.

    In addition the valid phon range should be 0 - 100 dB SPL. Values outside
    this range do not have experimental values and their contours should be
    treated as inaccurate. If more samples are required you should be able to
    easily interpolate these values using spline().

    Parameters
    ----------
    ln : float
        Is the phon value in dB SPL that you want the equal loudness curve to
        represent. (1phon = 1dB @ 1kHz)

    Returns
    -------
    spl : ndarray of float
        Is the Sound Pressure Level amplitude returned for each of the 29
        frequencies evaluated by ISO226.
    frq : ndarray of float
        Is the returned vector of frequencies that ISO226 evaluates to generate
        the contour.

    Notes
    -----
    Author: Jeff Tackett 03/01/05
    Converted to Python by: Christos Sevastiadis [2019-04-22]

    """

    # TABLES FROM ISO226:2023
    _frq = np.array(
        [
            20,
            25,
            31.5,
            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,
        ]
    )
    _af_2003 = np.array(
        [
            0.532,
            0.506,
            0.480,
            0.455,
            0.432,
            0.409,
            0.387,
            0.367,
            0.349,
            0.330,
            0.315,
            0.301,
            0.288,
            0.276,
            0.267,
            0.259,
            0.253,
            0.250,
            0.246,
            0.244,
            0.243,
            0.243,
            0.243,
            0.242,
            0.242,
            0.245,
            0.254,
            0.271,
            0.301,
        ]
    )
    _af = np.array(
        [
            0.635,
            0.602,
            0.569,
            0.537,
            0.509,
            0.482,
            0.456,
            0.433,
            0.412,
            0.391,
            0.373,
            0.357,
            0.343,
            0.330,
            0.320,
            0.311,
            0.303,
            0.300,
            0.295,
            0.292,
            0.290,
            0.290,
            0.289,
            0.289,
            0.289,
            0.293,
            0.303,
            0.323,
            0.354,
        ]
    )
    _lu_2003 = np.array(
        [
            -31.6,
            -27.2,
            -23.0,
            -19.1,
            -15.9,
            -13.0,
            -10.3,
            -8.1,
            -6.2,
            -4.5,
            -3.1,
            -2.0,
            -1.1,
            -0.4,
            0.0,
            0.3,
            0.5,
            0.0,
            -2.7,
            -4.1,
            -1.0,
            1.7,
            2.5,
            1.2,
            -2.1,
            -7.1,
            -11.2,
            -10.7,
            -3.1,
        ]
    )
    _lu = np.array(
        [
            -31.5,
            -27.2,
            -23.1,
            -19.3,
            -16.1,
            -13.1,
            -10.4,
            -8.2,
            -6.3,
            -4.6,
            -3.2,
            -2.1,
            -1.2,
            -0.5,
            0.0,
            0.4,
            0.5,
            0.0,
            -2.7,
            -4.2,
            -1.1,
            1.4,
            2.3,
            1.0,
            -2.3,
            -7.2,
            -11.2,
            -10.9,
            -3.5,
        ]
    )
    _tf_2003 = np.array(
        [
            78.5,
            68.7,
            59.5,
            51.1,
            44.0,
            37.5,
            31.5,
            26.5,
            22.1,
            17.9,
            14.4,
            11.4,
            8.6,
            6.2,
            4.4,
            3.0,
            2.2,
            2.4,
            3.5,
            1.7,
            -1.3,
            -4.2,
            -6.0,
            -5.4,
            -1.5,
            6.0,
            12.6,
            13.9,
            12.3,
        ]
    )
    _tf = np.array(
        [
            78.1,
            68.7,
            59.5,
            51.1,
            44.0,
            37.5,
            31.5,
            26.5,
            22.1,
            17.9,
            14.4,
            11.4,
            8.6,
            6.2,
            4.4,
            3.0,
            2.2,
            2.4,
            3.5,
            1.7,
            -1.3,
            -4.2,
            -6.0,
            -5.4,
            -1.5,
            6.0,
            12.6,
            13.9,
            12.3,
        ]
    )

    # Error Trapping
    if (ln < 0) or (ln > 100):
        print("L_n phon value out of bounds!")
        return 0, 0
    else:
        lmtidx = len(_frq)
        if iso == 2003:
            _tf = _tf_2003
            _lu = _lu_2003
            _af = _af_2003
            # Deriving sound pressure level from loudness level (iso226 sect 4.1)
            af = (
                4.47e-3 * (10 ** (0.025 * ln) - 1.15)
                + (0.4 * 10 ** (((_tf + _lu) / 10) - 9)) ** _af
            )
            lf = ((10 / _af) * np.log10(af)) - _lu + 94
        else:
            if ln >= 90 and ln < 100:
                lmtidx = 24  # 4000 Hz
            elif ln == 100:
                lmtidx = 18  # 1000 Hz
            lf = (10 / _af) * np.log10(
                (4e-10) ** (0.3 - _af) * (10 ** (0.03 * ln) - 10**0.072)
                + 10 ** (_af * (_tf + _lu) / 10)
            ) - _lu
        # Return user data
        return lf[:lmtidx], _frq[:lmtidx]


def iso226_ln(lf, frq, iso=2023):
    """Derive loudness levels from sound pressure levels as described in ISO 226.

    This function will return the equal loudness contour for your desired phon
    level. The frequencies evaulated in this function only span from
    20Hz - 12.5kHz, and only 29 selective frequencies are covered. This is the
    limitation of the ISO standard.

    In addition the valid phon range should be 0 - 100 dB SPL. Values outside
    this range do not have experimental values and their contours should be
    treated as inaccurate. If more samples are required you should be able to
    easily interpolate these values using spline().

    Parameters
    ----------
    lf : float
        Is the sound pressure value, in dB, of a pure tone of frequency `frq`
    freq : int
        Is the frequency of the pure tone, in Hz, with sound pressure level
        `lf`.

    Returns
    -------
    ln : float
        Is the loudness level L_N, in phon.

    """

    # TABLES FROM ISO226:2023
    _frq = np.array(
        [
            20,
            25,
            31.5,
            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,
        ]
    )
    _af = np.array(
        [
            0.635,
            0.602,
            0.569,
            0.537,
            0.509,
            0.482,
            0.456,
            0.433,
            0.412,
            0.391,
            0.373,
            0.357,
            0.343,
            0.330,
            0.320,
            0.311,
            0.303,
            0.300,
            0.295,
            0.292,
            0.290,
            0.290,
            0.289,
            0.289,
            0.289,
            0.293,
            0.303,
            0.323,
            0.354,
        ]
    )
    _lu = np.array(
        [
            -31.5,
            -27.2,
            -23.1,
            -19.3,
            -16.1,
            -13.1,
            -10.4,
            -8.2,
            -6.3,
            -4.6,
            -3.2,
            -2.1,
            -1.2,
            -0.5,
            0.0,
            0.4,
            0.5,
            0.0,
            -2.7,
            -4.2,
            -1.1,
            1.4,
            2.3,
            1.0,
            -2.3,
            -7.2,
            -11.2,
            -10.9,
            -3.5,
        ]
    )
    _tf = np.array(
        [
            78.1,
            68.7,
            59.5,
            51.1,
            44.0,
            37.5,
            31.5,
            26.5,
            22.1,
            17.9,
            14.4,
            11.4,
            8.6,
            6.2,
            4.4,
            3.0,
            2.2,
            2.4,
            3.5,
            1.7,
            -1.3,
            -4.2,
            -6.0,
            -5.4,
            -1.5,
            6.0,
            12.6,
            13.9,
            12.3,
        ]
    )
    idx = (np.where(_frq == frq))[0]
    if len(idx) == 0:
        print(
            "Frequency should be the central of a one-third octave zone, and between"
            " 20 Hz and 12500 Hz."
        )
        return 0
    ln = (
        100
        / 3
        * np.log10(
            (
                10 ** (_af[idx] * (lf + _lu[idx]) / 10)
                - 10 ** (_af[idx] * (_tf[idx] + _lu[idx]) / 10)
            )
            / (4e-10) ** (0.3 - _af[idx])
            + 10 ** (0.072)
        )
    )
    if (ln >= 90 and ln < 100 and idx < 24) or (ln >= 100 and idx < 18):
        print("The loudness level exceeds the higher limits.")
    return ln[0]


def create_arg_tme(frq, no_prd, samples_per_period=100, negative_time=False):
    """
    Create vectors in radians and in time [s].

    Parameters
    ----------
    frq : float
        Signal frequency [Hz]
    no_prd : float
        Number of sinusoidal signal periods [-]
    negative_time : boolean
        Flag to creative negative time vector ending to zero.

    Returns
    -------
    arg : ndarray of float
        The argument vector [rad]
    t : ndarray of float
        The time vector [s]

    """

    start = 0
    end = no_prd * 2 * np.pi
    if negative_time:
        start = -end
        end = 0
    arg = np.linspace(start, end, num=no_prd * samples_per_period)
    tme = arg / (2 * np.pi * frq)
    return arg, tme


def frq_vec(ppo, start, stop):
    """Create a frequency values logarithmic vector.

    Parameters
    ----------
    ppo   : Number of points per octave.
    start : Start frequency value.
    stop  : Stop frequency value.

    Returns
    -------
    ndarray
        The time vector.

    """

    # Number of total points
    nop = int(ppo * np.log2(stop / start))

    return np.logspace(np.log2(start), np.log2(stop), num=nop, base=2)


def prepare_storage_directory(storage_path: str) -> None:
    """
    Create a directory and remove all previously saved files.

    Create the parent directory of the 'path' if it is not exist, or remove all old
    saved files with filenames starting with `path`.

    Parameters
    ----------
    storage_path: pahtlib.Path
        The starting part of the path of files to be removed.

    """

    p = Path(storage_path)
    # Remove old output images
    d = p.parent
    if not d.exists():
        d.mkdir(parents=True)
    else:
        for file in glob(storage_path + "*.*"):
            Path(file).unlink()


def save_fig_images(fig, filename, png=True, svg=True, pdf=True):
    """
    Save a Matplotlib Figure in multiple image formats.

    Parameters
    ----------
    fig: matplotlib.figure.Figure
        Matplotlib Figure object.
    filname: string
        The filename to be saved.
    png: bool, default: True
        Toggles if PNG file will be saved.
    svg: bool, default: True
        Toggles if SVG file will be saved.
    pdf: bool, default: True
        Toggles if PDF file will be saved.
    
    """

    if png:
        fig.savefig(filename + ".png")
    if svg:
        fig.savefig(filename + ".svg")
    if pdf:
        fig.savefig(filename + ".pdf")
