"""Fractional octave filter bank."""
import warnings
import numpy as np
import scipy.signal as spsignal
import pyfar as pf
from pyfar._utils import rename_arg
[docs]
def fractional_octave_frequencies(
num_fractions=1, frequency_range=(20, 20e3), return_cutoff=False):
"""Return the octave center frequencies according to the IEC 61260:1:2014
standard.
For numbers of fractions other than ``1`` and ``3``, only the
exact center frequencies are returned, since nominal frequencies are not
specified by corresponding standards.
Parameters
----------
num_fractions : int, optional
The number of bands an octave is divided into. Eg., ``1`` refers to
octave bands and ``3`` to third octave bands. The default is ``1``.
frequency_range : array, tuple
The lower and upper frequency limits, the default is
``frequency_range=(20, 20e3)``.
return_cutoff : bool, optional
If ``True``, the lower and upper critical frequencies of the bandpass
filters for each band are returned. The default is ``False``.
Returns
-------
nominal : array, float
The nominal center frequencies in Hz specified in the standard.
Nominal frequencies are only returned for octave bands and third octave
bands.
exact : array, float
The exact center frequencies in Hz, resulting in a uniform distribution
of frequency bands over the frequency range.
cutoff_freq : tuple, array, float
The lower and upper critical frequencies in Hz of the bandpass filters
for each band as a tuple corresponding to ``(f_lower, f_upper)``.
"""
nominal = None
f_lims = np.asarray(frequency_range)
if f_lims.size != 2:
raise ValueError(
"You need to specify a lower and upper limit frequency.")
if f_lims[0] > f_lims[1]:
raise ValueError(
"The second frequency needs to be higher than the first.")
if num_fractions in [1, 3]:
nominal, exact = _center_frequencies_fractional_octaves_iec(
num_fractions)
mask = (nominal >= f_lims[0]) & (nominal <= f_lims[1])
nominal = nominal[mask]
exact = exact[mask]
else:
exact = _exact_center_frequencies_fractional_octaves(
num_fractions, f_lims)
if return_cutoff:
octave_ratio = 10**(3/10)
freqs_upper = exact * octave_ratio**(1/2/num_fractions)
freqs_lower = exact * octave_ratio**(-1/2/num_fractions)
f_crit = (freqs_lower, freqs_upper)
return nominal, exact, f_crit
else:
return nominal, exact
def _exact_center_frequencies_fractional_octaves(
num_fractions, frequency_range):
"""Calculate the center frequencies of arbitrary fractional octave bands.
Parameters
----------
num_fractions : int
The number of fractions
frequency_range
The upper and lower frequency limits
Returns
-------
exact : array, float
An array containing the center frequencies of the respective fractional
octave bands
"""
ref_freq = 1e3
Nmax = np.around(num_fractions*(np.log2(frequency_range[1]/ref_freq)))
Nmin = np.around(num_fractions*(np.log2(ref_freq/frequency_range[0])))
indices = np.arange(-Nmin, Nmax+1)
exact = ref_freq * 2**(indices / num_fractions)
return exact
def _center_frequencies_fractional_octaves_iec(num_fractions):
"""Returns the exact center frequencies for fractional octave bands
according to the IEC 61260:1:2014 standard.
octave ratio
.. G = 10^{3/10}
center frequencies
.. f_m = f_r G^{x/b}
.. f_m = f_e G^{(2x+1)/(2b)}
where b is the number of octave fractions, f_r is the reference frequency
chosen as 1000Hz and x is the index of the frequency band.
Parameters
----------
num_fractions : 1, 3
The number of octave fractions. 1 returns octave center frequencies,
3 returns third octave center frequencies.
Returns
-------
nominal : array, float
The nominal (rounded) center frequencies specified in the standard.
Nominal frequencies are only returned for octave bands and third octave
bands
exact : array, float
The exact center frequencies, resulting in a uniform distribution of
frequency bands over the frequency range.
"""
if num_fractions == 1:
nominal = np.array([
31.5, 63, 125, 250, 500, 1e3,
2e3, 4e3, 8e3, 16e3], dtype=float)
elif num_fractions == 3:
nominal = np.array([
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], dtype=float)
reference_freq = 1e3
octave_ratio = 10**(3/10)
iseven = np.mod(num_fractions, 2) == 0
if ~iseven:
indices = np.around(
num_fractions * np.log(nominal/reference_freq)
/ np.log(octave_ratio))
exponent = (indices/num_fractions)
else:
indices = np.around(
2.0*num_fractions *
np.log(nominal/reference_freq) / np.log(octave_ratio) - 1)/2
exponent = ((2*indices + 1) / num_fractions / 2)
exact = reference_freq * octave_ratio**exponent
return nominal, exact
[docs]
@rename_arg({"freq_range": "frequency_range"},
"freq_range parameter will be deprecated in pyfar 0.8.0 in "
"favor of frequency_range")
def fractional_octave_bands(
signal,
num_fractions,
sampling_rate=None,
frequency_range=(20.0, 20e3),
order=14):
"""Create and/or apply an energy preserving fractional octave filter bank.
The filters are designed using second order sections of Butterworth
band-pass filters. Note that if the upper cut-off frequency of a band lies
above the Nyquist frequency, a high-pass filter is applied instead. Due to
differences in the design of band-pass and high-pass filters, their slopes
differ, potentially introducing an error in the summed energy in the stop-
band region of the respective filters.
.. note::
This filter bank has -3 dB cut-off frequencies. For sufficiently large
values of ``'order'``, the summed energy of the filter bank equals the
energy of input signal, i.e., the filter bank is energy preserving
(reconstructing). This is useful for analysis energetic properties of
the input signal such as the room acoustic property reverberation
time. For an amplitude preserving filter bank with -6 dB cut-off
frequencies see
:py:func:`~pyfar.dsp.filter.reconstructing_fractional_octave_bands`.
Parameters
----------
signal : Signal, None
The signal to be filtered. Pass ``None`` to create the filter without
applying it.
num_fractions : int
The number of bands an octave is divided into. Eg., ``1`` refers to
octave bands and ``3`` to third octave bands.
sampling_rate : None, int
The sampling rate in Hz. Only required if signal is ``None``. The
default is ``None``.
frequency_range : array, tuple, optional
The lower and upper frequency limits. The default is
``frequency_range=(20, 20e3)``.
order : int, optional
Order of the Butterworth filter. The default is ``14``.
freq_range: array, tuple, optional
The lower and upper frequency limits. The default is
``frequency_range=(20, 20e3)``.
``'freq_range'`` parameter will be deprecated in pyfar 0.8.0 in favor
of ``'frequency_range'``.
Returns
-------
signal : Signal
The filtered signal. Only returned if ``sampling_rate = None``.
filter : FilterSOS
Filter object. Only returned if ``signal = None``.
Examples
--------
Filter an impulse into octave bands. The summed energy of all bands equals
the energy of the input signal.
.. plot::
>>> import pyfar as pf
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> # generate the data
>>> x = pf.signals.impulse(2**17)
>>> y = pf.dsp.filter.fractional_octave_bands(
... x, 1, frequency_range=(20, 8e3))
>>> # frequency domain plot
>>> y_sum = pf.FrequencyData(
... np.sum(np.abs(y.freq)**2, 0), y.frequencies)
>>> pf.plot.freq(y)
>>> ax = pf.plot.freq(y_sum, color='k', log_prefix=10, linestyle='--')
>>> ax.set_title(
... "Filter bands and the sum of their squared magnitudes")
"""
# check input
if (signal is None and sampling_rate is None) \
or (signal is not None and sampling_rate is not None):
raise ValueError('Either signal or sampling_rate must be none.')
fs = signal.sampling_rate if sampling_rate is None else sampling_rate
sos = _coefficients_fractional_octave_bands(
sampling_rate=fs, num_fractions=num_fractions,
frequency_range=frequency_range, order=order)
filt = pf.FilterSOS(sos, fs)
filt.comment = (
"Second order section 1/{num_fractions} fractional octave band"
"filter of order {order}")
# return the filter object
if signal is None:
# return the filter object
return filt
else:
# return the filtered signal
signal_filt = filt.process(signal)
return signal_filt
def _coefficients_fractional_octave_bands(
sampling_rate, num_fractions,
frequency_range=(20.0, 20e3), order=14):
"""Calculate the second order section filter coefficients of a fractional
octave band filter bank.
Parameters
----------
num_fractions : int, optional
The number of bands an octave is divided into. Eg., 1 refers to octave
bands and 3 to third octave bands. The default is 1.
sampling_rate : None, int
The sampling rate in Hz. Only required if signal is None. The default
is None.
frequency_range : array, tuple, optional
The lower and upper frequency limits. The default is (20, 20e3)
order : integer, optional
Order of the Butterworth filter. The default is 14.
Returns
-------
sos : array, float
Second order section filter coefficients with shape (.., 6)
Notes
-----
This function uses second order sections of butterworth filters for
increased numeric accuracy and stability.
"""
f_crit = fractional_octave_frequencies(
num_fractions, frequency_range, return_cutoff=True)[2]
freqs_upper = f_crit[1]
freqs_lower = f_crit[0]
# normalize interval such that the Nyquist frequency is 1
Wns = np.vstack((freqs_lower, freqs_upper)).T / sampling_rate * 2
mask_skip = Wns[:, 0] >= 1
if np.any(mask_skip):
Wns = Wns[~mask_skip]
warnings.warn(
"Skipping bands above the Nyquist frequency", stacklevel=2)
num_bands = np.sum(~mask_skip)
sos = np.zeros((num_bands, order, 6), np.double)
for idx, Wn in enumerate(Wns):
# in case the upper frequency limit is above Nyquist, use a highpass
if Wn[-1] > 1:
warnings.warn(
f'The upper frequency limit {freqs_upper[idx]:.1f} Hz is above'
' the Nyquist frequency. Using a highpass filter instead of a '
'bandpass.',
stacklevel=2)
Wn = Wn[0]
btype = 'highpass'
sos_hp = spsignal.butter(order, Wn, btype=btype, output='sos')
sos_coeff = pf.classes.filter._extend_sos_coefficients(
sos_hp, order)
else:
btype = 'bandpass'
sos_coeff = spsignal.butter(
order, Wn, btype=btype, output='sos')
sos[idx, :, :] = sos_coeff
return sos
[docs]
def reconstructing_fractional_octave_bands(
signal, num_fractions=1, frequency_range=(63, 16000),
overlap=1, slope=0, n_samples=2**12, sampling_rate=None):
"""
Create and/or apply an amplitude preserving fractional octave filter bank.
.. note::
This filter bank has -6 dB cut-off frequencies. For sufficient lengths
of ``'n_samples'``, the summed output of the filter bank equals the
input signal, i.e., the filter bank is amplitude preserving
(reconstructing). This is useful for analysis and synthesis
applications such as room acoustical simulations. For an energy
preserving filter bank with -3 dB cut-off frequencies see
:py:func:`~pyfar.dsp.filter.fractional_octave_bands`.
The filters have a linear phase with a delay of ``n_samples/2`` and are
windowed with a Hanning window to suppress side lobes of the finite
filters. The magnitude response of the filters is designed similar to [#]_
with two exceptions:
1. The magnitude response is designed using squared sine/cosine ramps to
obtain -6 dB at the cut-off frequencies.
2. The overlap between the filters is calculated between the center and
upper cut-off frequencies and not between the center and lower cut-off
frequencies. This enables smaller pass-bands with unity gain, which
might be advantageous for applications that apply analysis and
resynthesis.
Parameters
----------
signal : Signal, None
The Signal to be filtered. Pass ``None`` to create the filter without
applying it.
num_fractions : int, optional
Octave fraction, e.g., ``3`` for third-octave bands. The default is
``1``.
frequency_range : tuple, optional
Frequency range for fractional octave in Hz. The default is
``(63, 16000)``
overlap : float
Band overlap of the filter slopes between 0 and 1. Smaller values yield
wider pass-bands and steeper filter slopes. The default is ``1``.
slope : int, optional
Number > 0 that defines the width and steepness of the filter slopes.
Larger values yield wider pass-bands and steeper filter slopes. The
default is ``0``.
n_samples : int, optional
Length of the filter in samples. Longer filters yield more exact
filters. The default is ``2**12``.
sampling_rate : int
Sampling frequency in Hz. The default is ``None``. Only required if
``signal=None``.
Returns
-------
signal : Signal
The filtered signal. Only returned if ``sampling_rate = None``.
filter : FilterFIR
FIR Filter object. Only returned if ``signal = None``.
frequencies : np.ndarray
Center frequencies of the filters.
References
----------
.. [#] Antoni, J. (2010). Orthogonal-like fractional-octave-band filters.
J. Acous. Soc. Am., 127(2), 884–895, doi: 10.1121/1.3273888
Examples
--------
Filter and re-synthesize an impulse signal.
.. plot::
>>> import pyfar as pf
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> # generate data
>>> x = pf.signals.impulse(2**12)
>>> y, f = pf.dsp.filter.reconstructing_fractional_octave_bands(x)
>>> y_sum = pf.Signal(np.sum(y.time, 0), y.sampling_rate)
>>> # time domain plot
>>> ax = pf.plot.time_freq(y_sum, color='k', unit='ms')
>>> pf.plot.time(x, ax=ax[0], unit='ms')
>>> ax[0].set_xlim(-20, 250)
>>> ax[0].set_title("Original (blue) and reconstructed pulse (black)")
>>> # frequency domain plot
>>> pf.plot.freq(y_sum, color='k', ax=ax[1])
>>> pf.plot.freq(y, ax=ax[1])
>>> ax[1].set_title(
... "Reconstructed (black) and filtered impulse (colored)")
"""
# check input
if (signal is None and sampling_rate is None) \
or (signal is not None and sampling_rate is not None):
raise ValueError('Either signal or sampling_rate must be none.')
if overlap < 0 or overlap > 1:
raise ValueError("overlap must be between 0 and 1")
if not isinstance(slope, int) or slope < 0:
raise ValueError("slope must be a positive integer.")
# sampling frequency in Hz
sampling_rate = \
signal.sampling_rate if sampling_rate is None else sampling_rate
# number of frequency bins
n_bins = int(n_samples // 2 + 1)
# fractional octave frequencies
_, f_m, f_cut_off = pf.dsp.filter.fractional_octave_frequencies(
num_fractions, frequency_range, return_cutoff=True)
# discard fractional octaves, if the center frequency exceeds
# half the sampling rate
f_id = f_m < sampling_rate / 2
if not np.all(f_id):
warnings.warn(
"Skipping bands above the Nyquist frequency", stacklevel=2)
# DFT lines of the lower cut-off and center frequency as in
# Antoni, Eq. (14)
k_1 = np.round(n_samples * f_cut_off[0][f_id] / sampling_rate).astype(int)
k_m = np.round(n_samples * f_m[f_id] / sampling_rate).astype(int)
k_2 = np.round(n_samples * f_cut_off[1][f_id] / sampling_rate).astype(int)
# overlap in samples (symmetrical around the cut-off frequencies)
P = np.round(overlap / 2 * (k_2 - k_m)).astype(int)
# initialize array for magnitude values
g = np.ones((len(k_m), n_bins))
# calculate the magnitude responses
# (start at 1 to make the first fractional octave band as the low-pass)
for b_idx in range(1, len(k_m)):
if P[b_idx] > 0:
# calculate phi_l for Antoni, Eq. (19)
p = np.arange(-P[b_idx], P[b_idx] + 1)
# initialize phi_l in the range [-1, 1]
# (Antoni suggest to initialize this in the range of [0, 1] but
# that yields wrong results and might be an error in the original
# paper)
phi = p / P[b_idx]
# recursion if slope>0 as in Antoni, Eq. (20)
for _ in range(slope):
phi = np.sin(np.pi / 2 * phi)
# shift range to [0, 1]
phi = .5 * (phi + 1)
# apply fade out to current channel
g[b_idx - 1, k_1[b_idx] - P[b_idx]:k_1[b_idx] + P[b_idx] + 1] = \
np.cos(np.pi / 2 * phi)
# apply fade in in to next channel
g[b_idx, k_1[b_idx] - P[b_idx]:k_1[b_idx] + P[b_idx] + 1] = \
np.sin(np.pi / 2 * phi)
# set current and next channel to zero outside their range
g[b_idx - 1, k_1[b_idx] + P[b_idx]:] = 0.
g[b_idx, :k_1[b_idx] - P[b_idx]] = 0.
# Force -6 dB at the cut-off frequencies. This is not part of Antony (2010)
g = g**2
# generate linear phase
frequencies = pf.dsp.fft.rfftfreq(n_samples, sampling_rate)
group_delay = n_samples / 2 / sampling_rate
g = g.astype(complex) * np.exp(-1j * 2 * np.pi * frequencies * group_delay)
# get impulse responses
time = pf.dsp.fft.irfft(g, n_samples, sampling_rate, 'none')
# window
time *= spsignal.windows.hann(time.shape[-1])
# create filter object
filt = pf.FilterFIR(time, sampling_rate)
filt.comment = (
"Reconstructing linear phase fractional octave filter bank."
f"(num_fractions={num_fractions}, frequency_range={frequency_range}, "
f"overlap={overlap}, slope={slope})")
if signal is None:
# return the filter object
return filt, f_m[f_id]
else:
# return the filtered signal
signal_filt = filt.process(signal)
return signal_filt, f_m[f_id]