Spectrum Analyser

 

Key Features:

  • Done with JUCE framework by using filter processor duplicator (IIR filter, juce::dsp::ProcessorDuplicator)
  • Balanced log-linear spectrum, audio waveform, peak and RMS level meters
  • Volume and low-high-band pass filters (with 0.707 Q value) for simple modification test purposes
  • Mouse draggable playing position bar on audio waveform window
  • Gradual spacing increase at high frequencies on 99 bands spectrum
  • Smooth levelling applied to spectrum and peak-RMS level meters
  • Mostly fixed hard-coded GUI style for components

 

Improvement Areas:

  • General refactoring to reduce the complexity
  • Higher resolution in low frequencies might be preference
  • DSP setup structure should be improved
  • Stereo correlation might be added
  • Some tweaks for dB level measurements

 

Download Link:

 

References:

 

 

DSPSetup.h

#pragma once

#include "SpectrumComponent.h"
#include "LevelMeterComponent.h"

//==============================================================================
struct DSPProcessBase 
{
    void prepare(const juce::dsp::ProcessSpec& spec)
    {      
        jassert(spec.sampleRate > 0.0f && spec.sampleRate * 0.5f > 20000.0f);

        sampleRate = spec.sampleRate;
        *filterProcessor.state = juce::dsp::IIR::ArrayCoefficients<float>::makeLowPass(sampleRate, 20000.0f, Q);
        filterProcessor.prepare(spec);

        gainProcessor.prepare(spec);
        gainProcessor.setGainLinear(1.0f);
    }

    void process(const juce::dsp::ProcessContextReplacing<float>& context)
    {        
        juce::ScopedNoDenormals noDenormals;
           
        filterProcessor.process(context);       
        gainProcessor.process(context);       
    }

    void reset()
    {       
        filterProcessor.reset();
        gainProcessor.reset();
    }

    //==============================================================================
    enum FilterType
    {
        LowPass = 1,
        HighPass = 2,
        BandPass = 3
    };

    void updateFilterType(const int type)
    {
        switch (type)
        {
        case LowPass: 
            *filterProcessor.state = juce::dsp::IIR::ArrayCoefficients<float>::makeLowPass(sampleRate, filterCutOff, Q);
            filterType = FilterType::LowPass;
            break;
        case HighPass:
            *filterProcessor.state = juce::dsp::IIR::ArrayCoefficients<float>::makeHighPass(sampleRate, filterCutOff, Q);
            filterType = FilterType::HighPass;
            break;
        case BandPass:
            *filterProcessor.state = juce::dsp::IIR::ArrayCoefficients<float>::makeBandPass(sampleRate, filterCutOff, Q);
            filterType = FilterType::BandPass;
            break;
        default:
            break;
        }
    }

    void updateFilterValue(const float cutoffValue)
    {      
        if (cutoffValue < 1.0f) return;

        filterCutOff = cutoffValue;
        switch (filterType)
        {
        case LowPass:
            *filterProcessor.state = juce::dsp::IIR::ArrayCoefficients<float>::makeLowPass(sampleRate, filterCutOff, Q);
            break;
        case HighPass:
            *filterProcessor.state = juce::dsp::IIR::ArrayCoefficients<float>::makeHighPass(sampleRate, filterCutOff, Q);
            break;
        case BandPass:
            *filterProcessor.state = juce::dsp::IIR::ArrayCoefficients<float>::makeBandPass(sampleRate, filterCutOff, Q);
            break;
        default:
            break;
        }
    }

    void updateGain(const float volumeLevel)
    {
        gainProcessor.setGainLinear(juce::jlimit(0.0f, 1.0f, volumeLevel));
    }
    //==============================================================================     

private:
    // IIR::Filter chosen as a mono processor type and process would be in the "float" numeric domain   
    juce::dsp::ProcessorDuplicator<juce::dsp::IIR::Filter<float>, juce::dsp::IIR::Coefficients<float>> filterProcessor;
    juce::dsp::Gain<float> gainProcessor;

    FilterType filterType = FilterType::LowPass;
    const float Q = 0.707f;
    int filterCutOff = 20000;
    float sampleRate = 0.0f;
};

