1
$\begingroup$

Overall aim: to take a short signal (likely to be noisy with some tonal components) and filter it into third-octave bands (TOBs), apply frequency-dependent spectral adjustments, then recombine the signal, apply some other time-domain processing, then refilter again back into TOBs. Phase and amplitude to be retained as faithfully as possible throughout the process.

My approach: using Python language, generate TOB Butterworth IIR filter and apply forwards-backwards to ensure zero-phase. The cutoff frequencies for each filter band are adjusted to ensure the two-way application results in the same cutoff (3dB down) frequencies as the use of a one-way equivalent filter. The filtered signals would, in my application, be adjusted with band-dependent factors, however I've omitted this stage from my example, so I can see what the effects of the filtering+recombination process are. The bandpass filtered signals are then summed over the frequencies into a 'recombined' signal. They would then also be processed in some other ways, again omitted from my example for simplicity. Finally the recombined signal is again filtered using the same TOB two-way process, and the RMS amplitudes calculated for the total and TOB components of the signals at each stage.

Problem/question: I have noticed that the RMS amplitudes of my filtered+recombined signals are slightly higher than the signal entering the filter. I have tested two different types of complex signal as inputs: i) pink noise, and ii) a sum of sinusoids (with randomised phase shifts) at each TOB mid-frequency. The pink noise signal seems to get an increase of roughly ~1.2-1.4 dB for each application of the filtering+recombination process, while the sinusoid sum signal gets a smaller increase of ~0.3-0.6 dB. Why is the process adding energy into the signals? For most filtering processes, you might expect a small loss in total energy, but here I observe an increase.

Additional notes: Due to the random numbers used to generate the signals, the script produces slightly different outputs with every run. The factor used to adjust the cutoff frequencies for the forwards-backwards filter has been derived by experimentation - the appropriate factor to use has been found to be dependent on the ratio between the filter order and the sampling frequency, as well as the type of Butterworth design (bandpass/highpass/lowpass) - here it has been set according to the desired forwards-backwards filter order of 8 (ie the one-way filter order*2). The method of padding the filter has been applied on the basis of this article: Boore, DM, 2005. Previous investigation showed that using the default pad behaviour also showed an increase in the energy, so this parameter does not seem to be the main cause. I'm aware that my example signals are quite short (1 second) for the frequency range I'm interested in (5-630 Hz), but this is unavoidable with the signals I have to process.

import numpy as np
from scipy.signal import (butter, sosfiltfilt, sosfreqz)
import matplotlib.pyplot as plt
from random import random

