Assignment 2-FilterBank: Making a Filter-Bank Spectrum Analyzer

Due in One Week (Ideally) Start of Week 6
Deliverable: A video of you interacting with your filterbank spectrum analyzer
Email a link to your video to the teaching team

In this assignment you are asked to create a spectrum analyzer, but now using Faust instead of the JUCE FFT library. This assignment introduces the following topics:

As usual, feel free to go wild with added features! However, your implementation must have the following components:

Getting Started

This project will build off of Assignment 1-FFT. Your GUI from that assignment can be reused. However, be sure to remove all references to the JUCE FFT library. You will now be generating your spectrum data using a filter-bank from the Faust Libraries.

1. Filter Banks

To analyze our input signal we can use the Faust library's mth_octave_analyzer functions from their analyzers.lib library. Note that in the Faust Libraries, a filter bank is defined as an analyzer with delay compensation. The delay compensation allows the filter bands to be added back together without phase anomalies at the crossover frequencies between bands. We are only doing display of spectral power, so we do not need delay compensation, so we use an analyzer. That said, feel free to use fi.filterbank instead and experiment with real-time constant-Q audio processing effects.

1.1 Mth-Octave Analyzer

The default Mth-Octave Analyzer in the Faust Libaries, an.mth_octave_analyzer_default(M,fTop,N), implements a constant-Q audio filter bank which splits the signal into a parallel bank of signals, one for each spectral band, with the band-splits done using 6th-order elliptic bandpass filters.

mth_octave_analyzer_default has three parameters:

Parameter N is a compile-time parameter used in Faust's recursive pattern-matching while fTop and M are free to vary at run-time, although it would be very strange to vary M and not N. In practice, one can precompile the Faust code for all values of N needed, and switch among them at run time (muting the audio during the switch, of course).

Butterworth Analyzers of Any Order

While mth_octave_analyzer_default is hardwired to use a 6th-order elliptic lowpass/highpass pair for each band split (the strongest and sharpest available in the Faust Libraries), the more general

an.mth_octave_analyzer(Order,M,fTop,N)
will use a Butterworth band-split, for any odd Order, where the Order is a compile-time constant like number-of-bands N. Special cases of Butterworth band-splits are used in Linkwitz-Riley audio crossover filters. Order 3 gives a nice and gently rolling-off set of audio bands, which can be preferred when using fi.filterbank() to process audio. For spectrum analyzers, however, the 6th-order elliptic band-splits are normally preferable, unless they are too expensive computationally for real-time performance on small CPUs.

Octave Analyzer Examples

With M=1 we get N octave bands, starting with a highpass band crossing over to the next band below at frequency ftop Hz, and band-splitting downward from there by octaves. To see the block diagram of this filter bank, say (in a shell)

faust2firefox ~/faust/examples/filtering/filterBank.dsp
and click down three levels to see the octave filter-bank architecture. (You will not use the delay compensation delayeq unless you plan to recombine the channels.)

For a simple example, consider a 2-band octave-analyzer having its single crossover frequency at 10 kHz:

process = an.mth_octave_analyzer_default(1, 10000, 2);
This gives following block-diagram:

This Faust process function splits the input signal into two bands at crossover frequency ftop = 10 kHz. In this case, only a lowpass and highpass are used (no internal bandpass sections). If we increase N to 3, then the lowpass band gets split into a new lowpass/highpass pair crossing over at 5 kHz, creating a bandpass from 5 to 10 kHz, and so forth.

The following complete Faust program can be used to check your filter-bank in Python, Octave, or MATLAB, etc.:

import("stdfaust.lib");

bBands = 3;
fTop = 10000; // Hz
bandsPerOctave = 1;
displayOffsetDB = 5;
impulse = 1-1';
analyzer = an.mth_octave_analyzer_default(bandsPerOctave, fTop, nBands);
displayScaling = par(k,nBands,*(pow(10.0,(k*displayOffsetDB)/20.0)));