//==============================================================================
struct DSPAudioBlocks final : public juce::AudioSource,
    public juce::dsp::ProcessorWrapper<DSPProcessBase>
{
    DSPAudioBlocks(AudioSource& input) : inputSource(&input)
    {
    }

    void prepareToPlay(int blockSize, double sampleRate) override
    {
        inputSource->prepareToPlay(blockSize, sampleRate);       
        this->prepare({ sampleRate, (juce::uint32)blockSize, 2 });

        //==============================================================================
        // SPECTRUM SETUP
        spectrum.CalculateFFTIndices(sampleRate);
        spectrum.setSmoothingLevel(sampleRate, 0.1f);
        //==============================================================================
        
        //==============================================================================
        // LEVEL METERS SETUP 
        leftPeakMeter.setSmoothingLevel(sampleRate, 0.1f, -100.0f);
        rightPeakMeter.setSmoothingLevel(sampleRate, 0.1f, -100.0f);
        leftRMSMeter.setSmoothingLevel(sampleRate, 0.5f, -100.0f);
        rightRMSMeter.setSmoothingLevel(sampleRate, 0.5f, -100.0f);
        //==============================================================================
    }

    void releaseResources() override
    {
        inputSource->releaseResources();
    }

    void getNextAudioBlock(const juce::AudioSourceChannelInfo& bufferToFill) override
    {
        if (bufferToFill.buffer == nullptr)
        {
            jassertfalse;
            return;
        }        

        inputSource->getNextAudioBlock(bufferToFill);
        juce::dsp::AudioBlock<float> block(*bufferToFill.buffer, (size_t)bufferToFill.startSample);
      
        juce::ScopedLock audioLock(audioCallbackLock);
        this->process(juce::dsp::ProcessContextReplacing<float>(block));        

        //==============================================================================
        // FOR SPECTRUM ANALYSER
        spectrum.getNextAudioBlock(bufferToFill); // For modified data
        //==============================================================================
        
        //==============================================================================
        // FOR LEVEL METERS   
        leftPeakMeter.setLevelMeter(bufferToFill, LevelMeterComponent::Peak_Level, LevelMeterComponent::Left);
        rightPeakMeter.setLevelMeter(bufferToFill, LevelMeterComponent::Peak_Level, LevelMeterComponent::Right);
        leftRMSMeter.setLevelMeter(bufferToFill, LevelMeterComponent::RMS_Level, LevelMeterComponent::Left);
        rightRMSMeter.setLevelMeter(bufferToFill, LevelMeterComponent::RMS_Level, LevelMeterComponent::Right);
        //==============================================================================
    }

    void updateFilterType(const int filterType)
    {
        juce::ScopedLock audioLock(audioCallbackLock);
        this->processor.updateFilterType(filterType);
    }

    void updateFilterValue(const float cutoffValue)
    {
        juce::ScopedLock audioLock(audioCallbackLock);
        this->processor.updateFilterValue(cutoffValue);
    }

    void updateGain(const float volumeLevel)
    {
        juce::ScopedLock audioLock(audioCallbackLock);
        this->processor.updateGain(volumeLevel);
    }

    SpectrumComponent spectrum;
    LevelMeterComponent leftPeakMeter, rightPeakMeter, leftRMSMeter, rightRMSMeter;

private:
    juce::CriticalSection audioCallbackLock;
    juce::AudioSource* inputSource;
};

 

SpectrumComponent.h

#pragma once

#include <JuceHeader.h>

//==============================================================================
class SpectrumComponent : public juce::AudioAppComponent, private juce::Timer
{
public:
    SpectrumComponent()
        : forwardFFT(fftOrder), window(fftSize, juce::dsp::WindowingFunction<float>::blackman)
    {
        init();
        startTimerHz(25);    
    }

    ~SpectrumComponent() override
    {
        shutdownAudio();
    }

    //==============================================================================
    void init()
    {
        // Target frequencies for FFT
        targetFrequencies = 
        {
            20,  30,  50,  80, 100, 120, 140, 160, 180, 200, 225, 250, 275, 300, 330, 360,
            390, 420, 450, 480, 500, 550, 585, 620, 660, 700, 740, 780, 820, 860, 900, 950, 
            1000, 1060, 1120, 1180, 1250, 1320, 1400, 1500, 1600, 1700, 1800, 1900, 2000, 2120,
            2240, 2360, 2500, 2650, 2800, 3000, 3150, 3300, 3500, 3700, 3900, 4100, 4300, 4500, 
            4750, 5000, 5300, 5600, 5900, 6200, 6500, 6800, 7100, 7400, 7700, 8000, 8350, 8700, 
            9050, 9400, 9750, 10100, 10500, 10900, 11300, 11700, 12000, 12500, 13000, 13500, 14000, 
            14500, 15000, 15500, 16000, 16500, 17000, 17500, 18000, 18500, 19000, 19500, 20000
        };
       
        // Setting frequency data, spectrum bounds, and bar width 
        frequencyData = { FrequencyData() };
        spectrumBounds.setBounds(31, 15, 720, 390);
        barWidth = spectrumBounds.getWidth() / frequencyData.size();
    }
    
