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:
- JUCE Tutorial: Visualise the frequencies of a signal in real time
- Stereo Width Control
- Drawing Level Meters
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) };