process = impulse : analyzer : displayScaling;
This Faust program emits three signals containing the three band impulse-responses.
Taking an FFT of the faust2octave output, e.g., yields the following magnitude frequency-response overlay:

Third-Octave Analyzer

The third-octave filter-bank has been a basic staple for audio spectrum analysis since the early days of Bell Labs more than a century ago (implemented in analog then, of course). Setting bandsPerOctave = 3 above gives us a third-octave filter bank.
The number of bands nBands then determines how low our analysis goes in frequency.

Try implementing an.mth_octave_analyzer_default(3, 10000, 14) and listening to the different channels!
(On a Mac, faust2caqt is a convenient script for creating a Faust standalone app for that.)

1.2 si.smooth Operator

In our previous assignment we used a leaky integrator written in C++ to smooth the FFT power.
Now we will do the same using a combination of Faust library functions si.smooth() and ba.tau2pole().

si.smooth is in Faust's library signals.lib, and implements a dc-unity gain one-pole lowpass filter, which is essentially our leaky integrator. We need to provide the si.smooth() function with our pole location. To do this we call ba.tau2pole() and provide our time constant to the function. Since we want to smooth N bands we use the par integrator

par(i, N,si.smooth(ba.tau2pole(tau))
Since we prefer to smooth squared amplitude to obtain a short-term average power, we can use the par primitive: par(i, N, ^(2)).

1.3 hslider

We want to control our \(\tau\) parameter like before. Add an hslider to your Faust code.

tau = hslider("smoothTime [unit: ms]", 0, 0, 10, 0.1) * 0.001;
The "[unit: ms]" substring generates Faust meta-data in the compiler output that does nothing unless the architecture file supports it.

Implement your analyzer in Faust, compile it to C++, and add it to your JUCE project as in Assignment 1.5.

2. JUCE

It could be helpful to delete the old FFT size macros and replace them with macros that define the size of our Faust filter-bank

#define N 12 // your number-of-bands
#define M 3  // your number-of-bands-per-octave
If you want to change these at run time, it is necessary to manage a set of separate Faust compilations, one for each N (and ditto for the filter-order if you want to change that).

We also need to create new float arrays to use in our visualizer plot:

float fbData[N];
float frequencies[N];
Attach fbData to your Visualizer in your plugin constructor. In prepareToPlay calculate the center frequencies of your frequency bands. [Hint: look at the Faust implementation of an.mth_octave_analyzer]

To store the output of Faust's filter-bank we will declare a juce::AudioBuffer<float>private member. Based on our Faust code, our audio buffer member should have N channels (the number filter-bank bands) and match the number of samples of our input buffer. So we declare

juce::AudioBuffer<float> fbBuffer

in our header file, and in prepareToPlay we set our buffer size:

fbBuffer.setSize(nBands, samplesPerBlock)

Now we can allocate a double pointer, say float **faustOutputPtr, in our header, and in prepareToPlay attach the Faust audio buffer to this pointer:

faustOutputPtr = fbBuffer.getArrayOfWritePointers(); 

Use this buffer and pointer combination to call our compute function on the first channel of our input in processBlock:

fDSP->compute(buffer.getNumSamples(), buffer.getArrayOfWritePointers(), faustOutputPtr);

3. Last Steps

After calling compute we need to write our output to our analyzer plot

for (int band = 0; band < N; band++)
{
    for (int samp = 0; samp < buffer.getNumSamples(), samp++)
    {
        fbData[bin] = faustOutputPtrs[bin][samp];
        plot->update();
    }
}
Now we can update our spectrum plot every sample!
(Doing that using the FFT approach would require a hop-size of 1 sample, which would be much more expensive.)
(Also, converting the uniform FFT filter-bank to a constant-Q filter bank requires nonuniform smoothing of the FFT output bins to achieve a similar result.) Finally, port to Faust the other parameters from your previous assignment, plus any new parameters you want, and hook them up in JUCE!

Class Recordings Last Year Regarding this Assignment

  1. 2023-05-09:   Full HD (1 GB, 1920 x 1080)   |   HD (0.2 GB, 1280 x 720)