    void CalculateFFTIndices(const float sampleRate)
    {                
        for (size_t i = 0; i < frequencyData.size(); ++i)
        {
            const int fftIndex = static_cast<float>(fftSize * targetFrequencies[i] / sampleRate);
            frequencyData[i].fftIndex = fftIndex;
        }
    }

    std::array<int, 99>& getFrequencies()
    {
        return targetFrequencies;
    }
    //==============================================================================

    void prepareToPlay(int, double) override {}
    void releaseResources() override {}

    void getNextAudioBlock(const juce::AudioSourceChannelInfo& bufferToFill) override
    {
        if (bufferToFill.buffer->getNumChannels() > 0)
        {
            auto* channelData = bufferToFill.buffer->getReadPointer(0, bufferToFill.startSample);

            for (auto i = 0; i < bufferToFill.numSamples; ++i)
            {
                pushNextSampleIntoFifo(channelData[i]);
            }               
        }
    }

    void paint(juce::Graphics& g) override
    {                
    }
    
    void drawFrame(juce::Graphics& g)
    {
        g.setColour(juce::Colours::black); // Background color 
        g.fillRect(spectrumBounds);

        processSmoothing();

        // Draw the rising/falling bars with gradient colors
        for (size_t i = 0; i < frequencyData.size(); ++i)
        {      
            const float level = frequencyData[i].frequencyLevel;

            // Map the level to Y-axis (bar height)
            const float barHeight = juce::jmap<float>(level, -100.0f, 0.0f, 0.0f, spectrumBounds.getHeight());
            const float xPos = spectrumBounds.getX() + i * barWidth;

            
            // Define the gradient for the bar
            juce::ColourGradient gradient( juce::Colours::green,
                                           spectrumBounds.getBottomLeft(),
                                           juce::Colours::red,
                                           spectrumBounds.getTopLeft(),
                                           false
                                          );


            gradient.addColour(0.6, juce::Colours::yellow);

            // Set the gradient in the graphics context and draw the bar
            g.setGradientFill(gradient);
            juce::Rectangle<float> bar(xPos, spectrumBounds.getBottom() - barHeight, barWidth * 0.8f, barHeight);
            g.fillRect(bar);
        }     
    }

    void pushNextSampleIntoFifo(float sample) noexcept
    {
        // if the fifo contains enough data, render the next frame
        if (fifoIndex >= fftSize)               
        {
            if (!nextFFTBlockReady)            
            {
                std::fill(fftData.begin(), fftData.end(), 0.0f);
                std::copy(fifo.begin(), fifo.end(), fftData.begin());
                nextFFTBlockReady = true;
            }

            fifoIndex = 0;
        }

        fifo[fifoIndex++] = sample;            
    }

    void drawNextFrameOfSpectrum()
    {
        // Apply a windowing function to the data and render it
        window.multiplyWithWindowingTable(fftData.data(), fftSize);
        forwardFFT.performFrequencyOnlyForwardTransform(fftData.data());  

        for (size_t i = 0; i < frequencyData.size(); ++i)
        {
            // Normalize the FFT magnitude with guard agasint log(0)
            const auto fftIndex = frequencyData[i].fftIndex;
            const auto magnitude = std::max(fftData[fftIndex], 1.0e-9f) / static_cast<float>(fftSize);
            const auto level = std::round(juce::Decibels::gainToDecibels(magnitude));
            frequencyData[i].frequencyLevel = level;
        }
    }

    void timerCallback() override
    {
        if (nextFFTBlockReady)
        {
            drawNextFrameOfSpectrum();
            updateSmoothing();
            nextFFTBlockReady = false;           
            repaint();
        }
    }
   
    //==============================================================================
    // SMOOTHING SECTION
    void setSmoothingLevel
    (
        const double sampleRate,
        const float rampTimeMs
    )
    {
        for (size_t i = 0; i < frequencyData.size(); ++i)
        {
            frequencyData[i].smoothFrequencyLevel.reset(sampleRate, rampTimeMs / 1000.0f);
            frequencyData[i].smoothFrequencyLevel.setCurrentAndTargetValue(frequencyData[i].frequencyLevel);
        }
    }