# pink noise generation
# credit to python-acoustics library by Python Acoustics https://github.com/python-acoustics/python-acoustics
N = 8192  # signal length (samples), and sampling frequency
state = np.random.RandomState()
uneven = N % 2
X = state.randn(N // 2 + 1 + uneven) + 1j * state.randn(N // 2 + 1 + uneven)
S = np.sqrt(np.arange(len(X)) + 1.)  # +1 to avoid divide by zero
pink = (np.fft.irfft(X / S)).real
if uneven:
    pink = pink[:-1]
T = N/8192  # signal duration, 1 s
t = np.linspace(0, T-(T/N), N)  # signal time vector

# define filter order
order = 4
fbw_order = order*2  # fowards-backwards filter order

# generate third-octave band frequencies
b = 3
ind = np.arange(-23, -1, 1)  # range of frequency indices
G10 = 10**(3/10)  # octave ratio coefficient (base-ten)
OctRatio = G10**(0.5/b)  # octave ratio
fm = G10**(ind/b)*1000  # output range of exact fractional mid-frequencies
f1 = fm/OctRatio  # output range of exact lower band-edge frequencies
f2 = fm*OctRatio  # output range of exact upper band-edge frequencies

# generate sine-based complex signal
sine = np.zeros(8192)
for f in fm:
    sine += (0.003/fm.size)*np.sin(2*np.pi*f*t + random()*np.pi)

# forwards-backwards filter pre-warped frequencies
f1_fbw_HP = f1/1.12
f2_fbw_LP = f2*1.11
f1_fbw_BP = f1/1.0135
f2_fbw_BP = f2*1.0135

# generate forwards-backwards HP and LP filters
sos_HP = butter(order, f1_fbw_HP[0], btype='highpass', output='sos', fs=N)
sos_LP = butter(order, f2_fbw_LP[-1], btype='lowpass', output='sos', fs=N)

# filter pink noise with LP and HP filters
padN = np.min([N-1, int(N*1.5*order/f2_fbw_LP[-1])])
psignal = sosfiltfilt(sos_LP, pink, padtype='constant', padlen=padN)
padN = np.min([N-1, int(N*1.5*order/f1_fbw_HP[0])])
psignal = sosfiltfilt(sos_HP, psignal, padtype='constant', padlen=padN)

# calculate RMS and dB re 1e-9
psignal_RMS = np.sqrt(np.mean(np.square(psignal)))
psignal_RMS_dB = 20*np.log10(np.sqrt(np.mean(np.square(psignal)))/1e-9)
ssignal_RMS = np.sqrt(np.mean(np.square(sine)))
ssignal_RMS_dB = 20*np.log10(np.sqrt(np.mean(np.square(sine)))/1e-9)

# loop to filter signals into third-octave bands
psignal_TOB_fbw = np.zeros((psignal.size, fm.size))
ssignal_TOB_fbw = np.zeros((sine.size, fm.size))
for ii, f in enumerate(fm):
    sos_fbw_BP = butter(order, [f1_fbw_BP[ii], f2_fbw_BP[ii]], btype='bandpass',
                    output='sos', fs=N)
    padN = np.min([N-1, int(N*1.5*order/f2_fbw_BP[ii])])
    psignal_TOB_fbw[:, ii] = sosfiltfilt(sos_fbw_BP, pink, padtype='constant',
                                        padlen=padN)
    ssignal_TOB_fbw[:, ii] = sosfiltfilt(sos_fbw_BP, sine, padtype='constant',
                                    padlen=padN)

# calculate TOB root-mean-square and dB re 1e-9
psignal_TOB_fbw_RMS = np.sqrt(np.mean(np.square(psignal_TOB_fbw), axis=0))
psignal_TOB_fbw_RMS_dB = 20*np.log10(psignal_TOB_fbw_RMS/1e-9)
ssignal_TOB_fbw_RMS = np.sqrt(np.mean(np.square(ssignal_TOB_fbw), axis=0))
ssignal_TOB_fbw_RMS_dB = 20*np.log10(ssignal_TOB_fbw_RMS/1e-9)

# recombine TOBs and calculate total RMS and dB re 1e-9
psignal_fbw_re = np.sum(psignal_TOB_fbw, axis=1)
psignal_fbw_RMS = np.sqrt(np.mean(np.square(psignal_fbw_re)))
psignal_fbw_RMS_dB = 20*np.log10(psignal_fbw_RMS/1e-9)
ssignal_fbw_re = np.sum(ssignal_TOB_fbw, axis=1)
ssignal_fbw_RMS = np.sqrt(np.mean(np.square(ssignal_fbw_re)))
ssignal_fbw_RMS_dB = 20*np.log10(ssignal_fbw_RMS/1e-9)

# loop to refilter recombined signal back into TOBs
psignal_TOB_fbw2x = np.zeros((psignal_fbw_re.size, fm.size))
ssignal_TOB_fbw2x = np.zeros((ssignal_fbw_re.size, fm.size))
for ii, f in enumerate(fm):
    sos_fbw_BP = butter(order, [f1_fbw_BP[ii], f2_fbw_BP[ii]], btype='bandpass',
                    output='sos', fs=N)
    padN = np.min([N-1, int(N*1.5*order/f2_fbw_BP[ii])])
    psignal_TOB_fbw2x[:, ii] = sosfiltfilt(sos_fbw_BP, psignal_fbw_re,
                                          padtype='constant', padlen=padN)
    ssignal_TOB_fbw2x[:, ii] = sosfiltfilt(sos_fbw_BP, ssignal_fbw_re,
                                          padtype='constant', padlen=padN)

# calculate TOB root-mean-square and dB re 1e-9
psignal_TOB_fbw2x_RMS = np.sqrt(np.mean(np.square(psignal_TOB_fbw2x), axis=0))
psignal_TOB_fbw2x_RMS_dB = 20*np.log10(psignal_TOB_fbw2x_RMS/1e-9)
ssignal_TOB_fbw2x_RMS = np.sqrt(np.mean(np.square(ssignal_TOB_fbw2x), axis=0))
ssignal_TOB_fbw2x_RMS_dB = 20*np.log10(ssignal_TOB_fbw2x_RMS/1e-9)

# recombine TOBs and calculate total RMS and dB re 1e-9
psignal_fbw2x_re = np.sum(psignal_TOB_fbw2x, axis=1)
psignal_fbw2x_RMS = np.sqrt(np.mean(np.square(psignal_fbw2x_re)))
psignal_fbw2x_RMS_dB = 20*np.log10(psignal_fbw2x_RMS/1e-9)
ssignal_fbw2x_re = np.sum(ssignal_TOB_fbw2x, axis=1)
ssignal_fbw2x_RMS = np.sqrt(np.mean(np.square(ssignal_fbw2x_re)))
ssignal_fbw2x_RMS_dB = 20*np.log10(ssignal_fbw2x_RMS/1e-9)

# calculate and display level differences
print(psignal_fbw_RMS_dB - psignal_RMS_dB)
print(psignal_fbw2x_RMS_dB - psignal_fbw_RMS_dB)
print(psignal_TOB_fbw2x_RMS_dB - psignal_TOB_fbw_RMS_dB)
print(ssignal_fbw_RMS_dB - ssignal_RMS_dB)
print(ssignal_fbw2x_RMS_dB - ssignal_fbw_RMS_dB)
print(ssignal_TOB_fbw2x_RMS_dB - ssignal_TOB_fbw_RMS_dB)

$\endgroup$
0
$\begingroup$

You get increased amplitude since your filter bank is not "perfectly reconstructing". Let's say your 1 kHz bandpass has a 0dB gain at 1 kHz and you feed in 1 kHz sine wave. You will get 0 dB enegery in the 1 kHz band but since yoru filters are not infinitely steep you also get some energy in the neighboring bands as well. Hence the sum the band energies is larger than your input enegery.

In general you want to make sure that sum of all transfer functions of your filter bank is 1 at all frequencies, i.e.

$$\sum H(\omega) = 1$$

That's tricky to do with a third ocatve filter bank. You also have to let go of the assumptions that 1 kHz sine wave ONLY shows up in your 1 kHz band. There will always be spectral leakage and the key to a good filter bank is to manage the leakage so that the overall energy is maintained for all frequencies.

$\endgroup$
  • $\begingroup$ Thanks. I tried running it with a 100 Hz sine and it showed that the total amplitude increase reduced to ~0.2 dB. I can also see now that by plotting the filter FRF it's obvious that the non-zero values in the bands outside the band cutoffs will, when summed, combine to non-negligible values. This begs the question: how does one design a filterbank to compensate for the spectral leakage, if signal reconstruction is the objective? $\endgroup$ – Mike Nov 2 '20 at 21:49
  • $\begingroup$ There is a large body of work round perfect reconstruction filter banks. See for example: ccrma.stanford.edu/~jos/sasp/… $\endgroup$ – Hilmar Nov 2 '20 at 23:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.