DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
SpectrumAnalyzer.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
70#include "../Core/DspMath.h"
71#include "../Core/FFT.h"
72#include "../Core/WindowFunctions.h"
73
74#include <algorithm>
75#include <array>
76#include <atomic>
77#include <cassert>
78#include <cmath>
79#include <cstddef>
80#include <memory>
81#include <type_traits>
82#include <vector>
83
84namespace dspark {
85
99template <FloatType T>
101{
102public:
104 enum class WindowType
105 {
106 Hann,
107 Hamming,
108 Blackman,
110 FlatTop,
112 };
113
166 void prepare(double sampleRate, int fftSize = 0, WindowType windowType = WindowType::Hann)
167 {
168 assert(fftSize <= 0 ||
169 (fftSize >= 256 && fftSize <= 16384 && (fftSize & (fftSize - 1)) == 0));
170
171 if (!std::isfinite(sampleRate) || sampleRate <= 0.0)
172 return;
173
174 if (fftSize <= 0)
175 {
176 // Automatic: hold the analysis TIME SPAN constant across sample
177 // rates (the span 2048 samples cover at 48 kHz). Bin width, main
178 // lobe and frame rate are all fs/fftSize up to a constant, so a
179 // constant span pins the resolution the display shows.
180 const double target = sampleRate * (kAutoSpanRef / kAutoSpanRate);
181 int n = kAutoMinFft;
182 while (n < kAutoMaxFft && static_cast<double>(n) < target) n <<= 1;
183 fftSize = n;
184 }
185
186 // Release-safe size sanitation (the assert only guards debug builds;
187 // an unvalidated size would make FFTReal throw at stream time).
188 fftSize = std::clamp(fftSize, 256, 16384);
189 int pow2 = 256;
190 while (pow2 < fftSize) pow2 <<= 1;
191 fftSize = pow2;
192
193 fft_.reset(); // gate OFF: pushSamples() is a no-op while rebuilding
194 // (and stays OFF if the allocation phase throws below)
195
196 const int numBins = fftSize / 2 + 1;
197 const T floorDb = floorDb_.load(std::memory_order_relaxed);
198
199 // --- Allocation phase. EVERYTHING is built into locals and no
200 // member is written until every allocation has succeeded, so
201 // a bad_alloc from any of them leaves the analyser exactly as it was
202 // (minus the fft_ gate above): sizes, slots and snapshots stay
203 // mutually consistent -- never NEW sizes over OLD storage.
204 std::vector<T> window(static_cast<size_t>(fftSize));
205 const WindowType effectiveWindow = generateWindow(window.data(), fftSize, windowType);
206
207 T windowGain = WindowFunctions<T>::coherentGain(window.data(), fftSize);
208 if (windowGain < T(0.001)) windowGain = T(1);
209 const T invGain = T(2) / (static_cast<T>(fftSize) * windowGain);
210
211 std::vector<T> inputRing(static_cast<size_t>(fftSize), T(0));
212 std::vector<T> fftBuffer(static_cast<size_t>(fftSize));
213 std::vector<T> freqBuffer(static_cast<size_t>(fftSize + 2));
214
215 // Unified DSP State
216 std::vector<T> magnitudesState(static_cast<size_t>(numBins), T(0));
217 std::vector<T> peakState(static_cast<size_t>(numBins), floorDb);
218
219 // Triple buffer for tear-free cross-thread reading (relaxed-atomic
220 // words; ordering comes from the pendingSlot_ RMWs and from each
221 // slot's own seqlock counter, see below).
222 std::array<OutSlot, 3> slots;
223 for (auto& slot : slots)
224 {
225 slot.magnitudesDb = std::make_unique<std::atomic<T>[]>(static_cast<size_t>(numBins));
226 slot.peakDb = std::make_unique<std::atomic<T>[]>(static_cast<size_t>(numBins));
227 slot.seq = std::make_unique<std::atomic<unsigned>>(0u);
228 for (int k = 0; k < numBins; ++k)
229 {
230 slot.magnitudesDb[static_cast<size_t>(k)].store(floorDb, std::memory_order_relaxed);
231 slot.peakDb[static_cast<size_t>(k)].store(floorDb, std::memory_order_relaxed);
232 }
233 }
234 // Reader-private snapshot storage backing the getter return pointers:
235 // TWO alternating numBins-sized halves per getter, so a copy that
236 // fails seqlock validation is discarded without disturbing the
237 // coherent snapshot the reader is still allowed to be holding.
238 std::vector<T> magSnapshot(static_cast<size_t>(2 * numBins), floorDb);
239 std::vector<T> peakSnapshot(static_cast<size_t>(2 * numBins), floorDb);
240
241 auto fft = std::make_unique<FFTReal<T>>(static_cast<size_t>(fftSize));
242
243 // --- Commit phase: moves and scalar stores only, all noexcept, so
244 // every size below matches its storage on every exit path (the
245 // getter/reset() loop bounds trust that invariant). Setup thread
246 // only (documented), so plain stores suffice. The "all noexcept"
247 // half of that claim is compiler-checked rather than asserted in
248 // prose: a throw BETWEEN two commits is the only way back to a
249 // half-committed shape (NEW sizes over OLD storage).
250 static_assert(std::is_nothrow_move_assignable_v<std::vector<T>> &&
251 std::is_nothrow_move_assignable_v<std::array<OutSlot, 3>> &&
252 std::is_nothrow_move_assignable_v<std::unique_ptr<FFTReal<T>>>,
253 "prepare()'s commit phase must be non-throwing");
254
255 sampleRate_ = sampleRate;
256 fftSize_ = fftSize;
257 numBins_ = numBins;
258 hopSize_ = fftSize / 2; // 50% overlap
259 windowType_ = effectiveWindow;
260 window_ = std::move(window);
261 windowGain_ = windowGain;
262 invGain_ = invGain;
263
264 inputRing_ = std::move(inputRing);
265 fftBuffer_ = std::move(fftBuffer);
266 freqBuffer_ = std::move(freqBuffer);
267 magnitudesState_ = std::move(magnitudesState);
268 peakState_ = std::move(peakState);
269
270 outSlots_ = std::move(slots);
271 magSnapshot_ = std::move(magSnapshot);
272 peakSnapshot_ = std::move(peakSnapshot);
273 magSnapshotHalf_ = 0;
274 peakSnapshotHalf_ = 0;
275 staleSnapshots_ = 0;
276 writeSlot_ = 0;
277 readSlot_ = 1;
278 pendingSlot_.store(2, std::memory_order_relaxed);
279
280 ringWritePos_ = 0;
281 ringMask_ = fftSize_ - 1; // power of two guaranteed by the sanitation
282 samplesUntilFFT_ = hopSize_;
283 newDataReady_.store(false, std::memory_order_relaxed);
284
285 fft_ = std::move(fft); // gate ON, last
286 }
287
295 void reset() noexcept
296 {
297 const T floorDb = floorDb_.load(std::memory_order_relaxed);
298 std::fill(inputRing_.begin(), inputRing_.end(), T(0));
299 std::fill(magnitudesState_.begin(), magnitudesState_.end(), T(0));
300 std::fill(peakState_.begin(), peakState_.end(), floorDb);
301
302 for (auto& slot : outSlots_)
303 {
304 // prepare() commits numBins_ and the slot arrays together (and
305 // numBins_ is 0 before the first successful prepare()), so this
306 // loop bound always matches the allocated storage.
307 for (int k = 0; k < numBins_; ++k)
308 {
309 slot.magnitudesDb[static_cast<size_t>(k)].store(floorDb, std::memory_order_relaxed);
310 slot.peakDb[static_cast<size_t>(k)].store(floorDb, std::memory_order_relaxed);
311 }
312 }
313 std::fill(magSnapshot_.begin(), magSnapshot_.end(), floorDb);
314 std::fill(peakSnapshot_.begin(), peakSnapshot_.end(), floorDb);
315
316 ringWritePos_ = 0;
317 samplesUntilFFT_ = hopSize_;
318 newDataReady_.store(false, std::memory_order_relaxed);
319 }
320
327 void setSmoothing(T factor) noexcept
328 {
329 if (!std::isfinite(factor)) return;
330 smoothing_.store(std::clamp(factor, T(0), T(0.99)), std::memory_order_relaxed);
331 }
332
338 void setPeakDecay(T decayDbPerSecond) noexcept
339 {
340 if (!std::isfinite(decayDbPerSecond)) return;
341 peakDecayRate_.store(std::max(T(0), decayDbPerSecond), std::memory_order_relaxed);
342 }
343
345 void setPeakHoldEnabled(bool enabled) noexcept
346 {
347 peakHoldEnabled_.store(enabled, std::memory_order_relaxed);
348 }
349
355 void setFloorDb(T floorDb) noexcept
356 {
357 if (!std::isfinite(floorDb)) return;
358 floorDb_.store(floorDb, std::memory_order_relaxed);
359 }
360
362 [[nodiscard]] T getSmoothing() const noexcept { return smoothing_.load(std::memory_order_relaxed); }
363
365 [[nodiscard]] T getPeakDecay() const noexcept { return peakDecayRate_.load(std::memory_order_relaxed); }
366
368 [[nodiscard]] bool isPeakHoldEnabled() const noexcept { return peakHoldEnabled_.load(std::memory_order_relaxed); }
369
371 [[nodiscard]] T getFloorDb() const noexcept { return floorDb_.load(std::memory_order_relaxed); }
372
374 [[nodiscard]] WindowType getWindowType() const noexcept { return windowType_; }
375
385 void pushSamples(const T* samples, int numSamples) noexcept
386 {
387 if (fft_ == nullptr || samples == nullptr || numSamples <= 0)
388 return;
389
390 for (int i = 0; i < numSamples; ++i)
391 {
392 inputRing_[static_cast<size_t>(ringWritePos_)] = samples[i];
393 ringWritePos_ = (ringWritePos_ + 1) & ringMask_; // pow2 mask, no division
394
395 if (--samplesUntilFFT_ <= 0)
396 {
397 computeSpectrum();
398 samplesUntilFFT_ = hopSize_; // Restart countdown with 50% overlap
399 }
400 }
401 }
402
424 [[nodiscard]] const T* getMagnitudesDb() const noexcept
425 {
426 return snapshotFrame(false);
427 }
428
439 [[nodiscard]] const T* getPeakHoldDb() const noexcept
440 {
441 return snapshotFrame(true);
442 }
443
455 [[nodiscard]] long long getStaleSnapshotCount() const noexcept { return staleSnapshots_; }
456
458 [[nodiscard]] bool isNewDataReady() noexcept
459 {
460 return newDataReady_.exchange(false, std::memory_order_relaxed);
461 }
462
466 [[nodiscard]] int getNumBins() const noexcept { return numBins_; }
467
469 [[nodiscard]] int getFFTSize() const noexcept { return fftSize_; }
470
473 [[nodiscard]] T binToFrequency(int bin) const noexcept
474 {
475 if (fftSize_ <= 0) return T(0);
476 return static_cast<T>(bin) * static_cast<T>(sampleRate_) / static_cast<T>(fftSize_);
477 }
478
479private:
484 [[nodiscard]] static WindowType generateWindow(T* dst, int size, WindowType type) noexcept
485 {
486 switch (type)
487 {
488 case WindowType::Hamming: WindowFunctions<T>::hamming(dst, size); break;
491 case WindowType::FlatTop: WindowFunctions<T>::flatTop(dst, size); break;
493 case WindowType::Hann:
494 default: // wild enum value: fall back to Hann (never a zeroed window)
495 WindowFunctions<T>::hann(dst, size);
496 return WindowType::Hann;
497 }
498 return type;
499 }
500
501 void computeSpectrum() noexcept
502 {
503 // Parameters are published atomically; load once per frame.
504 const T smoothing = smoothing_.load(std::memory_order_relaxed);
505 const T floorDb = floorDb_.load(std::memory_order_relaxed);
506 const T decayRate = peakDecayRate_.load(std::memory_order_relaxed);
507 const bool peakHold = peakHoldEnabled_.load(std::memory_order_relaxed);
508
509 // 1. Copy & Window (pow2 mask instead of division)
510 for (int i = 0; i < fftSize_; ++i)
511 {
512 int ringIdx = (ringWritePos_ + i) & ringMask_;
513 fftBuffer_[static_cast<size_t>(i)] = inputRing_[static_cast<size_t>(ringIdx)] * window_[static_cast<size_t>(i)];
514 }
515
516 // 2. Forward FFT
517 fft_->forward(fftBuffer_.data(), freqBuffer_.data());
518
519 // 3. Magnitude Calculation & Smoothing
520 const T oneMinusSmooth = T(1) - smoothing;
521
522 for (int k = 0; k < numBins_; ++k)
523 {
524 T re = freqBuffer_[static_cast<size_t>(2 * k)];
525 T im = freqBuffer_[static_cast<size_t>(2 * k + 1)];
526 T mag = std::sqrt(re * re + im * im) * invGain_;
527
528 // DC and Nyquist carry no mirrored bin, so the single-sided 2x in
529 // invGain_ must be undone HERE, on the fresh magnitude. (Scaling
530 // the smoothed STATE compounded 0.5x every frame and parked those
531 // bins ~9.5 dB low in steady state.)
532 if (k == 0 || k == numBins_ - 1) mag *= T(0.5);
533
534 magnitudesState_[static_cast<size_t>(k)] =
535 smoothing * magnitudesState_[static_cast<size_t>(k)] + oneMinusSmooth * mag;
536 }
537
538 // 4. Time-Domain Peak Decay Calculation (Using real hopSize_)
539 const T peakDecayDb = decayRate * static_cast<T>(hopSize_) / static_cast<T>(sampleRate_);
540
541 // 5. Write into the writer-owned slot of the triple buffer
542 auto& slot = outSlots_[static_cast<size_t>(writeSlot_)];
543 std::atomic<unsigned>& seq = *slot.seq;
544
545 // Seqlock OPEN, frame-for-frame the same discipline as the reference
546 // implementation in Core/Biquad.h setCoeffs(). An odd counter marks
547 // this slot's frame as mid-write, so a reader copying the slot can
548 // detect that its copy would mix two frames and discard it instead of
549 // returning the mixture. The release fence is MANDATORY
550 // ([atomics.fences]/2; Boehm, MSPC 2012): without it the relaxed bin
551 // stores below may be hoisted above the odd counter, and a copy that
552 // read mid-write words would pass validation. Cost is per analysis
553 // FRAME (two RMWs and one fence per hop), never per sample.
554 seq.fetch_add(1u, std::memory_order_acq_rel); // -> odd
555 std::atomic_thread_fence(std::memory_order_release);
556
557 // 6. DB Conversion and Peak Hold (relaxed atomic stores: the slot is
558 // writer-owned here, ordering is carried by the publication exchange)
559 for (int k = 0; k < numBins_; ++k)
560 {
561 T dB = gainToDecibels(magnitudesState_[static_cast<size_t>(k)], floorDb);
562 slot.magnitudesDb[static_cast<size_t>(k)].store(dB, std::memory_order_relaxed);
563
564 if (peakHold)
565 {
566 // Canonical hold law: decay, but never below the live value.
567 // (The old strict-compare branch decayed a full step whenever
568 // dB == peak, so a stationary tone made the peak FLICKER one
569 // decay step below the signal every other frame.)
570 T& peak = peakState_[static_cast<size_t>(k)];
571 peak = std::max(dB, std::max(floorDb, peak - peakDecayDb));
572 slot.peakDb[static_cast<size_t>(k)].store(peak, std::memory_order_relaxed);
573 }
574 }
575
576 // Seqlock CLOSE: the frame staged in this slot is complete. The
577 // release half publishes every bin store above to any reader that
578 // reads this counter with acquire.
579 seq.fetch_add(1u, std::memory_order_release); // -> even
580
581 // 7. Publish: swap the finished slot into 'pending' (with the fresh
582 // bit) and adopt whatever slot was there as the next write target.
583 // Wait-free; the reader can never observe a slot mid-write.
584 const int old = pendingSlot_.exchange(writeSlot_ | kFreshBit, std::memory_order_acq_rel);
585 writeSlot_ = old & kSlotMask;
586 newDataReady_.store(true, std::memory_order_release);
587 }
588
589 double sampleRate_ = 48000.0;
590 int fftSize_ = 0; // 0 = unprepared (getters report honestly)
591 int numBins_ = 0;
592 int hopSize_ = 1024;
593
594 std::unique_ptr<FFTReal<T>> fft_; // doubles as the "prepared" gate
595 std::vector<T> window_;
596 WindowType windowType_ = WindowType::Hann;
597 T windowGain_ = T(1);
598 T invGain_ = T(1);
599
600 std::vector<T> inputRing_;
601 int ringWritePos_ = 0;
602 int samplesUntilFFT_ = 0;
603
604 std::vector<T> fftBuffer_;
605 std::vector<T> freqBuffer_;
606
607 // Single source of truth for DSP state
608 std::vector<T> magnitudesState_;
609 std::vector<T> peakState_;
610
611 // Tear-free triple buffer: writer owns writeSlot_, the reader owns
612 // readSlot_, and pendingSlot_ (atomic, with a freshness bit) carries the
613 // hand-off. Classic wait-free GUI metering scheme.
614 //
615 // Cross-thread publication derivation. No local race detector can
616 // certify relaxed atomics, so the argument is made here. THREE
617 // obligations, all required: an ownership argument at acquisition time
618 // is not sufficient -- every returned pointer needs a LIFETIME one, and
619 // every multi-word readout needs a FRAME-COHERENCE one that holds for
620 // the whole duration of the copy, not just at its first instruction.
621 //
622 // 1) OWNERSHIP at access time. A slot's words are accessed only by the
623 // thread that currently owns the slot, and ownership moves
624 // exclusively through atomic read-modify-writes on pendingSlot_:
625 // - writer: exchange(writeSlot_ | fresh, acq_rel) -- the release half
626 // publishes every write to the finished slot; the acquire half
627 // synchronizes with the reader's release when adopting the slot the
628 // reader gave up.
629 // - reader: compare_exchange(expected, readSlot_, acq_rel, acquire) --
630 // on success its acquire half reads from the writer's release
631 // exchange ([atomics.order]/2), so all writes to the adopted slot
632 // happen-before the reader's reads; its release half publishes that
633 // the reader is done with the slot it returns.
634 // {writeSlot_, readSlot_, pendingSlot_ & kSlotMask} is a permutation of
635 // {0, 1, 2}: it holds at prepare() (0/1/2) and every RMW swaps one
636 // party's slot with the pending one atomically, so by induction over the
637 // serialized modification order of pendingSlot_ no slot is ever owned by
638 // both threads (the same ownership-transfer discipline as
639 // Core/SpscQueue.h). ABA is harmless: a successful CAS is an RMW and
640 // therefore reads the LATEST value in pendingSlot_'s modification order
641 // -- if the same slot index returns, it does so via a writer
642 // release-exchange that already published that slot's newest contents.
643 //
644 // 2) LIFETIME of returned pointers. Ownership alone does not cover the
645 // pointers the getters return: a pointer into a slot would outlive
646 // the reader's ownership as soon as the NEXT acquisition (by either
647 // getter) handed that slot back to the writer, which would then
648 // overwrite it under the caller's feet. Therefore NO pointer into a
649 // slot ever escapes this class: each getter copies the acquired
650 // slot into reader-private snapshot storage (magSnapshot_ /
651 // peakSnapshot_, touched only by the reader) WHILE it owns the slot,
652 // and returns a pointer to the snapshot. The audio thread cannot
653 // reach the snapshots, so a held pointer can never be written by
654 // another thread, regardless of how long the caller keeps it.
655 //
656 // 3) FRAME COHERENCE across a slow copy. (1) and (2) are arguments
657 // about the OWNERSHIP protocol, and both hold only as long as that
658 // protocol is exactly right: a numBins-word copy is not atomic, so a
659 // reader that took a slow path (page fault, preemption, debugger,
660 // an unoptimised build) would return bins from two different frames
661 // the instant a future edit let the writer reach the slot being
662 // copied. Nothing about the copy itself detected that. Each slot
663 // therefore carries its own seqlock counter (OutSlot::seq): the
664 // writer brackets the bin stores with odd/even RMWs and a release
665 // fence, and the reader validates the counter after the copy behind
666 // an acquire fence ([atomics.fences]/2). A copy that could have
667 // mixed frames FAILS validation and is discarded into the spare
668 // snapshot half rather than published, so the contract holds by
669 // construction and not merely by the correctness of (1). It is the
670 // library's canonical seqlock applied per slot; the reference
671 // implementation is Core/Biquad.h.
672 //
673 // Defence in depth + oracle legibility: the slot words are relaxed
674 // std::atomic<T>. Under (1)+(2) they are never accessed concurrently,
675 // so plain words would be formally correct -- but atomics make any
676 // future lifetime regression read defined (stale) values instead of UB,
677 // and they make the DRD/Helgrind green criterion mechanical: those
678 // oracles model pthread happens-before only and cannot see the RMW
679 // edges, so with plain words they reported the slot arrays as racing
680 // and the verdict rested on a human classification argument (which
681 // an earlier version of this header got wrong). With atomic words
682 // every residual frame machine-classifies as a std::atomic op;
683 // relaxed load/store compiles to plain moves on x86 and ARM. The
684 // C++11-aware oracle for the protocol itself remains ThreadSanitizer
685 // (CI) via the pinned concurrent tests (single-getter tear pin and
686 // paired-getter lifetime pin).
687 static constexpr int kFreshBit = 4;
688 static constexpr int kSlotMask = 3;
689
692 static constexpr double kAutoSpanRef = 2048.0;
693 static constexpr double kAutoSpanRate = 48000.0;
694 static constexpr int kAutoMinFft = 256;
695 static constexpr int kAutoMaxFft = 16384;
696
701 static constexpr int kSnapshotAttempts = 4;
702
703 static_assert(std::atomic<T>::is_always_lock_free,
704 "SpectrumAnalyzer requires lock-free atomic<T> slot words "
705 "(audio-thread stores must not lock)");
706 static_assert(std::atomic<unsigned>::is_always_lock_free,
707 "SpectrumAnalyzer requires a lock-free seqlock counter "
708 "(audio-thread RMWs must not lock)");
709
710 struct OutSlot
711 {
712 std::unique_ptr<std::atomic<T>[]> magnitudesDb;
713 std::unique_ptr<std::atomic<T>[]> peakDb;
714 // Seqlock counter for the frame staged here: odd = mid-write,
715 // even = complete (obligation 3 above). Heap-held so that OutSlot
716 // stays movable and prepare()'s commit phase remains noexcept.
717 std::unique_ptr<std::atomic<unsigned>> seq;
718 };
719 std::array<OutSlot, 3> outSlots_;
720 int writeSlot_ = 0; // writer-thread private
721 mutable int readSlot_ = 1; // reader-thread private
722 mutable std::atomic<int> pendingSlot_{ 2 };
723
724 // Reader-private snapshot storage backing the getter return pointers
725 // (see the lifetime derivation above). Only the reader thread touches
726 // these; contents change only on the reader's own getter calls. Each
727 // vector holds TWO numBins_-sized halves: the getter copies into the
728 // spare half and swaps the published index only once the copy has been
729 // proved frame-coherent, so a discarded copy can never be observed and
730 // a pointer handed out last call is never scribbled on mid-copy.
731 mutable std::vector<T> magSnapshot_;
732 mutable std::vector<T> peakSnapshot_;
733 mutable int magSnapshotHalf_ = 0; // half currently published
734 mutable int peakSnapshotHalf_ = 0;
735 mutable long long staleSnapshots_ = 0; // coherent-copy failures
736
738 void acquireLatestSlot() const noexcept
739 {
740 int expected = pendingSlot_.load(std::memory_order_acquire);
741 while (expected & kFreshBit)
742 {
743 if (pendingSlot_.compare_exchange_weak(expected, readSlot_,
744 std::memory_order_acq_rel,
745 std::memory_order_acquire))
746 {
747 readSlot_ = expected & kSlotMask;
748 return;
749 }
750 }
751 }
752
768 [[nodiscard]] const T* snapshotFrame(bool peakArray) const noexcept
769 {
770 std::vector<T>& store = peakArray ? peakSnapshot_ : magSnapshot_;
771 int& half = peakArray ? peakSnapshotHalf_ : magSnapshotHalf_;
772
773 // Unprepared: no slots, no seq counters, nothing readable. Honest
774 // empty result (the pointer may be null), as documented.
775 if (numBins_ <= 0) return store.data();
776
777 T* const spare = store.data()
778 + static_cast<size_t>(half ^ 1) * static_cast<size_t>(numBins_);
779
780 for (int attempt = 0; attempt < kSnapshotAttempts; ++attempt)
781 {
782 acquireLatestSlot();
783 const OutSlot& slot = outSlots_[static_cast<size_t>(readSlot_)];
784 const std::atomic<T>* const src =
785 (peakArray ? slot.peakDb : slot.magnitudesDb).get();
786 const std::atomic<unsigned>& seq = *slot.seq;
787
788 const unsigned s0 = seq.load(std::memory_order_acquire);
789 if ((s0 & 1u) != 0u) continue; // a frame is mid-write in here
790
791 for (int k = 0; k < numBins_; ++k)
792 spare[static_cast<size_t>(k)] =
793 src[static_cast<size_t>(k)].load(std::memory_order_relaxed);
794
795 std::atomic_thread_fence(std::memory_order_acquire);
796 if (seq.load(std::memory_order_relaxed) == s0)
797 {
798 half ^= 1; // commit the validated copy
799 return spare;
800 }
801 }
802
803 ++staleSnapshots_;
804 return store.data()
805 + static_cast<size_t>(half) * static_cast<size_t>(numBins_);
806 }
807
808 int ringMask_ = 2047;
809
810 std::atomic<T> smoothing_ { T(0.8) };
811 std::atomic<T> peakDecayRate_ { T(10) };
812 std::atomic<T> floorDb_ { T(-100) };
813 std::atomic<bool> peakHoldEnabled_ { false };
814
815 std::atomic<bool> newDataReady_{ false };
816};
817
818} // 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.
int getFFTSize() const noexcept
FFT size in samples; 0 before the first successful 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 the first successful prepare(). Always matches the ...
void prepare(double sampleRate, int fftSize=0, WindowType windowType=WindowType::Hann)
Prepares the analyser and allocates all necessary buffers.
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.
long long getStaleSnapshotCount() const noexcept
Number of getter calls that had to return the previous snapshot because no attempt copied one frame c...
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 the first successful 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:89
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).