    void updateSmoothing()
    {
        for (size_t i = 0; i < frequencyData.size(); ++i)
        {
            const float gain = 1.1f; // tuning for compensation
            frequencyData[i].smoothFrequencyLevel.setTargetValue(gain * frequencyData[i].frequencyLevel);
        }
    }

    void processSmoothing()
    {
        for (size_t i = 0; i < frequencyData.size(); ++i)
        {
            frequencyData[i].frequencyLevel = static_cast<int>(frequencyData[i].smoothFrequencyLevel.getNextValue());
        }
    }
    //==============================================================================

private:
    juce::dsp::FFT forwardFFT;                    
    juce::dsp::WindowingFunction<float> window;     

    static constexpr int fftOrder = 11;
    static constexpr int fftSize = 2048;

    std::array<float, fftSize> fifo;                    
    std::array<float, fftSize * 2> fftData;        
    int fifoIndex = 0;                              
    bool nextFFTBlockReady = false;          
     
    struct FrequencyData
    {
        int fftIndex = 0;
        int frequencyLevel = -100;
        juce::LinearSmoothedValue<float> smoothFrequencyLevel = -100.0f;
    };   
    std::array<int, 99> targetFrequencies;
    std::array<FrequencyData, 99> frequencyData;

    juce::Rectangle<float> spectrumBounds;
    float barWidth;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SpectrumComponent)
};


 

LevelMeterComponent.h

#pragma once

#include <JuceHeader.h>

class LevelMeterComponent : public juce::Component, public juce::Timer
{
public:
    LevelMeterComponent():
        gradient(), grill(), levelMeterValue(-100.0f), smoothLevelMeterValue(-100.0f)
    {
        startTimerHz(25);
    }

    ~LevelMeterComponent() = default;


    void paint(juce::Graphics& g) override
    {
        auto bounds = getLocalBounds();

        g.setColour(juce::Colours::black);
        g.fillRect(bounds);

        g.setGradientFill(gradient);

        const auto scaledY = juce::jmap(levelMeterValue, -100.0f, 3.0f, 0.0f, static_cast<float>(getHeight()));
        g.fillRect(bounds.removeFromBottom(scaledY));	
    }

    void resized() override
    {
        const auto bounds = getLocalBounds().toFloat();
        gradient = juce::ColourGradient{
            juce::Colours::green,
            bounds.getBottomLeft(),
            juce::Colours::red,
            bounds.getTopLeft(),
            false
        };
        gradient.addColour(0.5, juce::Colours::yellow);		
    }

    void timerCallback() override
    {
        repaint();
    }

    //==============================================================================
    enum MeterType
    {
        Left = 0, Right = 1, Peak_Level, RMS_Level
    };

    void setSmoothingLevel
    (
        const double sampleRate,
        const float rampLengthInSecs,
        const float bottomLevelValue
    )
    {
        smoothLevelMeterValue.reset(sampleRate, rampLengthInSecs);
        smoothLevelMeterValue.setCurrentAndTargetValue(bottomLevelValue);
    }

    void setLevelMeter
    (   const juce::AudioSourceChannelInfo& bufferToFill,
        const MeterType meterType,
        const int channel
    )
    {
        smoothLevelMeterValue.skip(bufferToFill.buffer->getNumSamples());

        float value = 0.0f;

        switch (meterType)
        {
        case Peak_Level:
            value = bufferToFill.buffer->getMagnitude(channel, (size_t)bufferToFill.startSample, bufferToFill.buffer->getNumSamples());
            break;
        case RMS_Level:
            value = bufferToFill.buffer->getRMSLevel(channel, (size_t)bufferToFill.startSample, bufferToFill.buffer->getNumSamples());
            break;
        default:
            // Level meter would not work
            break;
        }

        if (value < smoothLevelMeterValue.getCurrentValue())
        {
            smoothLevelMeterValue.setTargetValue(value);
        }
        else
        {
            smoothLevelMeterValue.setCurrentAndTargetValue(value);
        }

        levelMeterValue = juce::Decibels::gainToDecibels(smoothLevelMeterValue.getCurrentValue());
    }
    //==============================================================================

private:
    juce::ColourGradient gradient;
    juce::Image grill;

    float levelMeterValue;
    juce::LinearSmoothedValue<float> smoothLevelMeterValue;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(LevelMeterComponent)
};