DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
PhaseVocoderEngine.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
161#include "../../Core/DspMath.h"
162#include "../../Core/FFT.h"
163#include "../../Core/WindowFunctions.h"
164
165#include <algorithm>
166#include <atomic>
167#include <cmath>
168#include <cstdint>
169#include <memory>
170#include <numbers>
171#include <vector>
172
173namespace dspark {
174namespace detail {
175
182template <FloatType T>
184{
185public:
186 // The staged parameter words are single machine words on every supported
187 // target; the audio path must never take a lock to read them.
188 static_assert(std::atomic<double>::is_always_lock_free,
189 "PhaseVocoderEngine requires lock-free std::atomic<double>");
190 static_assert(std::atomic<unsigned>::is_always_lock_free,
191 "PhaseVocoderEngine requires lock-free std::atomic<unsigned>");
192 static_assert(std::atomic<bool>::is_always_lock_free,
193 "PhaseVocoderEngine requires lock-free std::atomic<bool>");
194
196 struct Params
197 {
198 double targetSemitones = 0.0;
199 bool transientPreserve = true;
200 bool formantPreserve = false;
201 bool phaseLock = true;
202 bool percussiveSplit = false;
203 };
204
205 // -- Lifecycle (setup thread) ---------------------------------------------
206
280 bool prepare(double sampleRate, int numChannels, int fftSize,
281 bool resampleCompensation, bool separationSupport = false,
282 bool transientLockedHop = false,
283 bool spectralFluxDetector = false)
284 {
285 if (!(sampleRate > 0.0) || !std::isfinite(sampleRate) || numChannels < 1
286 || (fftSize & (fftSize - 1)) != 0 || fftSize < 256 || fftSize > (1 << 20))
287 return false;
288
289 prepared_ = false;
290
291 sampleRate_ = sampleRate;
292 numChannels_ = numChannels;
293 fftSize_ = fftSize;
294 numBins_ = fftSize_ / 2 + 1;
295 synthHop_ = fftSize_ / 4;
296 ringMask_ = fftSize_ - 1;
297 resampleCompensation_ = resampleCompensation;
298
299 accumSize_ = fftSize_ * 4; // power of two: frame + read margin
300 accumMask_ = accumSize_ - 1;
301
302 fft_ = std::make_unique<FFTReal<T>>(static_cast<size_t>(fftSize_));
303
304 window_.resize(static_cast<size_t>(fftSize_));
305 WindowFunctions<T>::hann(window_.data(), fftSize_, true);
306 for (auto& w : window_) w = std::sqrt(w); // sqrt-Hann analysis+synthesis
307
308 const int nCh = numChannels_;
309 inputRing_.assign(static_cast<size_t>(nCh), {});
310 accum_.assign(static_cast<size_t>(nCh), {});
311 for (int ch = 0; ch < nCh; ++ch)
312 {
313 inputRing_[static_cast<size_t>(ch)].assign(static_cast<size_t>(fftSize_), T(0));
314 accum_[static_cast<size_t>(ch)].assign(static_cast<size_t>(accumSize_), T(0));
315 }
316
317 fftIn_.resize(static_cast<size_t>(fftSize_));
318 spec_.resize(static_cast<size_t>(fftSize_ + 2));
319 fftResult_.resize(static_cast<size_t>(fftSize_));
320 prevAnalysis_.resize(static_cast<size_t>(fftSize_ + 2));
321 prevSynth_.resize(static_cast<size_t>(fftSize_ + 2));
322
323 // Formant preservation scratch (cepstral envelope).
324 cepsTime_.resize(static_cast<size_t>(fftSize_));
325 cepsSpec_.resize(static_cast<size_t>(fftSize_ + 2));
326 envLog_.resize(static_cast<size_t>(numBins_));
327 formantGain_.resize(static_cast<size_t>(numBins_));
328 mag_.resize(static_cast<size_t>(numBins_));
329 prevMag_.resize(static_cast<size_t>(numBins_));
330 rotRe_.resize(static_cast<size_t>(numBins_));
331 rotIm_.resize(static_cast<size_t>(numBins_));
332 peakBin_.resize(static_cast<size_t>(numBins_ / 2 + 2));
333
334 // Onset detection front end, sized only for the owner that asked for
335 // it. Everything the flux detector needs per frame is allocated
336 // here: the filterbank tables, the two band frames it differences,
337 // and the flux history the running median is taken over. An owner on
338 // the frame-energy test allocates none of it and runs the same
339 // detector, over the same magnitudes, as it did before this
340 // selection existed.
341 fluxDetectorEnabled_ = spectralFluxDetector;
342 if (fluxDetectorEnabled_)
343 {
344 buildOnsetFilterBank();
345 bandCur_.assign(static_cast<size_t>(numBands_), T(0));
346 bandPrev_.assign(static_cast<size_t>(numBands_), T(0));
347 bandMaxPrev_.assign(static_cast<size_t>(numBands_), T(0));
348 fluxHist_.assign(static_cast<size_t>(kFluxWindow), 0.0);
349 fluxScratch_.assign(static_cast<size_t>(kFluxWindow), 0.0);
350 // Un-normalised |X| grows linearly with the frame length, so the
351 // band accumulation is referred to a 2048-sample frame before
352 // the log compression: the growth cancels and one firing
353 // threshold means the same sensitivity at every frame size.
354 // fftSize is a power of two, so the factor is exact and
355 // introduces no rounding.
356 odfScale_ = static_cast<T>(kOdfRefFrame / static_cast<double>(fftSize_));
357 }
358 else
359 {
360 numBands_ = 0;
361 fbStart_.clear();
362 fbOffset_.clear();
363 fbCount_.clear();
364 fbWeights_.clear();
365 bandCur_.clear();
366 bandPrev_.clear();
367 bandMaxPrev_.clear();
368 fluxHist_.clear();
369 fluxScratch_.clear();
370 odfScale_ = T(1);
371 }
372 // One lock spans every analysis frame whose window can contain the
373 // strike; that count is a property of the window geometry, not of
374 // any signal. An owner that did not ask for the locked hop gets a
375 // lock length of zero, which is the schedule with no lock in it.
376 lockEnabled_ = transientLockedHop;
377 lockFrames_ = lockEnabled_ ? (fftSize_ + synthHop_ - 1) / synthHop_ : 0;
378
379 // Harmonic/percussive median filtering. Both window lengths are
380 // chosen from a PHYSICAL span, so they follow the sample rate and the
381 // frame size instead of being fixed bin/frame counts:
382 // - along time, 0.2 s of history, the span over which a musical
383 // partial is steady but a strike is not;
384 // - along frequency, 500 Hz, wide enough to bridge the harmonic
385 // comb of any pitched note in range and narrow enough to leave a
386 // broadband strike untouched.
387 // Both are forced odd so the median is a sample of the window rather
388 // than an average of two, and both are clamped so extreme frame sizes
389 // cannot ask for a window longer than the data.
390 separationEnabled_ = separationSupport;
391 if (separationEnabled_)
392 {
393 const double hopSeconds = static_cast<double>(synthHop_) / sampleRate_;
394 const double binHz = sampleRate_ / static_cast<double>(fftSize_);
395 timeMedian_ = makeOdd(static_cast<int>(std::lround(0.2 / hopSeconds)), 3, 31);
396 freqMedian_ = makeOdd(static_cast<int>(std::lround(500.0 / binHz)), 3, 63);
397 freqMedian_ = std::min(freqMedian_, makeOdd(numBins_, 3, 63));
398
399 magHist_.assign(static_cast<size_t>(timeMedian_) * static_cast<size_t>(numBins_), T(0));
400 medianScratch_.resize(static_cast<size_t>(std::max(timeMedian_, freqMedian_)));
401 maskHarm_.resize(static_cast<size_t>(numBins_));
402 maskPerc_.resize(static_cast<size_t>(numBins_));
403 specRaw_.resize(static_cast<size_t>(fftSize_ + 2));
404 }
405 else
406 {
407 timeMedian_ = 0;
408 freqMedian_ = 0;
409 magHist_.clear();
410 medianScratch_.clear();
411 maskHarm_.clear();
412 maskPerc_.clear();
413 specRaw_.clear();
414 }
415
416 prepared_ = true;
417 return true;
418 }
419
422 void reset() noexcept
423 {
424 if (!prepared_) return;
425 for (auto& r : inputRing_) std::fill(r.begin(), r.end(), T(0));
426 for (auto& a : accum_) std::fill(a.begin(), a.end(), T(0));
427 std::fill(prevAnalysis_.begin(), prevAnalysis_.end(), T(0));
428 std::fill(prevSynth_.begin(), prevSynth_.end(), T(0));
429 std::fill(prevMag_.begin(), prevMag_.end(), T(0));
430 std::fill(magHist_.begin(), magHist_.end(), T(0));
431 histPos_ = 0;
432 histFilled_ = 0;
433
434 std::fill(bandPrev_.begin(), bandPrev_.end(), T(0));
435 std::fill(bandMaxPrev_.begin(), bandMaxPrev_.end(), T(0));
436 std::fill(fluxHist_.begin(), fluxHist_.end(), 0.0);
437 fluxPos_ = 0;
438 fluxFilled_ = 0;
439 onsetEnv_ = 0.0;
440 lockLeft_ = 0;
441 debt_ = 0.0;
442
443 inputPos_ = 0;
444 writeHead_ = static_cast<int64_t>(accumSize_); // keep indices positive
445
446 adoptParamsIfDirty();
447 stActive_ = std::clamp(targetSemitones_, -12.0, 12.0);
448 ratioActive_ = std::exp2(stActive_ / 12.0);
449 hopCarry_ = 0.0;
450 inputSinceHop_ = 0;
451 analysisHop_ = computeAnalysisHop();
452 firstFrame_ = true;
453 }
454
455 // -- Parameters (control thread) ------------------------------------------
456
465 void publishParams(const Params& p) noexcept
466 {
467 seq_.fetch_add(1, std::memory_order_acq_rel); // -> odd
468 // Release fence: pairs with the reader's acquire fence via
469 // fence-fence synchronization ([atomics.fences]/2). Without it the
470 // relaxed data stores below are not ordered against the counter and
471 // a torn copy could pass validation on weakly ordered targets.
472 std::atomic_thread_fence(std::memory_order_release);
473 stgSemitones_.store(p.targetSemitones, std::memory_order_relaxed);
474 stgTransient_.store(p.transientPreserve, std::memory_order_relaxed);
475 stgFormant_.store(p.formantPreserve, std::memory_order_relaxed);
476 stgPhaseLock_.store(p.phaseLock, std::memory_order_relaxed);
477 stgSplit_.store(p.percussiveSplit, std::memory_order_relaxed);
478 seq_.fetch_add(1, std::memory_order_release); // -> even
479 dirty_.store(true, std::memory_order_release);
480 }
481
482 // -- Streaming (audio thread, stream owner) -------------------------------
483
485 [[nodiscard]] int samplesToNextHop() const noexcept
486 {
487 return analysisHop_ - inputSinceHop_;
488 }
489
492 void pushInput(int ch, const T* src, int count) noexcept
493 {
494 auto& ring = inputRing_[static_cast<size_t>(ch)];
495 int wp = inputPos_;
496 for (int k = 0; k < count; ++k)
497 {
498 ring[static_cast<size_t>(wp)] = src[k];
499 wp = (wp + 1) & ringMask_;
500 }
501 }
502
511 void commitInput(int count, int numActiveChannels) noexcept
512 {
513 inputPos_ = (inputPos_ + count) & ringMask_;
514 inputSinceHop_ += count;
515 if (inputSinceHop_ >= analysisHop_)
516 {
517 inputSinceHop_ = 0;
518 processHop(numActiveChannels);
519 }
520 }
521
523 [[nodiscard]] double activeRatio() const noexcept { return ratioActive_; }
524
528 [[nodiscard]] Params activeParams() const noexcept
529 {
530 Params p;
531 p.targetSemitones = targetSemitones_;
532 p.transientPreserve = transientOn_;
533 p.formantPreserve = formantOn_;
534 p.phaseLock = phaseLockOn_;
535 p.percussiveSplit = splitOn_;
536 return p;
537 }
538
540 [[nodiscard]] const T* olaData(int ch) const noexcept
541 {
542 return accum_[static_cast<size_t>(ch)].data();
543 }
544
546 [[nodiscard]] int64_t olaMask() const noexcept
547 {
548 return static_cast<int64_t>(accumMask_);
549 }
550
552 [[nodiscard]] int olaSize() const noexcept { return accumSize_; }
553
555 [[nodiscard]] int64_t writeHead() const noexcept { return writeHead_; }
556
558 [[nodiscard]] int fftSize() const noexcept { return prepared_ ? fftSize_ : 0; }
559
561 [[nodiscard]] int synthHop() const noexcept { return synthHop_; }
562
563private:
564 static constexpr double kTwoPi = 2.0 * std::numbers::pi;
565
571 static constexpr int kSeqlockMaxAttempts = 3;
572
578 static constexpr double kSeparationFactor = 2.0;
579
584 static constexpr double kOnsetFMin = 27.5;
585 static constexpr double kOnsetFMax = 16000.0;
586 static constexpr int kOnsetBandsPerOctave = 24;
590 static constexpr double kOdfRefFrame = 2048.0;
595 static constexpr int kFluxWindow = 12;
603 static constexpr double kFluxDelta = 0.02;
604
612 void adoptParamsIfDirty() noexcept
613 {
614 if (!dirty_.exchange(false, std::memory_order_acquire)) return;
615
616 // Bounded seqlock read (kSeqlockMaxAttempts): never adopts a torn set
617 // (the accept test is the unbounded form's) and on give-up keeps the
618 // previously adopted private copy.
619 bool adopted = false;
620 double st = 0.0;
621 bool tr = true, fo = false, pl = true, sp = false;
622 for (int attempt = 0; attempt < kSeqlockMaxAttempts; ++attempt)
623 {
624 const unsigned s0 = seq_.load(std::memory_order_acquire);
625 if ((s0 & 1u) != 0u) continue; // writer mid-publish: do not copy
626 st = stgSemitones_.load(std::memory_order_relaxed);
627 tr = stgTransient_.load(std::memory_order_relaxed);
628 fo = stgFormant_.load(std::memory_order_relaxed);
629 pl = stgPhaseLock_.load(std::memory_order_relaxed);
630 sp = stgSplit_.load(std::memory_order_relaxed);
631 // The fence orders the copy above BEFORE the re-read below and
632 // pairs with the writer's release fence ([atomics.fences]/2); a
633 // plain load-acquire orders only LATER accesses, so without it
634 // the copy could sink below the re-read on weakly ordered CPUs.
635 std::atomic_thread_fence(std::memory_order_acquire);
636 if (s0 == seq_.load(std::memory_order_relaxed)) { adopted = true; break; }
637 }
638 if (!adopted)
639 {
640 // Adopt nothing: the active copies do not move. Re-arm and pick
641 // the update up at a later hop.
642 dirty_.store(true, std::memory_order_release);
643 return;
644 }
645 // Commit-on-validate: the active copies are written only here, after
646 // the copy validated, so a give-up can never leave a torn set live.
647 targetSemitones_ = st;
648 transientOn_ = tr;
649 formantOn_ = fo;
650 phaseLockOn_ = pl;
651 splitOn_ = sp && separationEnabled_;
652 }
653
655 [[nodiscard]] static double princArg(double x) noexcept
656 {
657 return x - kTwoPi * std::round(x / kTwoPi);
658 }
659
682 [[nodiscard]] int computeAnalysisHop() noexcept
683 {
684 double ideal;
685 if (lockLeft_ > 0)
686 {
687 --lockLeft_;
688 debt_ += static_cast<double>(synthHop_) / ratioActive_
689 - static_cast<double>(synthHop_);
690 ideal = static_cast<double>(synthHop_) + hopCarry_;
691 }
692 else
693 {
694 const double cap = 0.5 * static_cast<double>(synthHop_);
695 const double repay = std::clamp(debt_, -cap, cap);
696 debt_ -= repay;
697 ideal = static_cast<double>(synthHop_) / ratioActive_ + repay + hopCarry_;
698 }
699 int hop = static_cast<int>(ideal);
700 hop = std::clamp(hop, 1, fftSize_);
701 hopCarry_ = ideal - static_cast<double>(hop);
702 return hop;
703 }
704
719 void armTransientLock(bool transient) noexcept
720 {
721 if (!lockEnabled_) return;
722 if (!transient) return;
723 if (firstFrame_) return;
724 if (lockLeft_ > 0) return;
725 if (std::abs(debt_) >= 1.0) return;
726 lockLeft_ = lockFrames_;
727 }
728
730 void processHop(int nCh) noexcept
731 {
732 // Adopt pending control publications at the hop boundary (cold by
733 // construction: it runs only when the control thread published; the
734 // audio thread never publishes to itself through the staged channel).
735 adoptParamsIfDirty();
736
737 // --- glide the active ratio toward the target (max 0.5 st per hop) ----
738 const double stTarget = std::clamp(targetSemitones_, -12.0, 12.0);
739 stActive_ += std::clamp(stTarget - stActive_, -0.5, 0.5);
740 ratioActive_ = std::exp2(stActive_ / 12.0);
741
742 // --- reference-channel analysis ---------------------------------------
743 analyzeChannel(0);
744
745 const bool transient = detectTransient();
746 armTransientLock(transient);
747 buildRotations(transient);
748 // The magnitude history is kept whenever the owner allocated it, not
749 // only while the split is engaged: turning the split on mid-stream
750 // must find a full window behind it, not an empty one.
751 if (separationEnabled_)
752 {
753 pushMagnitudeHistory();
754 if (splitOn_) updateSeparationMasks();
755 }
756
757 // Snapshot the reference analysis spectrum for the next frame's
758 // heterodyned phase increments (after decisions, before modification).
759 std::copy(spec_.begin(), spec_.end(), prevAnalysis_.begin());
760 std::copy(mag_.begin(), mag_.end(), prevMag_.begin());
761
762 // --- synthesis: reference channel first, then the rest -----------------
763 synthesizeChannel(0, true);
764 for (int ch = 1; ch < nCh; ++ch)
765 {
766 analyzeChannel(ch);
767 synthesizeChannel(ch, false);
768 }
769
770 analysisHop_ = computeAnalysisHop();
771 writeHead_ += synthHop_;
772 firstFrame_ = false;
773 }
774
776 void analyzeChannel(int ch) noexcept
777 {
778 const auto& ring = inputRing_[static_cast<size_t>(ch)];
779 const int readPos = inputPos_; // oldest sample (ring size == fftSize)
780 for (int k = 0; k < fftSize_; ++k)
781 {
782 const int idx = (readPos + k) & ringMask_;
783 fftIn_[static_cast<size_t>(k)] = ring[static_cast<size_t>(idx)]
784 * window_[static_cast<size_t>(k)];
785 }
786 fft_->forward(fftIn_.data(), spec_.data());
787
788 if (ch == 0)
789 {
790 for (int k = 0; k < numBins_; ++k)
791 {
792 const T re = spec_[static_cast<size_t>(2 * k)];
793 const T im = spec_[static_cast<size_t>(2 * k + 1)];
794 mag_[static_cast<size_t>(k)] = std::sqrt(re * re + im * im);
795 }
796 }
797 }
798
806 [[nodiscard]] bool detectTransient() noexcept
807 {
808 return fluxDetectorEnabled_ ? detectTransientByFlux()
809 : detectTransientByEnergy();
810 }
811
822 [[nodiscard]] bool detectTransientByEnergy() noexcept
823 {
824 double energy = 0.0;
825 for (int k = 0; k < numBins_; ++k)
826 {
827 const double m = static_cast<double>(mag_[static_cast<size_t>(k)]);
828 energy += m * m;
829 }
830 const double prevEnv = onsetEnv_;
831 onsetEnv_ = std::max(energy, onsetEnv_ * 0.7);
832 if (!transientOn_)
833 return firstFrame_;
834 return firstFrame_ || (energy > 4.0 * prevEnv && energy > 1e-12);
835 }
836
854 [[nodiscard]] bool detectTransientByFlux() noexcept
855 {
856 const double flux = computeSpectralFlux();
857
858 // Median of the trailing window, taken before this frame is pushed:
859 // a frame is compared with the material that preceded it, never with
860 // itself.
861 bool rise = false;
862 if (fluxFilled_ >= kFluxWindow)
863 {
864 std::copy(fluxHist_.begin(), fluxHist_.end(), fluxScratch_.begin());
865 const auto mid = fluxScratch_.begin() + kFluxWindow / 2;
866 std::nth_element(fluxScratch_.begin(), mid, fluxScratch_.end());
867 rise = (flux - *mid) >= kFluxDelta;
868 }
869
870 fluxHist_[static_cast<size_t>(fluxPos_)] = flux;
871 fluxPos_ = (fluxPos_ + 1) % kFluxWindow;
872 if (fluxFilled_ < kFluxWindow) ++fluxFilled_;
873
874 if (!transientOn_)
875 return firstFrame_;
876 return firstFrame_ || rise;
877 }
878
881 [[nodiscard]] double computeSpectralFlux() noexcept
882 {
883 filterLogBands();
884
885 double flux = 0.0;
886 for (int b = 0; b < numBands_; ++b)
887 {
888 const double d = static_cast<double>(bandCur_[static_cast<size_t>(b)])
889 - static_cast<double>(bandMaxPrev_[static_cast<size_t>(b)]);
890 if (d > 0.0) flux += d;
891 }
892 flux /= static_cast<double>(std::max(1, numBands_));
893
894 // Rotate: previous <- current, and rebuild the maximum-filtered
895 // reference the next frame will be differenced against.
896 bandPrev_ = bandCur_;
897 maxFilterBands();
898 return flux;
899 }
900
904 void filterLogBands() noexcept
905 {
906 for (int b = 0; b < numBands_; ++b)
907 {
908 const int start = fbStart_[static_cast<size_t>(b)];
909 const int off = fbOffset_[static_cast<size_t>(b)];
910 const int cnt = fbCount_[static_cast<size_t>(b)];
911 T acc = T(0);
912 for (int i = 0; i < cnt; ++i)
913 {
914 const int k = start + i;
915 if (k >= 0 && k < numBins_)
916 acc += mag_[static_cast<size_t>(k)]
917 * fbWeights_[static_cast<size_t>(off + i)];
918 }
919 bandCur_[static_cast<size_t>(b)] = std::log10(acc * odfScale_ + T(1));
920 }
921 }
922
924 void maxFilterBands() noexcept
925 {
926 for (int b = 0; b < numBands_; ++b)
927 {
928 T mx = bandPrev_[static_cast<size_t>(b)];
929 if (b > 0) mx = std::max(mx, bandPrev_[static_cast<size_t>(b - 1)]);
930 if (b + 1 < numBands_) mx = std::max(mx, bandPrev_[static_cast<size_t>(b + 1)]);
931 bandMaxPrev_[static_cast<size_t>(b)] = mx;
932 }
933 }
934
944 void buildOnsetFilterBank()
945 {
946 fbStart_.clear();
947 fbOffset_.clear();
948 fbCount_.clear();
949 fbWeights_.clear();
950 numBands_ = 0;
951
952 const double binHz = sampleRate_ / static_cast<double>(fftSize_);
953 const double fMax = std::min(kOnsetFMax, sampleRate_ * 0.5 * 0.999);
954
955 std::vector<int> centres;
956 for (int i = 0; ; ++i)
957 {
958 const double f = kOnsetFMin * std::pow(2.0, static_cast<double>(i)
959 / static_cast<double>(kOnsetBandsPerOctave));
960 if (f > fMax) break;
961 int bin = static_cast<int>(std::lround(f / binHz));
962 bin = std::clamp(bin, 0, numBins_ - 1);
963 if (centres.empty() || bin > centres.back())
964 centres.push_back(bin);
965 }
966
967 for (size_t j = 1; j + 1 < centres.size(); ++j)
968 {
969 const int lo = centres[j - 1];
970 const int ce = centres[j];
971 const int hi = centres[j + 1];
972 if (!(lo < ce && ce < hi)) continue;
973
974 fbStart_.push_back(lo);
975 fbOffset_.push_back(static_cast<int>(fbWeights_.size()));
976 for (int k = lo; k <= hi; ++k)
977 {
978 const T wv = (k <= ce)
979 ? static_cast<T>(static_cast<double>(k - lo)
980 / static_cast<double>(ce - lo))
981 : static_cast<T>(static_cast<double>(hi - k)
982 / static_cast<double>(hi - ce));
983 fbWeights_.push_back(wv);
984 }
985 ++numBands_;
986 }
987 for (int b = 0; b < numBands_; ++b)
988 {
989 const int off = fbOffset_[static_cast<size_t>(b)];
990 const int nextOff = (b + 1 < numBands_)
991 ? fbOffset_[static_cast<size_t>(b + 1)]
992 : static_cast<int>(fbWeights_.size());
993 fbCount_.push_back(nextOff - off);
994 }
995 // A frame size too small to carry a single triangle leaves the flux
996 // at zero, which never fires: the detector goes quiet rather than
997 // reading out of an empty table.
998 if (numBands_ < 1) numBands_ = 0;
999 }
1000
1008 void buildRotations(bool transient) noexcept
1009 {
1010 if (transient)
1011 {
1012 std::fill(rotRe_.begin(), rotRe_.end(), T(1));
1013 std::fill(rotIm_.begin(), rotIm_.end(), T(0));
1014 return;
1015 }
1016
1017 if (!phaseLockOn_)
1018 {
1019 buildPerBinRotations();
1020 return;
1021 }
1022
1023 // --- find spectral peaks (local maxima over +-2 bins, above floor) -----
1024 T maxMag = T(0);
1025 for (int k = 0; k < numBins_; ++k)
1026 maxMag = std::max(maxMag, mag_[static_cast<size_t>(k)]);
1027
1028 numPeaks_ = 0;
1029 if (maxMag > T(1e-9))
1030 {
1031 const T floorMag = maxMag * T(1e-4); // -80 dB relative floor
1032 // Candidates stop at numBins-3 so the +-2-bin neighbour tests
1033 // stay inside mag_ (the old bound of numBins-2 read mag_[k+2]
1034 // one float past the end whenever the bin below Nyquist was a
1035 // candidate above the floor - broadband material at small frame
1036 // sizes reached it routinely).
1037 const int last = numBins_ - 3;
1038 for (int k = 2; k <= last; ++k)
1039 {
1040 const T m = mag_[static_cast<size_t>(k)];
1041 if (m < floorMag) continue;
1042 if (m > mag_[static_cast<size_t>(k - 1)] && m >= mag_[static_cast<size_t>(k + 1)]
1043 && m > mag_[static_cast<size_t>(k - 2)] && m >= mag_[static_cast<size_t>(k + 2)])
1044 {
1045 peakBin_[static_cast<size_t>(numPeaks_++)] = k;
1046 k += 2; // a neighbour cannot also be a peak
1047 }
1048 }
1049 }
1050
1051 if (numPeaks_ == 0)
1052 {
1053 std::fill(rotRe_.begin(), rotRe_.end(), T(1));
1054 std::fill(rotIm_.begin(), rotIm_.end(), T(0));
1055 return;
1056 }
1057
1058 // --- per-peak heterodyned phase propagation ----------------------------
1059 // analysisHop_ still holds the hop just consumed (it is recomputed
1060 // after this call), which is exactly the Ra of this frame interval.
1061 const double Ra = static_cast<double>(analysisHop_);
1062 const double Rs = static_cast<double>(synthHop_);
1063 const double binW = kTwoPi / static_cast<double>(fftSize_);
1064
1065 int regionStart = 0;
1066 for (int p = 0; p < numPeaks_; ++p)
1067 {
1068 const int bin = peakBin_[static_cast<size_t>(p)];
1069 const double re = static_cast<double>(spec_[static_cast<size_t>(2 * bin)]);
1070 const double im = static_cast<double>(spec_[static_cast<size_t>(2 * bin + 1)]);
1071 const double pre = static_cast<double>(prevAnalysis_[static_cast<size_t>(2 * bin)]);
1072 const double pim = static_cast<double>(prevAnalysis_[static_cast<size_t>(2 * bin + 1)]);
1073
1074 double rotR = 1.0, rotI = 0.0;
1075 // A peak with no spectral history is a fresh partial: keep its
1076 // analysis phase instead of propagating from noise.
1077 if (prevMag_[static_cast<size_t>(bin)] > T(0.1) * mag_[static_cast<size_t>(bin)])
1078 {
1079 // Measured inter-frame phase advance, inherently wrapped.
1080 const double deltaPhi = std::atan2(im * pre - re * pim, re * pre + im * pim);
1081 const double omegaK = binW * static_cast<double>(bin);
1082 const double omegaInst = omegaK + princArg(deltaPhi - omegaK * Ra) / Ra;
1083
1084 const double psiPrev = std::atan2(
1085 static_cast<double>(prevSynth_[static_cast<size_t>(2 * bin + 1)]),
1086 static_cast<double>(prevSynth_[static_cast<size_t>(2 * bin)]));
1087 const double phi = std::atan2(im, re);
1088 const double theta = psiPrev + Rs * omegaInst - phi;
1089 rotR = std::cos(theta);
1090 rotI = std::sin(theta);
1091 }
1092
1093 // Region of influence: up to the magnitude valley before the next peak.
1094 int regionEnd = numBins_; // exclusive
1095 if (p + 1 < numPeaks_)
1096 {
1097 const int nextBin = peakBin_[static_cast<size_t>(p + 1)];
1098 int valley = bin + 1;
1099 T valleyMag = mag_[static_cast<size_t>(valley)];
1100 for (int k = bin + 2; k < nextBin; ++k)
1101 {
1102 if (mag_[static_cast<size_t>(k)] < valleyMag)
1103 {
1104 valleyMag = mag_[static_cast<size_t>(k)];
1105 valley = k;
1106 }
1107 }
1108 regionEnd = valley + 1;
1109 }
1110
1111 for (int k = regionStart; k < regionEnd; ++k)
1112 {
1113 rotRe_[static_cast<size_t>(k)] = static_cast<T>(rotR);
1114 rotIm_[static_cast<size_t>(k)] = static_cast<T>(rotI);
1115 }
1116 regionStart = regionEnd;
1117 }
1118
1119 // DC and Nyquist are real-valued in the packed spectrum: never rotate.
1120 rotRe_[0] = T(1); rotIm_[0] = T(0);
1121 rotRe_[static_cast<size_t>(numBins_ - 1)] = T(1); rotIm_[static_cast<size_t>(numBins_ - 1)] = T(0);
1122 }
1123
1133 void buildPerBinRotations() noexcept
1134 {
1135 const double Ra = static_cast<double>(analysisHop_);
1136 const double Rs = static_cast<double>(synthHop_);
1137 const double binW = kTwoPi / static_cast<double>(fftSize_);
1138
1139 // DC and Nyquist are real-valued in the packed spectrum: never rotate.
1140 rotRe_[0] = T(1); rotIm_[0] = T(0);
1141 rotRe_[static_cast<size_t>(numBins_ - 1)] = T(1);
1142 rotIm_[static_cast<size_t>(numBins_ - 1)] = T(0);
1143
1144 for (int k = 1; k < numBins_ - 1; ++k)
1145 {
1146 double rotR = 1.0, rotI = 0.0;
1147 // A bin with no magnitude history carries no phase to propagate.
1148 if (prevMag_[static_cast<size_t>(k)] > T(0.1) * mag_[static_cast<size_t>(k)])
1149 {
1150 const double re = static_cast<double>(spec_[static_cast<size_t>(2 * k)]);
1151 const double im = static_cast<double>(spec_[static_cast<size_t>(2 * k + 1)]);
1152 const double pre = static_cast<double>(prevAnalysis_[static_cast<size_t>(2 * k)]);
1153 const double pim = static_cast<double>(prevAnalysis_[static_cast<size_t>(2 * k + 1)]);
1154
1155 const double deltaPhi = std::atan2(im * pre - re * pim, re * pre + im * pim);
1156 const double omegaK = binW * static_cast<double>(k);
1157 const double omegaInst = omegaK + princArg(deltaPhi - omegaK * Ra) / Ra;
1158
1159 const double psiPrev = std::atan2(
1160 static_cast<double>(prevSynth_[static_cast<size_t>(2 * k + 1)]),
1161 static_cast<double>(prevSynth_[static_cast<size_t>(2 * k)]));
1162 const double phi = std::atan2(im, re);
1163 const double theta = psiPrev + Rs * omegaInst - phi;
1164 rotR = std::cos(theta);
1165 rotI = std::sin(theta);
1166 }
1167 rotRe_[static_cast<size_t>(k)] = static_cast<T>(rotR);
1168 rotIm_[static_cast<size_t>(k)] = static_cast<T>(rotI);
1169 }
1170 }
1171
1173 [[nodiscard]] static int makeOdd(int v, int lo, int hi) noexcept
1174 {
1175 v = std::clamp(v, lo, hi);
1176 if ((v & 1) == 0) --v;
1177 return std::max(v, lo | 1);
1178 }
1179
1181 void pushMagnitudeHistory() noexcept
1182 {
1183 T* slot = magHist_.data() + static_cast<size_t>(histPos_) * static_cast<size_t>(numBins_);
1184 std::copy(mag_.begin(), mag_.end(), slot);
1185 histPos_ = (histPos_ + 1) % timeMedian_;
1186 if (histFilled_ < timeMedian_) ++histFilled_;
1187 }
1188
1206 void updateSeparationMasks() noexcept
1207 {
1208 const int nHist = histFilled_;
1209 const int half = freqMedian_ / 2;
1210
1211 for (int k = 0; k < numBins_; ++k)
1212 {
1213 // Median along time at this bin, over the frames seen so far.
1214 for (int f = 0; f < nHist; ++f)
1215 medianScratch_[static_cast<size_t>(f)] =
1216 magHist_[static_cast<size_t>(f) * static_cast<size_t>(numBins_)
1217 + static_cast<size_t>(k)];
1218 auto mid = medianScratch_.begin() + nHist / 2;
1219 std::nth_element(medianScratch_.begin(), mid,
1220 medianScratch_.begin() + nHist);
1221 const double h = static_cast<double>(*mid);
1222
1223 // Median along frequency in this frame, clamped at both edges so
1224 // the window never reads outside the spectrum.
1225 const int lo = std::max(0, k - half);
1226 const int hi = std::min(numBins_ - 1, k + half);
1227 const int n = hi - lo + 1;
1228 for (int j = 0; j < n; ++j)
1229 medianScratch_[static_cast<size_t>(j)] = mag_[static_cast<size_t>(lo + j)];
1230 auto midF = medianScratch_.begin() + n / 2;
1231 std::nth_element(medianScratch_.begin(), midF, medianScratch_.begin() + n);
1232 const double p = static_cast<double>(*midF);
1233
1234 // A bin goes down the percussive path only when the frequency
1235 // median dominates the time median by the separation factor.
1236 // Everything else, including everything ambiguous, stays
1237 // harmonic: the phase-propagated path is the one that is right
1238 // for most material, so the split must earn each bin it takes
1239 // rather than divide every bin in proportion. A proportional
1240 // (Wiener) mask measured 1.5 dB of log-spectral distance on a
1241 // purely harmonic bed, where the correct answer is that nothing
1242 // is percussive at all.
1243 const bool percussive = (p > kSeparationFactor * h);
1244 maskHarm_[static_cast<size_t>(k)] = percussive ? T(0) : T(1);
1245 maskPerc_[static_cast<size_t>(k)] = percussive ? T(1) : T(0);
1246 }
1247 }
1248
1251 void synthesizeChannel(int ch, bool isReference) noexcept
1252 {
1253 // The percussive path re-uses this channel's UNROTATED spectrum, so
1254 // it has to be kept before the rotation overwrites it.
1255 if (splitOn_)
1256 std::copy(spec_.begin(), spec_.end(), specRaw_.begin());
1257
1258 // Rigid per-region rotation preserves intra-region (and inter-channel)
1259 // relative phases exactly.
1260 for (int k = 1; k < numBins_ - 1; ++k)
1261 {
1262 const T re = spec_[static_cast<size_t>(2 * k)];
1263 const T im = spec_[static_cast<size_t>(2 * k + 1)];
1264 const T rr = rotRe_[static_cast<size_t>(k)];
1265 const T ri = rotIm_[static_cast<size_t>(k)];
1266 spec_[static_cast<size_t>(2 * k)] = re * rr - im * ri;
1267 spec_[static_cast<size_t>(2 * k + 1)] = re * ri + im * rr;
1268 }
1269
1270 // Formant preservation: pre-warp synthesis magnitudes by
1271 // env(k*ratio)/env(k) so the owner's output resampler puts the
1272 // spectral envelope back where the input had it. The gain table is
1273 // computed once on the reference channel and shared, keeping the
1274 // stereo image coherent.
1275 if (formantOn_ && std::abs(ratioActive_ - 1.0) > 1e-6)
1276 {
1277 if (isReference)
1278 computeFormantGains();
1279 for (int k = 1; k < numBins_ - 1; ++k)
1280 {
1281 const T g = formantGain_[static_cast<size_t>(k)];
1282 spec_[static_cast<size_t>(2 * k)] *= g;
1283 spec_[static_cast<size_t>(2 * k + 1)] *= g;
1284 }
1285 }
1286
1287 // Anti-alias for upward shifts (resample-back owners): the owner's
1288 // resampler multiplies frequencies by `ratio`, so taper bins that
1289 // would land above Nyquist.
1290 if (resampleCompensation_ && ratioActive_ > 1.0)
1291 {
1292 const int cut = static_cast<int>(static_cast<double>(fftSize_ / 2) / ratioActive_);
1293 const int taperStart = std::max(1, cut - 4);
1294 for (int k = taperStart; k < numBins_; ++k)
1295 {
1296 T g = T(0);
1297 if (k <= cut)
1298 g = static_cast<T>(cut - k + 1) / static_cast<T>(cut - taperStart + 1);
1299 spec_[static_cast<size_t>(2 * k)] *= g;
1300 spec_[static_cast<size_t>(2 * k + 1)] *= g;
1301 }
1302 }
1303
1304 if (isReference)
1305 std::copy(spec_.begin(), spec_.end(), prevSynth_.begin());
1306
1307 // Harmonic/percussive recombination. The phase recursion above is fed
1308 // the fully propagated spectrum (the line just past), never the mix:
1309 // the percussive share deliberately breaks the phase recursion, and
1310 // letting it back in would poison the next frame's propagation.
1311 if (splitOn_)
1312 {
1313 for (int k = 0; k < numBins_; ++k)
1314 {
1315 const T mh = maskHarm_[static_cast<size_t>(k)];
1316 const T mp = maskPerc_[static_cast<size_t>(k)];
1317 const auto re = static_cast<size_t>(2 * k);
1318 const auto im = static_cast<size_t>(2 * k + 1);
1319 spec_[re] = spec_[re] * mh + specRaw_[re] * mp;
1320 spec_[im] = spec_[im] * mh + specRaw_[im] * mp;
1321 }
1322 }
1323
1324 fft_->inverse(spec_.data(), fftResult_.data());
1325
1326 synthesizeTail(ch);
1327 }
1328
1337 void computeFormantGains() noexcept
1338 {
1339 // Even-symmetric log magnitude.
1340 for (int k = 0; k < numBins_; ++k)
1341 {
1342 const T re = spec_[static_cast<size_t>(2 * k)];
1343 const T im = spec_[static_cast<size_t>(2 * k + 1)];
1344 cepsTime_[static_cast<size_t>(k)] =
1345 std::log(std::sqrt(re * re + im * im) + T(1e-9));
1346 }
1347 for (int k = numBins_; k < fftSize_; ++k)
1348 cepsTime_[static_cast<size_t>(k)] =
1349 cepsTime_[static_cast<size_t>(fftSize_ - k)];
1350
1351 fft_->forward(cepsTime_.data(), cepsSpec_.data());
1352
1353 // Lifter: keep quefrencies below ~1 ms (the smooth envelope), zero
1354 // the rest (the harmonic comb). Cepstral index is quefrency in
1355 // samples: 1 ms = 0.001 * fs.
1356 const int qCut = std::max(8, static_cast<int>(0.001 * sampleRate_));
1357 const int keep = std::min(qCut, numBins_ - 1);
1358 for (int k = keep + 1; k < numBins_; ++k)
1359 {
1360 cepsSpec_[static_cast<size_t>(2 * k)] = T(0);
1361 cepsSpec_[static_cast<size_t>(2 * k + 1)] = T(0);
1362 }
1363
1364 fft_->inverse(cepsSpec_.data(), cepsTime_.data());
1365 for (int k = 0; k < numBins_; ++k)
1366 envLog_[static_cast<size_t>(k)] = cepsTime_[static_cast<size_t>(k)];
1367
1368 // Gains with linear interpolation at k*ratio, clamped to +-40 dB.
1369 for (int k = 0; k < numBins_; ++k)
1370 {
1371 const double pos = std::min(static_cast<double>(k) * ratioActive_,
1372 static_cast<double>(numBins_ - 1));
1373 const auto i0 = static_cast<int>(pos);
1374 const auto frac = static_cast<T>(pos - i0);
1375 const T target = envLog_[static_cast<size_t>(i0)]
1376 + (envLog_[static_cast<size_t>(std::min(i0 + 1, numBins_ - 1))]
1377 - envLog_[static_cast<size_t>(i0)]) * frac;
1378 const T delta = std::clamp(target - envLog_[static_cast<size_t>(k)],
1379 T(-4.6), T(4.6));
1380 formantGain_[static_cast<size_t>(k)] = std::exp(delta);
1381 }
1382 }
1383
1385 void synthesizeTail(int ch) noexcept
1386 {
1387 // Overlap-add at the fixed synthesis hop. sqrt-Hann analysis+synthesis
1388 // with Rs = N/4 overlap-adds to a constant 2.0, hence the 0.5 norm.
1389 auto& acc = accum_[static_cast<size_t>(ch)];
1390 const auto m = static_cast<int64_t>(accumMask_);
1391
1392 // The tail region [w + N - Rs, w + N) is entered for the first time by
1393 // this frame: clear the stale ring content before accumulating.
1394 for (int k = fftSize_ - synthHop_; k < fftSize_; ++k)
1395 acc[static_cast<size_t>((writeHead_ + k) & m)] = T(0);
1396
1397 constexpr T kNorm = T(0.5);
1398 for (int k = 0; k < fftSize_; ++k)
1399 {
1400 const auto idx = static_cast<size_t>((writeHead_ + k) & m);
1401 acc[idx] += fftResult_[static_cast<size_t>(k)]
1402 * window_[static_cast<size_t>(k)] * kNorm;
1403 }
1404 }
1405
1406 // -- Members -----------------------------------------------------------------
1407 double sampleRate_ = 48000.0;
1408 int numChannels_ = 0;
1409 bool prepared_ = false;
1410 bool resampleCompensation_ = false;
1411
1412 int fftSize_ = 2048;
1413 int numBins_ = 1025;
1414 int synthHop_ = 512;
1415 int ringMask_ = 2047;
1416 int accumSize_ = 8192;
1417 int accumMask_ = 8191;
1418
1419 std::unique_ptr<FFTReal<T>> fft_;
1420 std::vector<T> window_;
1421
1422 std::vector<std::vector<T>> inputRing_;
1423 std::vector<std::vector<T>> accum_;
1424
1425 std::vector<T> fftIn_, spec_, fftResult_;
1426 std::vector<T> prevAnalysis_, prevSynth_;
1427 std::vector<T> mag_, prevMag_;
1428 std::vector<T> cepsTime_, cepsSpec_;
1429 std::vector<T> envLog_, formantGain_;
1430 std::vector<T> rotRe_, rotIm_;
1431 std::vector<int> peakBin_;
1432 int numPeaks_ = 0;
1433
1434 // Onset detection. The frame-energy test needs one scalar; the flux
1435 // detector needs the log-frequency filterbank tables, the two band
1436 // frames the flux is taken between, and the flux history the firing
1437 // threshold's running median is taken over. Which of the two runs is the
1438 // owner's choice, fixed in prepare(), and only the selected one is
1439 // allocated for.
1440 bool fluxDetectorEnabled_ = false;
1441 double onsetEnv_ = 0.0;
1442 std::vector<int> fbStart_, fbOffset_, fbCount_;
1443 std::vector<T> fbWeights_;
1444 int numBands_ = 0;
1445 T odfScale_ = T(1);
1446 std::vector<T> bandCur_, bandPrev_, bandMaxPrev_;
1447 std::vector<double> fluxHist_;
1448 std::vector<double> fluxScratch_;
1449 int fluxPos_ = 0;
1450 int fluxFilled_ = 0;
1451
1452 // Transient-locked hop.
1453 bool lockEnabled_ = false;
1454 int lockFrames_ = 0;
1455 int lockLeft_ = 0;
1456 double debt_ = 0.0;
1457
1458 // Harmonic/percussive split (allocated only when the owner asked for it).
1459 bool separationEnabled_ = false;
1460 int timeMedian_ = 0;
1461 int freqMedian_ = 0;
1462 std::vector<T> magHist_;
1463 std::vector<T> medianScratch_;
1464 std::vector<T> maskHarm_, maskPerc_;
1465 std::vector<T> specRaw_;
1466 int histPos_ = 0;
1467 int histFilled_ = 0;
1468
1469 int inputPos_ = 0;
1470 int64_t writeHead_ = 0;
1471
1472 // Audio-thread-private active state: the stream owner writes these as
1473 // plain members and never publishes them through the staged channel.
1474 double stActive_ = 0.0;
1475 double ratioActive_ = 1.0;
1476 double hopCarry_ = 0.0;
1477 int analysisHop_ = 512;
1478 int inputSinceHop_ = 0;
1479 bool firstFrame_ = true;
1480
1481 // Audio-thread-private active copies of the published parameter set
1482 // (committed only by a validated bounded read; defaults == Params{}).
1483 double targetSemitones_ = 0.0;
1484 bool transientOn_ = true;
1485 bool formantOn_ = false;
1486 bool phaseLockOn_ = true;
1487 bool splitOn_ = false;
1488
1489 // Control -> audio staged parameter channel (canonical seqlock; the ONLY
1490 // cross-thread handoff in this class).
1491 std::atomic<double> stgSemitones_ { 0.0 };
1492 std::atomic<bool> stgTransient_ { true };
1493 std::atomic<bool> stgFormant_ { false };
1494 std::atomic<bool> stgPhaseLock_ { true };
1495 std::atomic<bool> stgSplit_ { false };
1496 std::atomic<unsigned> seq_ { 0 };
1497 std::atomic<bool> dirty_ { false };
1498};
1499
1500} // namespace detail
1501} // namespace dspark
Identity-phase-locked STFT analysis/synthesis core (internal).
void commitInput(int count, int numActiveChannels) noexcept
Advances the shared input position by count samples and runs one analysis->synthesis hop when the ana...
bool prepare(double sampleRate, int numChannels, int fftSize, bool resampleCompensation, bool separationSupport=false, bool transientLockedHop=false, bool spectralFluxDetector=false)
Allocates all rings and spectral state.
void pushInput(int ch, const T *src, int count) noexcept
Writes count samples (count <= samplesToNextHop()) into channel ch's analysis ring....
void publishParams(const Params &p) noexcept
Publishes a whole parameter set (control thread only).
void reset() noexcept
Clears all signal state and re-adopts the latest published parameters (jump, no glide)....
const T * olaData(int ch) const noexcept
Main namespace for the DSPark framework.
static void hann(T *output, int size, bool periodic=true) noexcept
Hann (raised cosine) window.
The control-published parameter set (adopted as ONE unit).
bool formantPreserve
Cepstral envelope pre-warp (resample-back owners).
bool phaseLock
Identity phase locking; false = plain vocoder.
bool percussiveSplit
Median-filter harmonic/percussive split.
double targetSemitones
Stretch/shift target, semitones, clamped +-12.
bool transientPreserve
Phase reset on detected transients.