DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
SpectrumAnalyzer.h
Go to the documentation of this file.
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
48#include "../Core/DspMath.h"
49#include "../Core/FFT.h"
50#include "../Core/WindowFunctions.h"
51
52#include <algorithm>
53#include <array>
54#include <atomic>
55#include <cassert>
56#include <cmath>
57#include <cstddef>
58#include <memory>
59#include <vector>
60
61namespace dspark {
62
74template <FloatType T>
76{
77public:
79 enum class WindowType
80 {
81 Hann,
82 Hamming,
83 Blackman,
85 FlatTop,
87 };
88
103 void prepare(double sampleRate, int fftSize = 2048, WindowType windowType = WindowType::Hann)
104 {
105 assert(fftSize >= 256 && fftSize <= 16384 && (fftSize & (fftSize - 1)) == 0);
106
107 if (!std::isfinite(sampleRate) || sampleRate <= 0.0)
108 return;
109
110 // Release-safe size sanitation (the assert only guards debug builds;
111 // an unvalidated size would make FFTReal throw at stream time).
112 fftSize = std::clamp(fftSize, 256, 16384);
113 int pow2 = 256;
114 while (pow2 < fftSize) pow2 <<= 1;
115 fftSize = pow2;
116
117 fft_.reset(); // gate OFF: pushSamples() is a no-op while rebuilding
118
119 sampleRate_ = sampleRate;
120 fftSize_ = fftSize;
121 numBins_ = fftSize / 2 + 1;
122 hopSize_ = fftSize / 2; // 50% overlap
123 windowType_ = windowType;
124
125 window_.resize(static_cast<size_t>(fftSize));
126 generateWindow(windowType);
127
128 windowGain_ = WindowFunctions<T>::coherentGain(window_.data(), fftSize);
129 if (windowGain_ < T(0.001)) windowGain_ = T(1);
130 invGain_ = T(2) / (static_cast<T>(fftSize_) * windowGain_);
131
132 inputRing_.assign(static_cast<size_t>(fftSize), T(0));
133 fftBuffer_.resize(static_cast<size_t>(fftSize));
134 freqBuffer_.resize(static_cast<size_t>(fftSize + 2));
135
136 // Unified DSP State
137 const T floorDb = floorDb_.load(std::memory_order_relaxed);
138 magnitudesState_.assign(static_cast<size_t>(numBins_), T(0));
139 peakState_.assign(static_cast<size_t>(numBins_), floorDb);
140
141 // Triple buffer for tear-free cross-thread reading
142 for (auto& slot : outSlots_)
143 {
144 slot.magnitudesDb.assign(static_cast<size_t>(numBins_), floorDb);
145 slot.peakDb.assign(static_cast<size_t>(numBins_), floorDb);
146 }
147 writeSlot_ = 0;
148 readSlot_ = 1;
149 pendingSlot_.store(2, std::memory_order_relaxed);
150
151 ringWritePos_ = 0;
152 ringMask_ = fftSize_ - 1; // power of two guaranteed by the sanitation
153 samplesUntilFFT_ = hopSize_;
154 newDataReady_.store(false, std::memory_order_relaxed);
155
156 fft_ = std::make_unique<FFTReal<T>>(static_cast<size_t>(fftSize)); // gate ON, last
157 }
158
160 void reset() noexcept
161 {
162 const T floorDb = floorDb_.load(std::memory_order_relaxed);
163 std::fill(inputRing_.begin(), inputRing_.end(), T(0));
164 std::fill(magnitudesState_.begin(), magnitudesState_.end(), T(0));
165 std::fill(peakState_.begin(), peakState_.end(), floorDb);
166
167 for (auto& slot : outSlots_)
168 {
169 std::fill(slot.magnitudesDb.begin(), slot.magnitudesDb.end(), floorDb);
170 std::fill(slot.peakDb.begin(), slot.peakDb.end(), floorDb);
171 }
172
173 ringWritePos_ = 0;
174 samplesUntilFFT_ = hopSize_;
175 newDataReady_.store(false, std::memory_order_relaxed);
176 }
177
184 void setSmoothing(T factor) noexcept
185 {
186 if (!std::isfinite(factor)) return;
187 smoothing_.store(std::clamp(factor, T(0), T(0.99)), std::memory_order_relaxed);
188 }
189
195 void setPeakDecay(T decayDbPerSecond) noexcept
196 {
197 if (!std::isfinite(decayDbPerSecond)) return;
198 peakDecayRate_.store(std::max(T(0), decayDbPerSecond), std::memory_order_relaxed);
199 }
200
202 void setPeakHoldEnabled(bool enabled) noexcept
203 {
204 peakHoldEnabled_.store(enabled, std::memory_order_relaxed);
205 }
206
212 void setFloorDb(T floorDb) noexcept
213 {
214 if (!std::isfinite(floorDb)) return;
215 floorDb_.store(floorDb, std::memory_order_relaxed);
216 }
217
219 [[nodiscard]] T getSmoothing() const noexcept { return smoothing_.load(std::memory_order_relaxed); }
220
222 [[nodiscard]] T getPeakDecay() const noexcept { return peakDecayRate_.load(std::memory_order_relaxed); }
223
225 [[nodiscard]] bool isPeakHoldEnabled() const noexcept { return peakHoldEnabled_.load(std::memory_order_relaxed); }
226
228 [[nodiscard]] T getFloorDb() const noexcept { return floorDb_.load(std::memory_order_relaxed); }
229
231 [[nodiscard]] WindowType getWindowType() const noexcept { return windowType_; }
232
242 void pushSamples(const T* samples, int numSamples) noexcept
243 {
244 if (fft_ == nullptr || samples == nullptr || numSamples <= 0)
245 return;
246
247 for (int i = 0; i < numSamples; ++i)
248 {
249 inputRing_[static_cast<size_t>(ringWritePos_)] = samples[i];
250 ringWritePos_ = (ringWritePos_ + 1) & ringMask_; // pow2 mask, no division
251
252 if (--samplesUntilFFT_ <= 0)
253 {
254 computeSpectrum();
255 samplesUntilFFT_ = hopSize_; // Restart countdown with 50% overlap
256 }
257 }
258 }
259
264 [[nodiscard]] const T* getMagnitudesDb() const noexcept
265 {
266 acquireLatestSlot();
267 return outSlots_[static_cast<size_t>(readSlot_)].magnitudesDb.data();
268 }
269
274 [[nodiscard]] const T* getPeakHoldDb() const noexcept
275 {
276 acquireLatestSlot();
277 return outSlots_[static_cast<size_t>(readSlot_)].peakDb.data();
278 }
279
281 [[nodiscard]] bool isNewDataReady() noexcept
282 {
283 return newDataReady_.exchange(false, std::memory_order_relaxed);
284 }
285
287 [[nodiscard]] int getNumBins() const noexcept { return numBins_; }
288
290 [[nodiscard]] int getFFTSize() const noexcept { return fftSize_; }
291
293 [[nodiscard]] T binToFrequency(int bin) const noexcept
294 {
295 if (fftSize_ <= 0) return T(0);
296 return static_cast<T>(bin) * static_cast<T>(sampleRate_) / static_cast<T>(fftSize_);
297 }
298
299private:
300 void generateWindow(WindowType type)
301 {
302 switch (type)
303 {
304 case WindowType::Hamming: WindowFunctions<T>::hamming(window_.data(), fftSize_); break;
305 case WindowType::Blackman: WindowFunctions<T>::blackman(window_.data(), fftSize_); break;
306 case WindowType::BlackmanHarris: WindowFunctions<T>::blackmanHarris(window_.data(), fftSize_); break;
307 case WindowType::FlatTop: WindowFunctions<T>::flatTop(window_.data(), fftSize_); break;
308 case WindowType::Rectangular: WindowFunctions<T>::rectangular(window_.data(), fftSize_); break;
309 case WindowType::Hann:
310 default: // wild enum value: fall back to Hann (never a zeroed window)
311 WindowFunctions<T>::hann(window_.data(), fftSize_);
312 windowType_ = WindowType::Hann;
313 break;
314 }
315 }
316
317 void computeSpectrum() noexcept
318 {
319 // Parameters are published atomically; load once per frame.
320 const T smoothing = smoothing_.load(std::memory_order_relaxed);
321 const T floorDb = floorDb_.load(std::memory_order_relaxed);
322 const T decayRate = peakDecayRate_.load(std::memory_order_relaxed);
323 const bool peakHold = peakHoldEnabled_.load(std::memory_order_relaxed);
324
325 // 1. Copy & Window (pow2 mask instead of division)
326 for (int i = 0; i < fftSize_; ++i)
327 {
328 int ringIdx = (ringWritePos_ + i) & ringMask_;
329 fftBuffer_[static_cast<size_t>(i)] = inputRing_[static_cast<size_t>(ringIdx)] * window_[static_cast<size_t>(i)];
330 }
331
332 // 2. Forward FFT
333 fft_->forward(fftBuffer_.data(), freqBuffer_.data());
334
335 // 3. Magnitude Calculation & Smoothing
336 const T oneMinusSmooth = T(1) - smoothing;
337
338 for (int k = 0; k < numBins_; ++k)
339 {
340 T re = freqBuffer_[static_cast<size_t>(2 * k)];
341 T im = freqBuffer_[static_cast<size_t>(2 * k + 1)];
342 T mag = std::sqrt(re * re + im * im) * invGain_;
343
344 // DC and Nyquist carry no mirrored bin, so the single-sided 2x in
345 // invGain_ must be undone HERE, on the fresh magnitude. (Scaling
346 // the smoothed STATE compounded 0.5x every frame and parked those
347 // bins ~9.5 dB low in steady state.)
348 if (k == 0 || k == numBins_ - 1) mag *= T(0.5);
349
350 magnitudesState_[static_cast<size_t>(k)] =
351 smoothing * magnitudesState_[static_cast<size_t>(k)] + oneMinusSmooth * mag;
352 }
353
354 // 4. Time-Domain Peak Decay Calculation (Using real hopSize_)
355 const T peakDecayDb = decayRate * static_cast<T>(hopSize_) / static_cast<T>(sampleRate_);
356
357 // 5. Write into the writer-owned slot of the triple buffer
358 auto& slot = outSlots_[static_cast<size_t>(writeSlot_)];
359
360 // 6. DB Conversion and Peak Hold
361 for (int k = 0; k < numBins_; ++k)
362 {
363 T dB = gainToDecibels(magnitudesState_[static_cast<size_t>(k)], floorDb);
364 slot.magnitudesDb[static_cast<size_t>(k)] = dB;
365
366 if (peakHold)
367 {
368 // Canonical hold law: decay, but never below the live value.
369 // (The old strict-compare branch decayed a full step whenever
370 // dB == peak, so a stationary tone made the peak FLICKER one
371 // decay step below the signal every other frame.)
372 T& peak = peakState_[static_cast<size_t>(k)];
373 peak = std::max(dB, std::max(floorDb, peak - peakDecayDb));
374 slot.peakDb[static_cast<size_t>(k)] = peak;
375 }
376 }
377
378 // 7. Publish: swap the finished slot into 'pending' (with the fresh
379 // bit) and adopt whatever slot was there as the next write target.
380 // Wait-free; the reader can never observe a slot mid-write.
381 const int old = pendingSlot_.exchange(writeSlot_ | kFreshBit, std::memory_order_acq_rel);
382 writeSlot_ = old & kSlotMask;
383 newDataReady_.store(true, std::memory_order_release);
384 }
385
386 double sampleRate_ = 48000.0;
387 int fftSize_ = 0; // 0 = unprepared (getters report honestly)
388 int numBins_ = 0;
389 int hopSize_ = 1024;
390
391 std::unique_ptr<FFTReal<T>> fft_; // doubles as the "prepared" gate
392 std::vector<T> window_;
393 WindowType windowType_ = WindowType::Hann;
394 T windowGain_ = T(1);
395 T invGain_ = T(1);
396
397 std::vector<T> inputRing_;
398 int ringWritePos_ = 0;
399 int samplesUntilFFT_ = 0;
400
401 std::vector<T> fftBuffer_;
402 std::vector<T> freqBuffer_;
403
404 // Single source of truth for DSP state
405 std::vector<T> magnitudesState_;
406 std::vector<T> peakState_;
407
408 // Tear-free triple buffer: writer owns writeSlot_, the reader owns
409 // readSlot_, and pendingSlot_ (atomic, with a freshness bit) carries the
410 // hand-off. Classic wait-free GUI metering scheme.
411 static constexpr int kFreshBit = 4;
412 static constexpr int kSlotMask = 3;
413
414 struct OutSlot
415 {
416 std::vector<T> magnitudesDb;
417 std::vector<T> peakDb;
418 };
419 std::array<OutSlot, 3> outSlots_;
420 int writeSlot_ = 0; // writer-thread private
421 mutable int readSlot_ = 1; // reader-thread private
422 mutable std::atomic<int> pendingSlot_{ 2 };
423
425 void acquireLatestSlot() const noexcept
426 {
427 int expected = pendingSlot_.load(std::memory_order_acquire);
428 while (expected & kFreshBit)
429 {
430 if (pendingSlot_.compare_exchange_weak(expected, readSlot_,
431 std::memory_order_acq_rel,
432 std::memory_order_acquire))
433 {
434 readSlot_ = expected & kSlotMask;
435 return;
436 }
437 }
438 }
439
440 int ringMask_ = 2047;
441
442 std::atomic<T> smoothing_ { T(0.8) };
443 std::atomic<T> peakDecayRate_ { T(10) };
444 std::atomic<T> floorDb_ { T(-100) };
445 std::atomic<bool> peakHoldEnabled_ { false };
446
447 std::atomic<bool> newDataReady_{ false };
448};
449
450} // namespace dspark
Real-time FFT spectrum analyser with per-bin smoothing and peak hold.
void setFloorDb(T floorDb) noexcept
Sets the readout floor in decibels.
T getFloorDb() const noexcept
Returns the readout floor in decibels.
void prepare(double sampleRate, int fftSize=2048, WindowType windowType=WindowType::Hann)
Prepares the analyser and allocates all necessary buffers.
int getFFTSize() const noexcept
FFT size in samples; 0 before prepare().
void setPeakHoldEnabled(bool enabled) noexcept
Enables or disables peak-hold tracking.
WindowType getWindowType() const noexcept
Returns the window type in use (set at prepare time).
int getNumBins() const noexcept
Number of spectrum bins (fftSize/2 + 1); 0 before prepare().
T getPeakDecay() const noexcept
Returns the peak-hold decay rate in dB per second.
T getSmoothing() const noexcept
Returns the per-frame magnitude smoothing factor.
void pushSamples(const T *samples, int numSamples) noexcept
Pushes audio samples into the analyser's internal ring buffer.
bool isPeakHoldEnabled() const noexcept
Returns true if peak-hold tracking is enabled.
void setPeakDecay(T decayDbPerSecond) noexcept
Sets the peak-hold decay rate.
const T * getPeakHoldDb() const noexcept
Returns the peak-hold spectrum in decibels.
void reset() noexcept
Resets all internal buffers and state to zero/floor values.
void setSmoothing(T factor) noexcept
Sets the per-frame magnitude smoothing factor.
const T * getMagnitudesDb() const noexcept
Returns the current magnitude spectrum in decibels.
T binToFrequency(int bin) const noexcept
Centre frequency of the given bin in Hz (0 before prepare()).
WindowType
Available window types for the FFT analysis.
@ Hann
Default. Good general-purpose choice.
@ Hamming
Slightly better side lobe rejection.
@ Rectangular
No windowing (transient analysis).
@ BlackmanHarris
Highest side lobe rejection.
@ FlatTop
Amplitude-accurate measurement.
bool isNewDataReady() noexcept
Consumes and returns the new data flag. True if updated since last call.
Main namespace for the DSPark framework.
T gainToDecibels(T gain, T minusInfinityDb=T(-100)) noexcept
Converts a linear gain value to decibels.
Definition DspMath.h:78
static void hann(T *output, int size, bool periodic=true) noexcept
Hann (raised cosine) window.
static void blackman(T *output, int size, bool periodic=true) noexcept
Blackman window.
static void blackmanHarris(T *output, int size, bool periodic=true) noexcept
Blackman-Harris window (4-term, -92 dB side lobes).
static void hamming(T *output, int size, bool periodic=true) noexcept
Hamming window.
static T coherentGain(const T *window, int size) noexcept
Computes the coherent gain of a window (mean of its samples).
static void flatTop(T *output, int size, bool periodic=true) noexcept
Flat-top window (amplitude-accurate: scallop loss < 0.01 dB).
static void rectangular(T *output, int size) noexcept
Rectangular window (no windowing – all ones).