Sprint 4: Add AudioAnalyser — LUFS, FFT bands, stereo width, centroid, true peak
Browse filesRuns in processBlock (audio thread), stores results in atomics.
MCP get_analysis reads atomics from HTTP thread — zero contention.
Output format: "-14.2 LUFS | TP:-1.1 | sub:ok bass:+3 low:ok mid:ok hi:-2 brill:ok | W:0.72 | bright"
Max ~60 tokens per call."
- Source/Plugin/AudioAnalyser.h +259 -0
Source/Plugin/AudioAnalyser.h
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#pragma once
|
| 2 |
+
|
| 3 |
+
#include <juce_audio_basics/juce_audio_basics.h>
|
| 4 |
+
#include <juce_dsp/juce_dsp.h>
|
| 5 |
+
#include <atomic>
|
| 6 |
+
#include <array>
|
| 7 |
+
#include <cmath>
|
| 8 |
+
#include <string>
|
| 9 |
+
|
| 10 |
+
// Real-time audio analyser — runs in processBlock, results read from MCP thread.
|
| 11 |
+
// All inter-thread communication via std::atomic — no locks in audio path.
|
| 12 |
+
|
| 13 |
+
class AudioAnalyser
|
| 14 |
+
{
|
| 15 |
+
public:
|
| 16 |
+
AudioAnalyser() = default;
|
| 17 |
+
|
| 18 |
+
void prepareToPlay(double sampleRate, int blockSize)
|
| 19 |
+
{
|
| 20 |
+
currentSampleRate = sampleRate;
|
| 21 |
+
currentBlockSize = blockSize;
|
| 22 |
+
|
| 23 |
+
// FFT setup (1024-point)
|
| 24 |
+
fftBuffer.fill(0.0f);
|
| 25 |
+
fftWritePos = 0;
|
| 26 |
+
|
| 27 |
+
// LUFS — simplified momentary loudness (400ms window)
|
| 28 |
+
lufsWindowSamples = (int)(sampleRate * 0.4);
|
| 29 |
+
lufsAccumulator = 0.0;
|
| 30 |
+
lufsSampleCount = 0;
|
| 31 |
+
|
| 32 |
+
// Reset all outputs
|
| 33 |
+
momentaryLUFS.store(-100.0f);
|
| 34 |
+
truePeak.store(-100.0f);
|
| 35 |
+
stereoWidth.store(0.0f);
|
| 36 |
+
spectralCentroid.store(0.0f);
|
| 37 |
+
isSilent.store(true);
|
| 38 |
+
|
| 39 |
+
for (auto& b : bands)
|
| 40 |
+
b.store(0.0f);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// Called from audio thread — MUST be lock-free
|
| 44 |
+
void processBlock(const juce::AudioBuffer<float>& buffer)
|
| 45 |
+
{
|
| 46 |
+
const int numSamples = buffer.getNumSamples();
|
| 47 |
+
const int numChannels = buffer.getNumChannels();
|
| 48 |
+
if (numSamples == 0 || numChannels == 0) return;
|
| 49 |
+
|
| 50 |
+
const float* left = buffer.getReadPointer(0);
|
| 51 |
+
const float* right = numChannels > 1 ? buffer.getReadPointer(1) : left;
|
| 52 |
+
|
| 53 |
+
// --- True Peak ---
|
| 54 |
+
float peak = 0.0f;
|
| 55 |
+
for (int i = 0; i < numSamples; ++i)
|
| 56 |
+
{
|
| 57 |
+
float absL = std::fabs(left[i]);
|
| 58 |
+
float absR = std::fabs(right[i]);
|
| 59 |
+
peak = std::max(peak, std::max(absL, absR));
|
| 60 |
+
}
|
| 61 |
+
float peakDb = peak > 0.0f ? 20.0f * std::log10(peak) : -100.0f;
|
| 62 |
+
truePeak.store(peakDb);
|
| 63 |
+
|
| 64 |
+
// --- Silence Gate ---
|
| 65 |
+
if (peakDb < -60.0f)
|
| 66 |
+
{
|
| 67 |
+
isSilent.store(true);
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
isSilent.store(false);
|
| 71 |
+
|
| 72 |
+
// --- LUFS (simplified momentary — sum of squares over 400ms) ---
|
| 73 |
+
for (int i = 0; i < numSamples; ++i)
|
| 74 |
+
{
|
| 75 |
+
float mono = (left[i] + right[i]) * 0.5f;
|
| 76 |
+
lufsAccumulator += (double)(mono * mono);
|
| 77 |
+
lufsSampleCount++;
|
| 78 |
+
|
| 79 |
+
if (lufsSampleCount >= lufsWindowSamples)
|
| 80 |
+
{
|
| 81 |
+
double meanSquare = lufsAccumulator / (double)lufsSampleCount;
|
| 82 |
+
float lufs = (meanSquare > 0.0) ? (float)(-0.691 + 10.0 * std::log10(meanSquare)) : -100.0f;
|
| 83 |
+
momentaryLUFS.store(lufs);
|
| 84 |
+
lufsAccumulator = 0.0;
|
| 85 |
+
lufsSampleCount = 0;
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// --- Stereo Width (correlation-based) ---
|
| 90 |
+
float sumMid = 0.0f, sumSide = 0.0f;
|
| 91 |
+
for (int i = 0; i < numSamples; ++i)
|
| 92 |
+
{
|
| 93 |
+
float mid = (left[i] + right[i]) * 0.5f;
|
| 94 |
+
float side = (left[i] - right[i]) * 0.5f;
|
| 95 |
+
sumMid += mid * mid;
|
| 96 |
+
sumSide += side * side;
|
| 97 |
+
}
|
| 98 |
+
float totalEnergy = sumMid + sumSide;
|
| 99 |
+
float width = (totalEnergy > 1e-10f) ? sumSide / totalEnergy : 0.0f;
|
| 100 |
+
// Smooth
|
| 101 |
+
float prevWidth = stereoWidth.load();
|
| 102 |
+
stereoWidth.store(prevWidth * 0.9f + width * 0.1f);
|
| 103 |
+
|
| 104 |
+
// --- FFT accumulator (ring buffer → run FFT when full) ---
|
| 105 |
+
for (int i = 0; i < numSamples; ++i)
|
| 106 |
+
{
|
| 107 |
+
float mono = (left[i] + right[i]) * 0.5f;
|
| 108 |
+
fftBuffer[fftWritePos] = mono;
|
| 109 |
+
fftWritePos++;
|
| 110 |
+
|
| 111 |
+
if (fftWritePos >= kFFTSize)
|
| 112 |
+
{
|
| 113 |
+
runFFT();
|
| 114 |
+
fftWritePos = 0;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Called from HTTP/MCP thread — thread-safe read of atomic values
|
| 120 |
+
std::string getCompactAnalysis() const
|
| 121 |
+
{
|
| 122 |
+
if (isSilent.load())
|
| 123 |
+
return "silent";
|
| 124 |
+
|
| 125 |
+
char buf[256];
|
| 126 |
+
float lufs = momentaryLUFS.load();
|
| 127 |
+
float tp = truePeak.load();
|
| 128 |
+
float width = stereoWidth.load();
|
| 129 |
+
float centroid = spectralCentroid.load();
|
| 130 |
+
|
| 131 |
+
// Band deviations from flat (0 = flat reference)
|
| 132 |
+
// Only report bands that deviate > ±2dB
|
| 133 |
+
std::string bandStr;
|
| 134 |
+
static const char* bandNames[] = {"sub", "bass", "low", "mid", "hi", "pres", "brill"};
|
| 135 |
+
for (int i = 0; i < 7; ++i)
|
| 136 |
+
{
|
| 137 |
+
float db = bands[i].load();
|
| 138 |
+
if (db > 2.0f)
|
| 139 |
+
bandStr += std::string(bandNames[i]) + ":+" + std::to_string((int)db) + " ";
|
| 140 |
+
else if (db < -2.0f)
|
| 141 |
+
bandStr += std::string(bandNames[i]) + ":" + std::to_string((int)db) + " ";
|
| 142 |
+
}
|
| 143 |
+
if (bandStr.empty())
|
| 144 |
+
bandStr = "flat ";
|
| 145 |
+
|
| 146 |
+
// Brightness descriptor from centroid
|
| 147 |
+
const char* brightnessDesc = "balanced";
|
| 148 |
+
if (centroid > 4000.0f) brightnessDesc = "bright";
|
| 149 |
+
else if (centroid < 1500.0f) brightnessDesc = "dark";
|
| 150 |
+
|
| 151 |
+
snprintf(buf, sizeof(buf), "%.1f LUFS | TP:%.1f | %s| W:%.2f | %s",
|
| 152 |
+
lufs, tp, bandStr.c_str(), width, brightnessDesc);
|
| 153 |
+
|
| 154 |
+
return std::string(buf);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
private:
|
| 158 |
+
static constexpr int kFFTOrder = 10;
|
| 159 |
+
static constexpr int kFFTSize = 1 << kFFTOrder; // 1024
|
| 160 |
+
|
| 161 |
+
void runFFT()
|
| 162 |
+
{
|
| 163 |
+
// Apply window
|
| 164 |
+
std::array<float, kFFTSize> windowed;
|
| 165 |
+
for (int i = 0; i < kFFTSize; ++i)
|
| 166 |
+
windowed[i] = fftBuffer[i] * hanningWindow(i, kFFTSize);
|
| 167 |
+
|
| 168 |
+
// JUCE FFT requires 2x buffer (real + imaginary interleaved)
|
| 169 |
+
std::array<float, kFFTSize * 2> fftData{};
|
| 170 |
+
for (int i = 0; i < kFFTSize; ++i)
|
| 171 |
+
fftData[i] = windowed[i];
|
| 172 |
+
|
| 173 |
+
juce::dsp::FFT fft(kFFTOrder);
|
| 174 |
+
fft.performFrequencyOnlyForwardTransform(fftData.data());
|
| 175 |
+
|
| 176 |
+
// Now fftData[0..kFFTSize/2] contains magnitudes
|
| 177 |
+
int halfSize = kFFTSize / 2;
|
| 178 |
+
float binWidth = (float)currentSampleRate / (float)kFFTSize;
|
| 179 |
+
|
| 180 |
+
// --- 7 Band Energy ---
|
| 181 |
+
// sub: 20-60, bass: 60-250, low: 250-500, mid: 500-2k, hi: 2k-6k, pres: 6k-12k, brill: 12k-20k
|
| 182 |
+
static const float bandEdges[] = {20, 60, 250, 500, 2000, 6000, 12000, 20000};
|
| 183 |
+
|
| 184 |
+
float bandEnergy[7] = {};
|
| 185 |
+
int bandCount[7] = {};
|
| 186 |
+
|
| 187 |
+
for (int bin = 1; bin < halfSize; ++bin)
|
| 188 |
+
{
|
| 189 |
+
float freq = bin * binWidth;
|
| 190 |
+
float mag = fftData[bin];
|
| 191 |
+
|
| 192 |
+
for (int b = 0; b < 7; ++b)
|
| 193 |
+
{
|
| 194 |
+
if (freq >= bandEdges[b] && freq < bandEdges[b + 1])
|
| 195 |
+
{
|
| 196 |
+
bandEnergy[b] += mag * mag;
|
| 197 |
+
bandCount[b]++;
|
| 198 |
+
break;
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Convert to dB relative to average
|
| 204 |
+
float totalEnergy = 0.0f;
|
| 205 |
+
for (int b = 0; b < 7; ++b)
|
| 206 |
+
totalEnergy += bandEnergy[b];
|
| 207 |
+
|
| 208 |
+
float avgEnergy = totalEnergy / 7.0f;
|
| 209 |
+
|
| 210 |
+
for (int b = 0; b < 7; ++b)
|
| 211 |
+
{
|
| 212 |
+
float bandDb = 0.0f;
|
| 213 |
+
if (avgEnergy > 1e-10f && bandEnergy[b] > 1e-10f)
|
| 214 |
+
bandDb = 10.0f * std::log10(bandEnergy[b] / avgEnergy);
|
| 215 |
+
bands[b].store(bandDb);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// --- Spectral Centroid ---
|
| 219 |
+
float weightedSum = 0.0f;
|
| 220 |
+
float magnitudeSum = 0.0f;
|
| 221 |
+
for (int bin = 1; bin < halfSize; ++bin)
|
| 222 |
+
{
|
| 223 |
+
float freq = bin * binWidth;
|
| 224 |
+
float mag = fftData[bin];
|
| 225 |
+
weightedSum += freq * mag;
|
| 226 |
+
magnitudeSum += mag;
|
| 227 |
+
}
|
| 228 |
+
float centroid = (magnitudeSum > 1e-10f) ? weightedSum / magnitudeSum : 0.0f;
|
| 229 |
+
// Smooth
|
| 230 |
+
float prevCentroid = spectralCentroid.load();
|
| 231 |
+
spectralCentroid.store(prevCentroid * 0.8f + centroid * 0.2f);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
static float hanningWindow(int i, int size)
|
| 235 |
+
{
|
| 236 |
+
return 0.5f * (1.0f - std::cos(2.0f * 3.14159265358979f * i / (float)(size - 1)));
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// State
|
| 240 |
+
double currentSampleRate = 44100.0;
|
| 241 |
+
int currentBlockSize = 512;
|
| 242 |
+
|
| 243 |
+
// FFT ring buffer
|
| 244 |
+
std::array<float, kFFTSize> fftBuffer{};
|
| 245 |
+
int fftWritePos = 0;
|
| 246 |
+
|
| 247 |
+
// LUFS accumulator
|
| 248 |
+
int lufsWindowSamples = 17640; // 400ms at 44.1kHz
|
| 249 |
+
double lufsAccumulator = 0.0;
|
| 250 |
+
int lufsSampleCount = 0;
|
| 251 |
+
|
| 252 |
+
// Thread-safe outputs (written in audio thread, read from HTTP thread)
|
| 253 |
+
std::atomic<float> momentaryLUFS{-100.0f};
|
| 254 |
+
std::atomic<float> truePeak{-100.0f};
|
| 255 |
+
std::atomic<float> stereoWidth{0.0f};
|
| 256 |
+
std::atomic<float> spectralCentroid{0.0f};
|
| 257 |
+
std::atomic<bool> isSilent{true};
|
| 258 |
+
std::array<std::atomic<float>, 7> bands{}; // sub, bass, low, mid, hi, pres, brill
|
| 259 |
+
};
|