Audio Analyser

 

Key Features:

  • Done with JUCE framework by using filter processor duplicator (IIR filter, juce::dsp::ProcessorDuplicator)
  • Audio thumbnail, linear spectrum and stereo correlation windows, peak and RMS level meters
  • Volume, low pass filter, and stereo width sliders for simple modification test purpose
  • Mouse draggable playing position bar
  • Second Order Butterworth low pass filter with 0.707 Q value for a quick coefficient calculation
  • Smooth levelling applied to stereo correlation and peak-RMS level meters
  • Mostly fixed hard-coded GUI style for components

 

Improvement Areas:

  • General refactoring to reduce the complexity
  • Spectrum scaling would be improved (to logarithmic)
  • DSP setup structure should be improved
  • Smooth levelling should be applied to spectrum section

 

Download Link:

  • AudioAnalyserTest.zip  (zipped)
  • Only DSP, stereo correlation, spectrum, and level meters code published here to keep simple

 

References:

 

 DSPSetup.h

#pragma once

#include "SpectrumComponent.h"
#include "LevelMeterComponent.h"
#include "StereoCorrelationComponent.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 = secondOrderButterworthCoefficients(spec.sampleRate, 20000.0f);
        filterProcessor.prepare(spec);

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

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

    void reset()
    {       
        filterProcessor.reset();
        gainProcessor.reset();
        stereoWidth = 1.0f;
    }
    
    //==============================================================================
    void updateLowPass(const float cutoffValue)
    {      
        if (cutoffValue < 1.0f) return;

        *filterProcessor.state = secondOrderButterworthCoefficients(sampleRate, cutoffValue);
    }

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

    void updateStereoWidth(const float widthValue)
    {    
        stereoWidth = widthValue;
    }

    void processStereoWidth(const juce::dsp::ProcessContextReplacing<float>& context)
    {        
        auto& block = context.getOutputBlock(); // mutable access !!!

        if (block.getNumChannels() < 2) return;

        auto* leftChannel = block.getChannelPointer(0);
        auto* rightChannel = block.getChannelPointer(1);
        auto numSamples = block.getNumSamples();

        for (size_t sample = 0; sample < numSamples; ++sample)
        {
            const float mid = (leftChannel[sample] + rightChannel[sample]) * 0.5f;
            const float side = (leftChannel[sample] - rightChannel[sample]) * 0.5f * stereoWidth;

            leftChannel[sample] = mid + side;
            rightChannel[sample] = mid - side;
        }
    }
    //==============================================================================
      
    //==============================================================================
    // FOR LOW PASS COEFFICIENTS
    static std::array<float, 6> secondOrderButterworthCoefficients(const float sampleRate, const float frequency)
    {            
        // where pi = 3.14159f and Q = 0.70711f

        const float n = 1 / std::tan(3.14159f * frequency / sampleRate);
        const float nSquared = n * n;
        const float invQ = 1 / 0.70711f;
        const float c = 1 / (1 + invQ * n + nSquared); // scaling factor

        // { b0, b1, b2, a0, a1, a2 } 

        return { c, 2 * c, c, 1, 2 * c * (1 - nSquared), c * (1 - invQ * n + nSquared) };
    }
    //==============================================================================

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;

    float sampleRate = 0.0;
    float stereoWidth = 1.0f; // wider if > 1, narrow if < 1 - mono is 0
};

//==============================================================================
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.CalculateFrequencyBins(sampleRate);
        //==============================================================================
        
        //==============================================================================
        // 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);
        //==============================================================================

        //==============================================================================
        // STEREO CORRELATION METER SETUP
        stereoCorrelation.setSmoothingCorrelation(sampleRate, 0.3f, 1.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);
        //==============================================================================

        //==============================================================================
        // FOR STEREO CORRELATION           
        stereoCorrelation.calculateStereoCorrelation(*bufferToFill.buffer);
        //==============================================================================

    }

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

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

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

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

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

StereoCorrelationComponent.h

#pragma once

#include <JuceHeader.h>
#include <juce_graphics/juce_graphics.h>

class StereoCorrelationComponent : public juce::Component, public juce::Timer
{
public:
    StereoCorrelationComponent() :
        gradient(), grill(), stereoCorrelationValue(0.0f), smoothCorreleationValue()
    {
        startTimerHz(25);
    }

    ~StereoCorrelationComponent() = default;


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

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

        const auto scaledX = juce::jmap(stereoCorrelationValue, -1.0f, 1.0f, 0.0f, static_cast<float>(getWidth()));

        // Calculate the center position of the stick
        const int stickWidth = 6; 
        const int stickX = static_cast<int>(scaledX - stickWidth / 2.0f);

        // Calculate the stick color using linear interpolation
        juce::Colour stickColor;
        if (stereoCorrelationValue <= 0.0f)
        {
            // Blend from red (-1) to yellow (0)
            stickColor = juce::Colours::red.interpolatedWith(juce::Colours::yellow, (stereoCorrelationValue + 1.0f) / 1.0f);
        }
        else
        {
            // Blend from yellow (0) to green (+1)
            stickColor = juce::Colours::yellow.interpolatedWith(juce::Colours::green, stereoCorrelationValue / 1.0f);
        }

        g.setColour(stickColor);
        g.fillRect(juce::Rectangle<int>(stickWidth, getHeight()).withCentre({ stickX, getHeight() / 2 }));
    }

    void paintOverChildren(::juce::Graphics& g) override
    {
        g.drawImage(grill, getLocalBounds().toFloat());
    }

    void timerCallback() override
    {
        repaint();
    }

    //==============================================================================
    void setSmoothingCorrelation
    (
        const double sampleRate,
        const float rampLengthInSecs,
        const float bottomLevelValue
    )
    {
        smoothCorreleationValue.reset(sampleRate, rampLengthInSecs);
        smoothCorreleationValue.setCurrentAndTargetValue(bottomLevelValue);
    }

    void calculateStereoCorrelation(const juce::AudioBuffer<float>& buffer)
    {
        smoothCorreleationValue.skip(buffer.getNumSamples());

        const int numSamples = buffer.getNumSamples();
        const int numChannels = buffer.getNumChannels();
        
        if (numChannels < 2 || numSamples == 0)
        {
            // Ensure the buffer has at least two channels for stereo correlation
            // Return 0 if there's not enough data to calculate
            smoothCorreleationValue.setTargetValue(0.0f); 
        }
            
        const float* leftChannel = buffer.getReadPointer(0);  // Left channel
        const float* rightChannel = buffer.getReadPointer(1); // Right channel

        float sumLR = 0.0;   // Sum of left * right
        float sumLL = 0.0;   // Sum of left * left
        float sumRR = 0.0;   // Sum of right * right

        for (int i = 0; i < numSamples; ++i)
        {
            float leftSample = leftChannel[i];
            float rightSample = rightChannel[i];

            sumLR += leftSample * rightSample; // Cross-product
            sumLL += leftSample * leftSample; // Left energy
            sumRR += rightSample * rightSample; // Right energy
        }

        // Compute the correlation coefficient
        const float denominator = std::sqrt(sumLL * sumRR); // Magnitudes
        if (denominator == 0.0f)
        {
            smoothCorreleationValue.setCurrentAndTargetValue(1.0f); // Avoid division by zero
        }
            
        const float correlation = sumLR / denominator;

        if (correlation < smoothCorreleationValue.getCurrentValue())
        {
            smoothCorreleationValue.setTargetValue(correlation);
        }
        else
        {
            smoothCorreleationValue.setCurrentAndTargetValue(correlation);
        }

        // Clamp the result to the range [-1, 1]
        stereoCorrelationValue = juce::jlimit(-1.0f, 1.0f, smoothCorreleationValue.getCurrentValue());
    }
    //==============================================================================

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

    float stereoCorrelationValue = 0.0f;
    juce::LinearSmoothedValue<float> smoothCorreleationValue;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(StereoCorrelationComponent)
};

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);  
        setVisible(true);       
    }

    ~SpectrumComponent() override
    {
        shutdownAudio();
    }

    //==============================================================================
    void init()
    {
        spectrumBounds.setBounds(425, 250, 325, 170);

        // Bar width calculation
        barWidth = spectrumBounds.getWidth() / frequencyBins.size();
    }
    //==============================================================================

    void CalculateFrequencyBins(const float sampleRate)
    {                
        // frequencyBin = (fftSize * freq) / sampleRate
        const float maxFrequency = 20000.0f;

        const int size = static_cast<int>(frequencyBins.size());
        for (int i = 0; i < size; ++i)
        {
            // targeting frequency bins over fft size in order
            int frequencyBin = std::round((fftSize * i * maxFrequency) / (sampleRate * size));
            frequencyBins[i] = frequencyBin;
        }
    }

    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);

        // Draw the rising/falling bars with gradient colors
        const int size = static_cast<int>(frequencyLevels.size());
        for (int i = 0; i < size; ++i)
        {
            const float amplitude = frequencyLevels[i];   

            // Map the amplitude to Y-axis (bar height)
            const float barHeight = juce::jmap<float>(amplitude, 0.0f, 100.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
        window.multiplyWithWindowingTable(fftData.data(), fftSize);

        // Render the FFT data
        forwardFFT.performFrequencyOnlyForwardTransform(fftData.data());  

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

    void timerCallback() override
    {
        if (nextFFTBlockReady)
        {
            drawNextFrameOfSpectrum();
            nextFFTBlockReady = false;
            repaint();
        }
    }

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

    static constexpr int fftOrder = 10;
    static constexpr int fftSize = 1024;

    std::array<float, fftSize> fifo;                    
    std::array<float, fftSize * 2> fftData;        
    int fifoIndex = 0;                              
    bool nextFFTBlockReady = false;          

    juce::Rectangle<float> spectrumBounds;
    std::array<int, 256> frequencyBins;
    std::array<int, 256> frequencyLevels = { 0 };
    float barWidth;
         
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SpectrumComponent)
};

LevelMeterComponent.h

#pragma once

#include <JuceHeader.h>
#include <juce_graphics/juce_graphics.h>

class LevelMeterComponent : public juce::Component, public juce::Timer
{
public:
    LevelMeterComponent():
        gradient(), grill(), levelMeterValue(-100.0f), smoothLevelMeterValue()
    {
        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, -80.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 paintOverChildren(::juce::Graphics& g) override
    {
        g.drawImage(grill, getLocalBounds().toFloat());
    }

    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 = -100.0f;
    juce::LinearSmoothedValue<float> smoothLevelMeterValue;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(LevelMeterComponent)
};