diff --git a/README.md b/README.md index e917784..952942f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Requires React Native **0.75 or later** with the New Architecture enabled. ## How it works -Audio is captured through platform-native APIs (AVAudioEngine on iOS, Oboe on Android) and fed into a lock-free SPSC ring buffer. A C++ worker thread drains the buffer in fixed-size frames, runs the signal through a preprocessing pipeline, then through an ensemble of three pitch detectors (YIN, PYIN, cepstrum). The ensemble votes for the best estimate and fires an `onPitch` event on the JS thread. +Audio is captured through platform-native APIs (AVAudioEngine on iOS, Oboe on Android) and fed into a lock-free SPSC ring buffer. A C++ worker thread drains the buffer in fixed-size frames, runs the signal through a preprocessing pipeline, then through two complementary pitch detectors: probabilistic YIN (pYIN) as the precise primary estimator and a cepstrum detector as an independent corroborator. Their fused result fires an `onPitch` event on the JS thread. The audio callback allocates nothing at runtime — all working buffers are pre-allocated during initialization. @@ -196,10 +196,9 @@ The shared C++ core (`cpp/`) compiles as a static library on both platforms. |---|---|---| | High-pass filter | `BiquadHpf` | Direct-Form II Transposed, 70 Hz cutoff (configurable), Q 0.707 | | Windowing | `Window` | Hann window, precomputed coefficients | -| Pitch detection | `EnsembleSelector` | Runs YIN, PYIN, and cepstrum; votes by agreement within 1 semitone | -| — detector 1 | `YinPitchDetector` | YIN with parabolic interpolation | -| — detector 2 | `PyinPitchDetector` | Probabilistic YIN; prunes harmonic aliases | -| — detector 3 | `CepstrumPitchDetector` | Real cepstrum via radix-2 FFT; SNR-based confidence | +| Pitch detection | `DetectorFusion` | Fuses a precise primary detector with a coarse corroborator (agree/clash/solo rules within 1 semitone) | +| — primary | `PyinPitchDetector` | Probabilistic YIN (Beta-distributed threshold prior, per-candidate probability mass) | +| — corroborator | `CepstrumPitchDetector` | Real cepstrum via radix-2 FFT; peak-vs-rival prominence confidence | | Note mapping | `NoteMapper` | Hz → MIDI, note name, octave, cents deviation | | SNR estimation | `SnrEstimator` | Signal RMS vs. noise-floor EMA | | Post-processing | `PostProcessor` | Median-5 filter, EMA smoothing (configurable), note-transition hysteresis (configurable) | diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 958d939..2790f4e 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -7,7 +7,6 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(tuner_engine_core STATIC src/NoteMapper.cpp - src/YinPitchDetector.cpp src/Window.cpp src/BiquadHpf.cpp src/SnrEstimator.cpp @@ -18,7 +17,7 @@ add_library(tuner_engine_core STATIC src/AudioFrameDispatcher.cpp src/CepstrumPitchDetector.cpp src/PyinPitchDetector.cpp - src/EnsembleSelector.cpp + src/DetectorFusion.cpp src/StringMatcher.cpp ) diff --git a/cpp/include/CepstrumPitchDetector.hpp b/cpp/include/CepstrumPitchDetector.hpp index ac5a46f..ec84130 100644 --- a/cpp/include/CepstrumPitchDetector.hpp +++ b/cpp/include/CepstrumPitchDetector.hpp @@ -15,15 +15,15 @@ class CepstrumPitchDetector : public IPitchDetector { void reset() override {} void setFrequencyRange(float minHz, float maxHz) override; - void setThreshold(float threshold) override; + void setProminenceThreshold(float threshold); private: float sampleRate_; int frameSize_; - float minHz_ = 60.0f; - float maxHz_ = 1200.0f; - float threshold_ = 0.10f; // minimum peak prominence to be considered voiced + float minHz_ = 60.0f; + float maxHz_ = 1200.0f; + float prominenceThreshold_ = 0.10f; // minimum peak prominence to be considered voiced std::vector hann_; std::vector> fftBuf_; diff --git a/cpp/include/DetectorFusion.hpp b/cpp/include/DetectorFusion.hpp new file mode 100644 index 0000000..2c53d89 --- /dev/null +++ b/cpp/include/DetectorFusion.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "IPitchDetector.hpp" +#include + +// Fuses two complementary pitch detectors into a single estimate: +// - primary (pYIN): precise time-domain frequency, the pitch we report +// - corroborator (cepstrum): coarser spectral estimate, independent evidence +// +// Their frequencies are never averaged — the two estimates have very different +// resolutions, so mixing them would only blur the precise one. The corroborator +// exists to confirm or contest the primary: +// - agree → primary's frequency, confidences combined as independent evidence +// - clash → still the primary's frequency, but its confidence dampened in +// proportion to the corroborator's conviction +// - solo → the only voiced result, mildly dampened (less if it's the primary) +class DetectorFusion : public IPitchDetector { +public: + DetectorFusion(std::unique_ptr primary, + std::unique_ptr corroborator); + + DetectorResult detect(const float* frame, int frameLength, float sampleRate) override; + + void reset() override; + void setFrequencyRange(float minHz, float maxHz) override; + +private: + std::unique_ptr primary_; + std::unique_ptr corroborator_; + + static float semitoneDistance(float frequencyA, float frequencyB); +}; diff --git a/cpp/include/EnsembleSelector.hpp b/cpp/include/EnsembleSelector.hpp deleted file mode 100644 index 0a9624a..0000000 --- a/cpp/include/EnsembleSelector.hpp +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include "IPitchDetector.hpp" -#include -#include - -// Runs every sub-detector on the same frame and selects the best result. -// Detectors that agree within one semitone reinforce each other (confidence bonus). -// A lone detector with no agreement receives a confidence penalty. -class EnsembleSelector : public IPitchDetector { -public: - explicit EnsembleSelector(std::vector> detectors); - - DetectorResult detect(const float* frame, int n, float sampleRate) override; - - void reset() override; - void setFrequencyRange(float minHz, float maxHz) override; - void setThreshold(float threshold) override; - -private: - struct VoicedEntry { int idx; float freq; float conf; int votes; }; - - std::vector> detectors_; - std::vector resultsBuf_; - std::vector voicedBuf_; - - static bool withinSemitones(float f1, float f2, float tolerance = 1.0f); -}; diff --git a/cpp/include/IPitchDetector.hpp b/cpp/include/IPitchDetector.hpp index 1bd65a0..4c50244 100644 --- a/cpp/include/IPitchDetector.hpp +++ b/cpp/include/IPitchDetector.hpp @@ -10,12 +10,11 @@ class IPitchDetector { public: virtual ~IPitchDetector() = default; - // Detect pitch from a pre-filtered, windowed frame of length n at the given sample rate. + // Detect pitch from a pre-filtered frame of length n at the given sample rate. virtual DetectorResult detect(const float* frame, int n, float sampleRate) = 0; - // Reset any inter-frame state (Viterbi, smoothing, etc.). + // Reset any inter-frame state (pitch tracking, smoothing, etc.). virtual void reset() {} virtual void setFrequencyRange(float minHz, float maxHz) = 0; - virtual void setThreshold(float threshold) = 0; }; diff --git a/cpp/include/PyinPitchDetector.hpp b/cpp/include/PyinPitchDetector.hpp index 88bb40e..d169924 100644 --- a/cpp/include/PyinPitchDetector.hpp +++ b/cpp/include/PyinPitchDetector.hpp @@ -3,33 +3,48 @@ #include "IPitchDetector.hpp" #include -// Probabilistic YIN (PYIN) — unlike plain YIN, it collects every CMND local minimum -// below threshold and picks the most probable one rather than the first. -// This eliminates the octave errors that occur when the first minimum corresponds -// to a harmonic period rather than the fundamental. +// Probabilistic YIN (pYIN, Mauch & Dixon 2014) — frame-wise stage. +// +// Plain YIN picks the first CMND minimum below one fixed threshold, so a single +// unlucky threshold choice produces octave errors. pYIN instead treats the +// threshold as a random variable with a Beta(2, 18) prior: every CMND local +// minimum accumulates the prior mass of all thresholds at which YIN would have +// picked it. The candidate with the most mass wins. +// +// The paper's HMM/Viterbi tracking stage is replaced by two lighter mechanisms +// suited to real-time tuning: a small selection bonus for candidates near the +// previous frame's pitch (here), and the pipeline's PostProcessor +// (median + EMA + hysteresis) downstream. class PyinPitchDetector : public IPitchDetector { public: PyinPitchDetector(float sampleRate, int frameSize); - DetectorResult detect(const float* frame, int n, float sampleRate) override; + DetectorResult detect(const float* frame, int frameLength, float sampleRate) override; - void reset() override {} + void reset() override; void setFrequencyRange(float minHz, float maxHz) override; - void setThreshold(float threshold) override; private: + struct PitchCandidate { + int lag; // integer sample lag of the CMND local minimum + float cmndDepth; // CMND value at the minimum (lower = more periodic) + float probability; // pYIN mass accumulated across all thresholds + }; + float sampleRate_; int frameSize_; - float minHz_ = 60.0f; - float maxHz_ = 1200.0f; - float threshold_ = 0.25f; // wider than YIN — captures all plausible candidates + float minFrequencyHz_ = 60.0f; + float maxFrequencyHz_ = 1200.0f; + + std::vector squaredDifference_; // YIN step 2, indexed by lag + std::vector normalizedDifference_; // CMND, YIN step 3, indexed by lag + std::vector candidates_; // pre-allocated, reused each frame - std::vector diff_; - std::vector cmnd_; + std::vector thresholdLevels_; // equally spaced YIN thresholds in (0, 1] + std::vector thresholdPriors_; // Beta(2, 18) weight per level, sums to 1 - struct Candidate { int tau; float prob; }; - std::vector candidates_; // pre-allocated, reused each frame + float previousPitchHz_ = 0.0f; // continuity-bonus target; 0 = no history - float parabolicInterpolation(int tau) const; + float refineLagByParabola(int lag) const; }; diff --git a/cpp/include/YinPitchDetector.hpp b/cpp/include/YinPitchDetector.hpp deleted file mode 100644 index d9e54a2..0000000 --- a/cpp/include/YinPitchDetector.hpp +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include "IPitchDetector.hpp" -#include - -struct YinResult { - bool hasPitch = false; - float frequency = 0.0f; - float confidence = 0.0f; -}; - -class YinPitchDetector : public IPitchDetector { -public: - YinPitchDetector(float sampleRate, int frameSize); - - // Legacy interface used by existing tests - YinResult detect(const float* input, int frameCount); - - // IPitchDetector — delegates to the above - DetectorResult detect(const float* frame, int n, float sampleRate) override; - - void setFrequencyRange(float minFrequency, float maxFrequency) override; - void setThreshold(float threshold) override; - -private: - float sampleRate_; - int frameSize_; - - float minFrequency_ = 60.0f; - float maxFrequency_ = 1200.0f; - float threshold_ = 0.15f; - - std::vector difference_; - std::vector cmnd_; - - float parabolicInterpolation(int tau) const; -}; diff --git a/cpp/src/CepstrumPitchDetector.cpp b/cpp/src/CepstrumPitchDetector.cpp index 8bbe122..b77b6fb 100644 --- a/cpp/src/CepstrumPitchDetector.cpp +++ b/cpp/src/CepstrumPitchDetector.cpp @@ -24,8 +24,8 @@ void CepstrumPitchDetector::setFrequencyRange(float minHz, float maxHz) { maxHz_ = maxHz; } -void CepstrumPitchDetector::setThreshold(float threshold) { - threshold_ = threshold; +void CepstrumPitchDetector::setProminenceThreshold(float threshold) { + prominenceThreshold_ = threshold; } DetectorResult CepstrumPitchDetector::detect(const float* frame, int n, float sampleRate) { @@ -63,17 +63,23 @@ DetectorResult CepstrumPitchDetector::detect(const float* frame, int n, float sa const int tauMax = std::min(frameSize_ / 2 - 1, static_cast(sr / minHz_)); if (tauMin >= tauMax) return DetectorResult{}; - // Find the quefrency peak - float maxVal = -1e30f; + // Find the tallest strict local maximum in the quefrency range. The global + // maximum often sits on the range boundary where the low-quefrency envelope + // tail is still decaying — that is not a periodicity peak, and accepting it + // produced confident junk detections pinned to the frequency-range edge. + float maxVal = -1e30f; int bestTau = -1; - for (int q = tauMin; q <= tauMax; ++q) { + for (int q = tauMin + 1; q < tauMax; ++q) { const float v = fftBuf_[q].real(); - if (v > maxVal) { maxVal = v; bestTau = q; } + if (v > fftBuf_[q - 1].real() && v >= fftBuf_[q + 1].real() && v > maxVal) { + maxVal = v; + bestTau = q; + } } if (bestTau < 0) return DetectorResult{}; const float prominence = peakProminence(tauMin, tauMax, bestTau); - if (prominence < threshold_) return DetectorResult{}; + if (prominence < prominenceThreshold_) return DetectorResult{}; // Sub-sample refinement via parabolic interpolation float peakTau = static_cast(bestTau); @@ -94,23 +100,29 @@ DetectorResult CepstrumPitchDetector::detect(const float* frame, int n, float sa float CepstrumPitchDetector::peakProminence(int tauMin, int tauMax, int peakTau) const { const float peak = fftBuf_[peakTau].real(); - // RMS and mean of the quefrency range (excluding the peak itself) + // Mean of the quefrency range — the baseline the peaks rise above. float sum = 0.0f; - float sumSq = 0.0f; const int count = tauMax - tauMin + 1; for (int q = tauMin; q <= tauMax; ++q) { - const float v = fftBuf_[q].real(); - sum += v; - sumSq += v * v; + sum += fftBuf_[q].real(); } - const float mean = sum / static_cast(count); - const float rms = std::sqrt(sumSq / static_cast(count)); - - // Confidence: how much the peak exceeds the RMS level. - // A cepstrum with no clear periodicity (pure sine) has peak ≈ RMS → conf ≈ 0. - // A strong harmonic signal has peak >> RMS → conf → 1. - if (rms < kEps) return 0.0f; - const float snr = (peak - mean) / rms; - // Map snr range [0, 5] → [0, 1]; cap both sides - return std::max(0.0f, std::min(1.0f, snr / 5.0f)); + const float mean = sum / static_cast(count); + + const float peakHeight = peak - mean; + if (peakHeight < kEps) return 0.0f; + + // Highest rival: the tallest value outside the peak's own ±10% neighbourhood. + // A truly harmonic signal has one dominant quefrency peak; a pure sine or + // noise produces several comparable peaks, so the rival nearly matches the + // peak and the prominence collapses. Scale-invariant by construction — + // no fixed SNR scale to saturate. + const int exclusionRadius = std::max(1, peakTau / 10); + float rival = -1e30f; + for (int q = tauMin; q <= tauMax; ++q) { + if (std::abs(q - peakTau) <= exclusionRadius) continue; + rival = std::max(rival, fftBuf_[q].real()); + } + const float rivalHeight = std::max(0.0f, rival - mean); + + return std::max(0.0f, std::min(1.0f, 1.0f - rivalHeight / peakHeight)); } diff --git a/cpp/src/DetectorFusion.cpp b/cpp/src/DetectorFusion.cpp new file mode 100644 index 0000000..5a91a3b --- /dev/null +++ b/cpp/src/DetectorFusion.cpp @@ -0,0 +1,78 @@ +#include "DetectorFusion.hpp" + +#include + +namespace { + +// Detectors within this distance count as agreeing on the same pitch. +constexpr float kAgreementSemitones = 1.0f; + +// On a clash the primary's confidence is dampened in proportion to how +// credible the contesting corroborator is: a barely-voiced contester costs +// little, a fully confident one cuts the confidence to (1 - kClashPenaltyMax). +constexpr float kClashPenaltyMax = 0.4f; + +// Dampening when only one detector fires. The primary alone is still fairly +// trustworthy; the corroborator alone is not precise enough to trust fully. +constexpr float kSoloPrimaryDamping = 0.9f; +constexpr float kSoloCorroboratorDamping = 0.7f; + +} // namespace + +DetectorFusion::DetectorFusion(std::unique_ptr primary, + std::unique_ptr corroborator) + : primary_(std::move(primary)) + , corroborator_(std::move(corroborator)) +{} + +void DetectorFusion::reset() { + primary_->reset(); + corroborator_->reset(); +} + +void DetectorFusion::setFrequencyRange(float minHz, float maxHz) { + primary_->setFrequencyRange(minHz, maxHz); + corroborator_->setFrequencyRange(minHz, maxHz); +} + +float DetectorFusion::semitoneDistance(float frequencyA, float frequencyB) { + if (frequencyA <= 0.0f || frequencyB <= 0.0f) return 1e9f; + return std::fabs(12.0f * std::log2(frequencyA / frequencyB)); +} + +DetectorResult DetectorFusion::detect(const float* frame, int frameLength, float sampleRate) { + const DetectorResult primary = primary_->detect(frame, frameLength, sampleRate); + const DetectorResult corroborator = corroborator_->detect(frame, frameLength, sampleRate); + + const bool primaryVoiced = primary.voiced && primary.confidence > 0.0f; + const bool corroboratorVoiced = corroborator.voiced && corroborator.confidence > 0.0f; + + if (!primaryVoiced && !corroboratorVoiced) return DetectorResult{}; + + if (primaryVoiced && corroboratorVoiced) { + if (semitoneDistance(primary.frequency, corroborator.frequency) <= kAgreementSemitones) { + // Agreement: report the primary's precise frequency. Treat the two + // detectors as independent witnesses for the confidence: + // P(pitch is right) = 1 - P(both witnesses are wrong). + const float combinedConfidence = + 1.0f - (1.0f - primary.confidence) * (1.0f - corroborator.confidence); + return DetectorResult{true, primary.frequency, combinedConfidence}; + } + + // Clash — usually an octave error in one detector. The corroborator is + // too coarse to replace the primary's estimate, and its confidence is + // not on the same scale as the primary's, so it never overrides — it + // only weakens the primary in proportion to its own conviction. If the + // primary really is wrong, the low fused confidence lets the pipeline + // reject the frame rather than display a wrong pitch. + const float damping = 1.0f - kClashPenaltyMax * corroborator.confidence; + return DetectorResult{true, primary.frequency, primary.confidence * damping}; + } + + if (primaryVoiced) { + return DetectorResult{true, primary.frequency, + primary.confidence * kSoloPrimaryDamping}; + } + return DetectorResult{true, corroborator.frequency, + corroborator.confidence * kSoloCorroboratorDamping}; +} diff --git a/cpp/src/EnsembleSelector.cpp b/cpp/src/EnsembleSelector.cpp deleted file mode 100644 index 0219a44..0000000 --- a/cpp/src/EnsembleSelector.cpp +++ /dev/null @@ -1,87 +0,0 @@ -#include "EnsembleSelector.hpp" - -#include - -EnsembleSelector::EnsembleSelector(std::vector> detectors) - : detectors_(std::move(detectors)) -{ - resultsBuf_.resize(detectors_.size()); - voicedBuf_.reserve(detectors_.size()); -} - -void EnsembleSelector::reset() { - for (auto& d : detectors_) d->reset(); -} - -void EnsembleSelector::setFrequencyRange(float minHz, float maxHz) { - for (auto& d : detectors_) d->setFrequencyRange(minHz, maxHz); -} - -void EnsembleSelector::setThreshold(float threshold) { - for (auto& d : detectors_) d->setThreshold(threshold); -} - -bool EnsembleSelector::withinSemitones(float f1, float f2, float tolerance) { - if (f1 <= 0.0f || f2 <= 0.0f) return false; - return std::fabs(12.0f * std::log2(f1 / f2)) <= tolerance; -} - -DetectorResult EnsembleSelector::detect(const float* frame, int n, float sampleRate) { - // Run all detectors into the pre-allocated buffer - for (int i = 0; i < static_cast(detectors_.size()); ++i) { - resultsBuf_[i] = detectors_[i]->detect(frame, n, sampleRate); - } - - voicedBuf_.clear(); - for (int i = 0; i < static_cast(resultsBuf_.size()); ++i) { - const auto& r = resultsBuf_[i]; - if (r.voiced && r.confidence > 0.0f) { - voicedBuf_.push_back({i, r.frequency, r.confidence, 0}); - } - } - - if (voicedBuf_.empty()) return DetectorResult{}; - - // Tally agreement votes between voiced entries - for (int i = 0; i < static_cast(voicedBuf_.size()); ++i) { - for (int j = i + 1; j < static_cast(voicedBuf_.size()); ++j) { - if (withinSemitones(voicedBuf_[i].freq, voicedBuf_[j].freq)) { - ++voicedBuf_[i].votes; - ++voicedBuf_[j].votes; - } - } - } - - // Pick winner: most votes first, then highest confidence - const VoicedEntry* best = &voicedBuf_[0]; - for (int i = 1; i < static_cast(voicedBuf_.size()); ++i) { - const VoicedEntry& c = voicedBuf_[i]; - if (c.votes > best->votes - || (c.votes == best->votes && c.conf > best->conf)) { - best = &c; - } - } - - // Average the frequency (and confidence) of all detectors that agree with winner - float freqSum = best->freq; - float confSum = best->conf; - int agreeing = 1; - - for (int i = 0; i < static_cast(voicedBuf_.size()); ++i) { - if (&voicedBuf_[i] != best && withinSemitones(voicedBuf_[i].freq, best->freq)) { - freqSum += voicedBuf_[i].freq; - confSum += voicedBuf_[i].conf; - ++agreeing; - } - } - - const float avgFreq = freqSum / static_cast(agreeing); - float avgConf = confSum / static_cast(agreeing); - - // Confidence bonus for agreement, penalty for a lone detector - avgConf = (agreeing > 1) - ? std::min(1.0f, avgConf * 1.1f) - : avgConf * 0.85f; - - return DetectorResult{true, avgFreq, avgConf}; -} diff --git a/cpp/src/PyinPitchDetector.cpp b/cpp/src/PyinPitchDetector.cpp index ecc7b84..ca180d6 100644 --- a/cpp/src/PyinPitchDetector.cpp +++ b/cpp/src/PyinPitchDetector.cpp @@ -3,132 +3,176 @@ #include #include +namespace { + +// Number of discrete levels the Beta threshold prior is sampled at. +constexpr int kThresholdCount = 100; + +// Beta(2, 18) — mean 0.1, the threshold prior used in the pYIN paper. +constexpr float kBetaAlpha = 2.0f; +constexpr float kBetaBeta = 18.0f; + +// When no minimum clears a threshold, the deepest minimum still receives this +// fraction of that threshold's prior mass (the paper's "no candidate" fallback). +constexpr float kNoCandidateFallbackWeight = 0.01f; + +// Candidates within this distance of the previous frame's pitch get a selection +// bonus — a lightweight stand-in for the paper's HMM tracking stage. +constexpr float kContinuitySemitones = 0.75f; +constexpr float kContinuityBonus = 1.2f; + +} // namespace + PyinPitchDetector::PyinPitchDetector(float sampleRate, int frameSize) : sampleRate_(sampleRate) , frameSize_(frameSize) { - diff_.resize(static_cast(frameSize_ / 2)); - cmnd_.resize(static_cast(frameSize_ / 2)); - candidates_.reserve(32); // typical number of CMND minima + squaredDifference_.resize(static_cast(frameSize_ / 2)); + normalizedDifference_.resize(static_cast(frameSize_ / 2)); + candidates_.reserve(32); // typical upper bound on CMND minima per frame + + // Discretize the Beta(2, 18) prior once; detect() only does lookups. + thresholdLevels_.resize(kThresholdCount); + thresholdPriors_.resize(kThresholdCount); + float priorSum = 0.0f; + for (int i = 0; i < kThresholdCount; ++i) { + const float threshold = static_cast(i + 1) / static_cast(kThresholdCount); + thresholdLevels_[i] = threshold; + thresholdPriors_[i] = std::pow(threshold, kBetaAlpha - 1.0f) + * std::pow(1.0f - threshold, kBetaBeta - 1.0f); + priorSum += thresholdPriors_[i]; + } + for (float& prior : thresholdPriors_) prior /= priorSum; } void PyinPitchDetector::setFrequencyRange(float minHz, float maxHz) { - minHz_ = minHz; - maxHz_ = maxHz; + minFrequencyHz_ = minHz; + maxFrequencyHz_ = maxHz; } -void PyinPitchDetector::setThreshold(float threshold) { - threshold_ = threshold; +void PyinPitchDetector::reset() { + previousPitchHz_ = 0.0f; } -DetectorResult PyinPitchDetector::detect(const float* frame, int n, float sampleRate) { - const float sr = sampleRate > 0.0f ? sampleRate : sampleRate_; +DetectorResult PyinPitchDetector::detect(const float* frame, int frameLength, float sampleRate) { + const float rate = sampleRate > 0.0f ? sampleRate : sampleRate_; - if (!frame || n < frameSize_) return DetectorResult{}; + if (!frame || frameLength < frameSize_) return DetectorResult{}; - const int tauMin = std::max(2, static_cast(sr / maxHz_)); - const int tauMax = std::min( - frameSize_ / 2 - 1, - static_cast(sr / minHz_) + const int minLag = std::max(2, static_cast(rate / maxFrequencyHz_)); + const int maxLag = std::min( + frameSize_ / 2 - 2, // leave room for the lag+1 neighbour reads below + static_cast(rate / minFrequencyHz_) ); + if (minLag >= maxLag) return DetectorResult{}; - if (tauMin >= tauMax) return DetectorResult{}; - - // Squared difference function (YIN step 2) - std::fill(diff_.begin(), diff_.end(), 0.0f); - for (int tau = 1; tau <= tauMax; ++tau) { + // YIN step 2: squared difference between the frame and its lagged copy. + // Computed one lag past maxLag so the minima scan can look at lag+1. + const int lagLimit = maxLag + 1; + for (int lag = 1; lag <= lagLimit; ++lag) { float sum = 0.0f; - for (int i = 0; i < frameSize_ - tau; ++i) { - const float d = frame[i] - frame[i + tau]; - sum += d * d; + for (int i = 0; i < frameSize_ - lag; ++i) { + const float delta = frame[i] - frame[i + lag]; + sum += delta * delta; } - diff_[tau] = sum; + squaredDifference_[lag] = sum; } - // Cumulative mean normalised difference (CMND, YIN step 3) - cmnd_[0] = 1.0f; - float runningSum = 0.0f; - for (int tau = 1; tau <= tauMax; ++tau) { - runningSum += diff_[tau]; - cmnd_[tau] = (runningSum <= 0.0f) - ? 1.0f - : diff_[tau] * static_cast(tau) / runningSum; + // YIN step 3: cumulative-mean-normalized difference (CMND). + normalizedDifference_[0] = 1.0f; + float differenceSum = 0.0f; + for (int lag = 1; lag <= lagLimit; ++lag) { + differenceSum += squaredDifference_[lag]; + normalizedDifference_[lag] = (differenceSum <= 0.0f) + ? 1.0f + : squaredDifference_[lag] * static_cast(lag) / differenceSum; } - // Collect all local minima of CMND below threshold — this is the PYIN divergence point. - // YIN stops at the first; PYIN considers all. + // Every CMND local minimum in the lag range is a pitch candidate. candidates_.clear(); - - auto tryAdd = [&](int tau) { - if (tau < tauMin || tau > tauMax) return; - if (cmnd_[tau] < threshold_) { - candidates_.push_back({tau, 1.0f - cmnd_[tau] / threshold_}); - } - }; - - // Interior local minima - for (int tau = tauMin + 1; tau < tauMax; ++tau) { - if (cmnd_[tau] < cmnd_[tau - 1] && cmnd_[tau] < cmnd_[tau + 1]) { - tryAdd(tau); + for (int lag = minLag; lag <= maxLag; ++lag) { + const float left = normalizedDifference_[lag - 1]; + const float here = normalizedDifference_[lag]; + const float right = normalizedDifference_[lag + 1]; + if (here < left && here <= right) { + candidates_.push_back({lag, here, 0.0f}); } } - // Boundary checks - if (cmnd_[tauMin] < cmnd_[tauMin + 1]) tryAdd(tauMin); - if (cmnd_[tauMax] < cmnd_[tauMax - 1]) tryAdd(tauMax); - if (candidates_.empty()) return DetectorResult{}; - // The CMND formula makes later (longer-period) minima numerically smaller than - // earlier ones at sub-multiples of the same fundamental period. Without pruning, - // a pure sine would always produce candidates at tau0, 2*tau0, 3*tau0 … with the - // highest probability at the LONGEST alias — the wrong (sub-octave) answer. - // - // Prune: for any candidate whose tau is within 2% of an integer multiple (≥2×) of - // a shorter-period candidate, zero its probability. Candidates are already in - // ascending tau order, so a single forward pass suffices. - for (int i = 0; i < static_cast(candidates_.size()); ++i) { - if (candidates_[i].prob == 0.0f) continue; - for (int j = i + 1; j < static_cast(candidates_.size()); ++j) { - const float ratio = static_cast(candidates_[j].tau) - / static_cast(candidates_[i].tau); - const float nearest = std::round(ratio); - if (nearest >= 2.0f && std::fabs(ratio - nearest) / nearest < 0.02f) { - candidates_[j].prob = 0.0f; + PitchCandidate* deepest = &candidates_[0]; + for (auto& candidate : candidates_) { + if (candidate.cmndDepth < deepest->cmndDepth) deepest = &candidate; + } + + // pYIN core: accumulate probability mass over Beta-distributed thresholds. + // For each threshold, YIN's rule picks the first (lowest-lag) minimum below + // it — so each candidate's mass is the prior probability of the thresholds + // at which YIN would have chosen it. + for (int i = 0; i < kThresholdCount; ++i) { + const float threshold = thresholdLevels_[i]; + PitchCandidate* firstBelowThreshold = nullptr; + for (auto& candidate : candidates_) { + if (candidate.cmndDepth < threshold) { + firstBelowThreshold = &candidate; + break; } } + if (firstBelowThreshold) { + firstBelowThreshold->probability += thresholdPriors_[i]; + } else { + deepest->probability += thresholdPriors_[i] * kNoCandidateFallbackWeight; + } } - // Sum surviving probabilities for voiced confidence - float totalProb = 0.0f; - for (const auto& c : candidates_) totalProb += c.prob; - - // Pick the candidate with the highest (surviving) probability. - // Strict '>' keeps the first (lowest-tau, highest-frequency) on exact ties. - const Candidate* winner = nullptr; - for (const auto& c : candidates_) { - if (!winner || c.prob > winner->prob) winner = &c; + // Winner = highest mass, with a small bonus for staying near the previous + // pitch. The bonus only affects the ranking; reported confidence uses the + // unbiased mass so a sustained note cannot inflate its own confidence. + const PitchCandidate* winner = nullptr; + float bestScore = 0.0f; + for (const auto& candidate : candidates_) { + if (candidate.probability <= 0.0f) continue; + float score = candidate.probability; + if (previousPitchHz_ > 0.0f) { + const float candidateHz = rate / static_cast(candidate.lag); + const float semitonesAway = + std::fabs(12.0f * std::log2(candidateHz / previousPitchHz_)); + if (semitonesAway <= kContinuitySemitones) score *= kContinuityBonus; + } + if (!winner || score > bestScore) { + winner = &candidate; + bestScore = score; + } } - if (!winner || winner->prob == 0.0f) return DetectorResult{}; - - const float betterTau = parabolicInterpolation(winner->tau); - if (betterTau <= 0.0f) return DetectorResult{}; - - // Confidence: voiced probability × how much the winner dominates - const float voicedProb = std::min(1.0f, totalProb); - const float winnerShare = (totalProb > 0.0f) ? winner->prob / totalProb : 0.0f; - const float confidence = voicedProb * (0.5f + 0.5f * winnerShare); - - return DetectorResult{true, sr / betterTau, confidence}; + if (!winner) return DetectorResult{}; + + const float refinedLag = refineLagByParabola(winner->lag); + if (refinedLag <= 0.0f) return DetectorResult{}; + const float pitchHz = rate / refinedLag; + + // Confidence = periodicity strength × share of the pYIN mass the winner + // captured. Both factors are in [0, 1]: a clean periodic frame with an + // unambiguous winner scores near 1, an ambiguous or aperiodic frame scores + // low. The pYIN mass decides WHICH candidate wins; the CMND depth keeps the + // scale calibrated to the pipeline's confidence threshold. + float totalMass = 0.0f; + for (const auto& candidate : candidates_) totalMass += candidate.probability; + const float winnerMassShare = totalMass > 0.0f ? winner->probability / totalMass : 0.0f; + const float periodicity = std::max(0.0f, 1.0f - winner->cmndDepth); + const float confidence = periodicity * winnerMassShare; + + previousPitchHz_ = pitchHz; + return DetectorResult{true, pitchHz, confidence}; } -float PyinPitchDetector::parabolicInterpolation(int tau) const { - if (tau <= 0 || tau >= static_cast(cmnd_.size()) - 1) { - return static_cast(tau); +float PyinPitchDetector::refineLagByParabola(int lag) const { + if (lag <= 0 || lag >= static_cast(normalizedDifference_.size()) - 1) { + return static_cast(lag); } - const float L = cmnd_[tau - 1]; - const float C = cmnd_[tau]; - const float R = cmnd_[tau + 1]; - const float d = L - 2.0f * C + R; - if (std::fabs(d) < 1e-6f) return static_cast(tau); - return tau + 0.5f * (L - R) / d; + const float left = normalizedDifference_[lag - 1]; + const float center = normalizedDifference_[lag]; + const float right = normalizedDifference_[lag + 1]; + const float curvature = left - 2.0f * center + right; + if (std::fabs(curvature) < 1e-6f) return static_cast(lag); + return static_cast(lag) + 0.5f * (left - right) / curvature; } diff --git a/cpp/src/TunerEngine.cpp b/cpp/src/TunerEngine.cpp index 71fb5cc..82a14e4 100644 --- a/cpp/src/TunerEngine.cpp +++ b/cpp/src/TunerEngine.cpp @@ -1,21 +1,17 @@ #include "TunerEngine.hpp" #include "CepstrumPitchDetector.hpp" -#include "EnsembleSelector.hpp" +#include "DetectorFusion.hpp" #include "PyinPitchDetector.hpp" #include "TuningPresets.hpp" -#include "YinPitchDetector.hpp" #include -#include TunerEngine::TunerEngine(float sampleRate, int frameSize) { - std::vector> detectors; - detectors.push_back(std::make_unique(sampleRate, frameSize)); - detectors.push_back(std::make_unique(sampleRate, frameSize)); - detectors.push_back(std::make_unique(sampleRate, frameSize)); - - auto ensemble = std::make_unique(std::move(detectors)); - pipeline_ = std::make_unique(frameSize, sampleRate, std::move(ensemble)); + auto fusion = std::make_unique( + std::make_unique(sampleRate, frameSize), + std::make_unique(sampleRate, frameSize) + ); + pipeline_ = std::make_unique(frameSize, sampleRate, std::move(fusion)); } PitchResult TunerEngine::process(const float* input, int frameCount) { diff --git a/cpp/src/YinPitchDetector.cpp b/cpp/src/YinPitchDetector.cpp deleted file mode 100644 index a2ae85d..0000000 --- a/cpp/src/YinPitchDetector.cpp +++ /dev/null @@ -1,118 +0,0 @@ -#include "YinPitchDetector.hpp" - -#include -#include - -YinPitchDetector::YinPitchDetector(float sampleRate, int frameSize) - : sampleRate_(sampleRate), frameSize_(frameSize) { - difference_.resize(frameSize_ / 2); - cmnd_.resize(frameSize_ / 2); -} - -void YinPitchDetector::setFrequencyRange(float minFrequency, float maxFrequency) { - minFrequency_ = minFrequency; - maxFrequency_ = maxFrequency; -} - -void YinPitchDetector::setThreshold(float threshold) { - threshold_ = threshold; -} - -YinResult YinPitchDetector::detect(const float* input, int frameCount) { - YinResult result; - - if (input == nullptr || frameCount < frameSize_) { - return result; - } - - const int tauMin = std::max(2, static_cast(sampleRate_ / maxFrequency_)); - const int tauMax = std::min( - frameSize_ / 2 - 1, - static_cast(sampleRate_ / minFrequency_) - ); - - std::fill(difference_.begin(), difference_.end(), 0.0f); - - for (int tau = 1; tau <= tauMax; ++tau) { - float sum = 0.0f; - - for (int i = 0; i < frameSize_ - tau; ++i) { - const float delta = input[i] - input[i + tau]; - sum += delta * delta; - } - - difference_[tau] = sum; - } - - cmnd_[0] = 1.0f; - - float runningSum = 0.0f; - - for (int tau = 1; tau <= tauMax; ++tau) { - runningSum += difference_[tau]; - - if (runningSum <= 0.0f) { - cmnd_[tau] = 1.0f; - } else { - cmnd_[tau] = difference_[tau] * tau / runningSum; - } - } - - int tauEstimate = -1; - - for (int tau = tauMin; tau <= tauMax; ++tau) { - if (cmnd_[tau] < threshold_) { - while (tau + 1 <= tauMax && cmnd_[tau + 1] < cmnd_[tau]) { - tau++; - } - - tauEstimate = tau; - break; - } - } - - if (tauEstimate == -1) { - return result; - } - - const float betterTau = parabolicInterpolation(tauEstimate); - - if (betterTau <= 0.0f) { - return result; - } - - result.hasPitch = true; - result.frequency = sampleRate_ / betterTau; - result.confidence = 1.0f - cmnd_[tauEstimate]; - - return result; -} - -DetectorResult YinPitchDetector::detect(const float* frame, int n, float sampleRate) { - // Store the passed sampleRate temporarily if it differs (Pipeline may have resampled) - const float savedSr = sampleRate_; - if (sampleRate > 0.0f && sampleRate != sampleRate_) { - sampleRate_ = sampleRate; - } - YinResult r = detect(frame, n); - sampleRate_ = savedSr; - return DetectorResult{ r.hasPitch, r.frequency, r.confidence }; -} - -float YinPitchDetector::parabolicInterpolation(int tau) const { - if (tau <= 0 || tau >= static_cast(cmnd_.size()) - 1) { - return static_cast(tau); - } - - const float left = cmnd_[tau - 1]; - const float center = cmnd_[tau]; - const float right = cmnd_[tau + 1]; - - const float denominator = left - 2.0f * center + right; - - if (std::fabs(denominator) < 1e-6f) { - return static_cast(tau); - } - - return tau + 0.5f * (left - right) / denominator; -} \ No newline at end of file diff --git a/cpp/tests/bench.cpp b/cpp/tests/bench.cpp index 78be6ac..7e14491 100644 --- a/cpp/tests/bench.cpp +++ b/cpp/tests/bench.cpp @@ -1,7 +1,6 @@ -#include "YinPitchDetector.hpp" #include "PyinPitchDetector.hpp" #include "CepstrumPitchDetector.hpp" -#include "EnsembleSelector.hpp" +#include "DetectorFusion.hpp" #include "Pipeline.hpp" #include @@ -10,15 +9,14 @@ #include #include -static constexpr float BENCH_SR = 48000.0f; -static constexpr int BENCH_FRAME = 2048; -static constexpr int WARMUP = 10; -static constexpr int ITERS = 200; -static constexpr float BENCH_PI = 3.14159265358979f; +static constexpr float BENCH_SR = 48000.0f; +static constexpr int WARMUP = 10; +static constexpr int ITERS = 200; +static constexpr float BENCH_PI = 3.14159265358979f; -static std::vector makeSine(float freq) { - std::vector buf(BENCH_FRAME); - for (int i = 0; i < BENCH_FRAME; ++i) +static std::vector makeSine(float freq, int frameSize) { + std::vector buf(frameSize); + for (int i = 0; i < frameSize; ++i) buf[i] = 0.8f * std::sin(2.0f * BENCH_PI * freq * static_cast(i) / BENCH_SR); return buf; } @@ -28,133 +26,57 @@ static double nsPerFrame(std::chrono::high_resolution_clock::time_point t0, return std::chrono::duration(t1 - t0).count() / static_cast(ITERS); } -int main() { - const auto buf = makeSine(440.0f); +template +static double benchmark(ProcessFn&& process) { + for (int i = 0; i < WARMUP; ++i) process(); + auto t0 = std::chrono::high_resolution_clock::now(); + for (int i = 0; i < ITERS; ++i) process(); + auto t1 = std::chrono::high_resolution_clock::now(); + return nsPerFrame(t0, t1); +} - // --- YIN --- - double ns_yin = 0.0; - { - YinPitchDetector yin(BENCH_SR, BENCH_FRAME); - for (int i = 0; i < WARMUP; ++i) yin.detect(buf.data(), BENCH_FRAME, BENCH_SR); - auto t0 = std::chrono::high_resolution_clock::now(); - for (int i = 0; i < ITERS; ++i) yin.detect(buf.data(), BENCH_FRAME, BENCH_SR); - auto t1 = std::chrono::high_resolution_clock::now(); - ns_yin = nsPerFrame(t0, t1); - } +static std::unique_ptr makeFusion(int frameSize) { + return std::make_unique( + std::make_unique(BENCH_SR, frameSize), + std::make_unique(BENCH_SR, frameSize)); +} + +static void benchFrameSize(int frameSize, float freq, const char* label) { + const auto buf = makeSine(freq, frameSize); - // --- PYIN --- double ns_pyin = 0.0; { - PyinPitchDetector pyin(BENCH_SR, BENCH_FRAME); - for (int i = 0; i < WARMUP; ++i) pyin.detect(buf.data(), BENCH_FRAME, BENCH_SR); - auto t0 = std::chrono::high_resolution_clock::now(); - for (int i = 0; i < ITERS; ++i) pyin.detect(buf.data(), BENCH_FRAME, BENCH_SR); - auto t1 = std::chrono::high_resolution_clock::now(); - ns_pyin = nsPerFrame(t0, t1); + PyinPitchDetector pyin(BENCH_SR, frameSize); + ns_pyin = benchmark([&] { pyin.detect(buf.data(), frameSize, BENCH_SR); }); } - // --- Cepstrum --- double ns_cep = 0.0; { - CepstrumPitchDetector cep(BENCH_SR, BENCH_FRAME); - for (int i = 0; i < WARMUP; ++i) cep.detect(buf.data(), BENCH_FRAME, BENCH_SR); - auto t0 = std::chrono::high_resolution_clock::now(); - for (int i = 0; i < ITERS; ++i) cep.detect(buf.data(), BENCH_FRAME, BENCH_SR); - auto t1 = std::chrono::high_resolution_clock::now(); - ns_cep = nsPerFrame(t0, t1); + CepstrumPitchDetector cep(BENCH_SR, frameSize); + ns_cep = benchmark([&] { cep.detect(buf.data(), frameSize, BENCH_SR); }); } - // --- Full pipeline (HPF + Window + Ensemble + SNR + PostProcessor) --- double ns_pipeline = 0.0; { - std::vector> dets; - dets.push_back(std::make_unique(BENCH_SR, BENCH_FRAME)); - dets.push_back(std::make_unique(BENCH_SR, BENCH_FRAME)); - dets.push_back(std::make_unique(BENCH_SR, BENCH_FRAME)); - auto ensemble = std::make_unique(std::move(dets)); - Pipeline pipeline(BENCH_FRAME, BENCH_SR, std::move(ensemble)); - - for (int i = 0; i < WARMUP; ++i) pipeline.process(buf.data(), BENCH_FRAME); - auto t0 = std::chrono::high_resolution_clock::now(); - for (int i = 0; i < ITERS; ++i) pipeline.process(buf.data(), BENCH_FRAME); - auto t1 = std::chrono::high_resolution_clock::now(); - ns_pipeline = nsPerFrame(t0, t1); + Pipeline pipeline(frameSize, BENCH_SR, makeFusion(frameSize)); + ns_pipeline = benchmark([&] { pipeline.process(buf.data(), frameSize); }); } - std::printf("Benchmark (frame=%d @ %.0f Hz, %d iterations)\n", - BENCH_FRAME, BENCH_SR, ITERS); - std::printf(" YIN : %8.1f ns/frame (%5.2f ms)\n", ns_yin, ns_yin * 1e-6); - std::printf(" PYIN : %8.1f ns/frame (%5.2f ms)\n", ns_pyin, ns_pyin * 1e-6); + std::printf("\nBenchmark (frame=%d @ %.0f Hz, %d iterations)%s\n", + frameSize, BENCH_SR, ITERS, label); + std::printf(" pYIN : %8.1f ns/frame (%5.2f ms)\n", ns_pyin, ns_pyin * 1e-6); std::printf(" Cepstrum : %8.1f ns/frame (%5.2f ms)\n", ns_cep, ns_cep * 1e-6); std::printf(" Full pipeline: %8.1f ns/frame (%5.2f ms)\n", ns_pipeline, ns_pipeline * 1e-6); - // --- 4096 frame benchmarks (bass/cello mode) --- - static constexpr int BENCH_FRAME_4096 = 4096; - - auto makeSine4096 = [](float freq) { - std::vector b(BENCH_FRAME_4096); - for (int i = 0; i < BENCH_FRAME_4096; ++i) - b[i] = 0.8f * std::sin(2.0f * BENCH_PI * freq * static_cast(i) / BENCH_SR); - return b; - }; - - const auto buf4096 = makeSine4096(82.41f); // E2 for bass benchmark - - double ns_yin4096 = 0.0; - { - YinPitchDetector yin(BENCH_SR, BENCH_FRAME_4096); - for (int i = 0; i < WARMUP; ++i) yin.detect(buf4096.data(), BENCH_FRAME_4096, BENCH_SR); - auto t0 = std::chrono::high_resolution_clock::now(); - for (int i = 0; i < ITERS; ++i) yin.detect(buf4096.data(), BENCH_FRAME_4096, BENCH_SR); - auto t1 = std::chrono::high_resolution_clock::now(); - ns_yin4096 = nsPerFrame(t0, t1); - } - - double ns_pyin4096 = 0.0; - { - PyinPitchDetector pyin(BENCH_SR, BENCH_FRAME_4096); - for (int i = 0; i < WARMUP; ++i) pyin.detect(buf4096.data(), BENCH_FRAME_4096, BENCH_SR); - auto t0 = std::chrono::high_resolution_clock::now(); - for (int i = 0; i < ITERS; ++i) pyin.detect(buf4096.data(), BENCH_FRAME_4096, BENCH_SR); - auto t1 = std::chrono::high_resolution_clock::now(); - ns_pyin4096 = nsPerFrame(t0, t1); - } - - double ns_cep4096 = 0.0; - { - CepstrumPitchDetector cep(BENCH_SR, BENCH_FRAME_4096); - for (int i = 0; i < WARMUP; ++i) cep.detect(buf4096.data(), BENCH_FRAME_4096, BENCH_SR); - auto t0 = std::chrono::high_resolution_clock::now(); - for (int i = 0; i < ITERS; ++i) cep.detect(buf4096.data(), BENCH_FRAME_4096, BENCH_SR); - auto t1 = std::chrono::high_resolution_clock::now(); - ns_cep4096 = nsPerFrame(t0, t1); - } - - double ns_pipeline4096 = 0.0; - { - std::vector> dets; - dets.push_back(std::make_unique(BENCH_SR, BENCH_FRAME_4096)); - dets.push_back(std::make_unique(BENCH_SR, BENCH_FRAME_4096)); - dets.push_back(std::make_unique(BENCH_SR, BENCH_FRAME_4096)); - auto ensemble = std::make_unique(std::move(dets)); - Pipeline pipeline(BENCH_FRAME_4096, BENCH_SR, std::move(ensemble)); - - for (int i = 0; i < WARMUP; ++i) pipeline.process(buf4096.data(), BENCH_FRAME_4096); - auto t0 = std::chrono::high_resolution_clock::now(); - for (int i = 0; i < ITERS; ++i) pipeline.process(buf4096.data(), BENCH_FRAME_4096); - auto t1 = std::chrono::high_resolution_clock::now(); - ns_pipeline4096 = nsPerFrame(t0, t1); + if (frameSize == 4096) { + std::printf(" Budget (75%% overlap, hop=1024): %.2f ms/hop → %.1f%% CPU\n", + ns_pipeline * 1e-6, + (ns_pipeline * 1e-6) / (1024.0 / BENCH_SR * 1000.0) * 100.0); } +} - std::printf("\nBenchmark (frame=%d @ %.0f Hz, %d iterations) [bass/cello mode]\n", - BENCH_FRAME_4096, BENCH_SR, ITERS); - std::printf(" YIN : %8.1f ns/frame (%5.2f ms)\n", ns_yin4096, ns_yin4096 * 1e-6); - std::printf(" PYIN : %8.1f ns/frame (%5.2f ms)\n", ns_pyin4096, ns_pyin4096 * 1e-6); - std::printf(" Cepstrum : %8.1f ns/frame (%5.2f ms)\n", ns_cep4096, ns_cep4096 * 1e-6); - std::printf(" Full pipeline: %8.1f ns/frame (%5.2f ms)\n", ns_pipeline4096, ns_pipeline4096 * 1e-6); - std::printf(" Budget (75%% overlap, hop=1024): %.2f ms/hop → %.1f%% CPU\n", - ns_pipeline4096 * 1e-6, - (ns_pipeline4096 * 1e-6) / (1024.0 / BENCH_SR * 1000.0) * 100.0); - +int main() { + benchFrameSize(2048, 440.0f, ""); + benchFrameSize(4096, 82.41f, " [bass/cello mode]"); return 0; } diff --git a/cpp/tests/main.cpp b/cpp/tests/main.cpp index 6bc17e6..e7bda40 100644 --- a/cpp/tests/main.cpp +++ b/cpp/tests/main.cpp @@ -1,10 +1,9 @@ #include "NoteMapper.hpp" #include "OnsetDetector.hpp" #include "TunerEngine.hpp" -#include "YinPitchDetector.hpp" #include "PyinPitchDetector.hpp" #include "CepstrumPitchDetector.hpp" -#include "EnsembleSelector.hpp" +#include "DetectorFusion.hpp" #include "BiquadHpf.hpp" #include "AudioFrameDispatcher.hpp" #include "InstrumentPresets.hpp" @@ -41,6 +40,26 @@ static std::vector generateSilence(int frameSize) { return std::vector(frameSize, 0.0f); } +// Fundamental plus decaying harmonics — closer to a plucked string than a pure +// sine, and rich enough for the cepstrum detector to find a quefrency peak. +static std::vector generateHarmonicTone( + float fundamental, + float sampleRate, + int sampleCount +) { + std::vector buffer(sampleCount, 0.0f); + + for (int harmonic = 1; harmonic <= 5; ++harmonic) { + const float amplitude = 0.5f / static_cast(harmonic); + for (int i = 0; i < sampleCount; ++i) { + buffer[i] += amplitude + * std::sin(2.0f * PI * fundamental * harmonic * i / sampleRate); + } + } + + return buffer; +} + static void testNoteMapper() { NoteMapper mapper(440.0f); @@ -77,22 +96,22 @@ static void testNoteMapper() { } } -static void testYin(float inputFrequency) { +static void testPyin(float inputFrequency) { constexpr float sampleRate = 48000.0f; constexpr int frameSize = 4096; auto buffer = generateSine(inputFrequency, sampleRate, frameSize); - YinPitchDetector detector(sampleRate, frameSize); - auto result = detector.detect(buffer.data(), static_cast(buffer.size())); + PyinPitchDetector detector(sampleRate, frameSize); + auto result = detector.detect(buffer.data(), static_cast(buffer.size()), sampleRate); std::cout - << "yin input: " << inputFrequency + << "pyin input: " << inputFrequency << " detected: " << result.frequency << " confidence: " << result.confidence << std::endl; - assert(result.hasPitch); + assert(result.voiced); assertNear(result.frequency, inputFrequency, 0.5f); assert(result.confidence > 0.80f); } @@ -284,7 +303,7 @@ static void testInstrumentPreset() { assert(result.noteName == "G"); } -// --- M3: per-detector and ensemble tests --- +// --- M3: per-detector and fusion tests --- static void testDetectorOnSine(float freq, const std::string& label) { constexpr float sr = 48000.0f; @@ -294,36 +313,120 @@ static void testDetectorOnSine(float freq, const std::string& label) { BiquadHpf hpf(sr, 70.0f); hpf.process(buf.data(), n); - YinPitchDetector yin(sr, n); PyinPitchDetector pyin(sr, n); + auto result = pyin.detect(buf.data(), n, sr); + + std::cout << "detector " << label << " @ " << freq << " Hz:" + << " pyin freq=" << result.frequency + << " conf=" << result.confidence << "\n"; + + assert(result.voiced); + assertNear(result.frequency, freq, freq * 0.01f); // within 1% +} + +static void testCepstrumOnHarmonicTone() { + // The cepstrum detector needs harmonics to find a quefrency peak; + // a plucked-string-like tone is its home turf. + constexpr float sr = 48000.0f; + constexpr int n = 4096; + constexpr float f0 = 196.0f; // G3 + + auto buf = generateHarmonicTone(f0, sr, n); + + CepstrumPitchDetector cepstrum(sr, n); + auto result = cepstrum.detect(buf.data(), n, sr); + + std::cout << "cepstrum harmonic G3: freq=" << result.frequency + << " conf=" << result.confidence << "\n"; + + assert(result.voiced); + // The cepstrum is the coarse corroborator — 3% is enough to land well + // inside the fusion's one-semitone (~6%) agreement window. + assertNear(result.frequency, f0, f0 * 0.03f); +} + +// Fixed-output detector for exercising DetectorFusion's decision logic in isolation. +struct StubDetector : IPitchDetector { + DetectorResult fixedResult; + explicit StubDetector(DetectorResult result) : fixedResult(result) {} + DetectorResult detect(const float*, int, float) override { return fixedResult; } + void setFrequencyRange(float, float) override {} +}; + +static void testFusionDecisions() { + const float dummyFrame[8] = {}; + + // Agreement: report the primary's precise frequency, combine confidences + // as independent evidence: 1 - (1-0.8)(1-0.5) = 0.9. + { + DetectorFusion fusion( + std::make_unique(DetectorResult{true, 440.0f, 0.8f}), + std::make_unique(DetectorResult{true, 442.0f, 0.5f})); + auto r = fusion.detect(dummyFrame, 8, 48000.0f); + assert(r.voiced); + assert(r.frequency == 440.0f); // exactly the primary's, never averaged + assertNear(r.confidence, 0.9f, 0.001f); + } + + // Clash (an octave apart): the primary's frequency survives — the coarse + // corroborator never overrides it — but its conviction dampens the + // confidence: 0.9 * (1 - 0.4 * 0.3) = 0.792. + { + DetectorFusion fusion( + std::make_unique(DetectorResult{true, 220.0f, 0.9f}), + std::make_unique(DetectorResult{true, 440.0f, 0.3f})); + auto r = fusion.detect(dummyFrame, 8, 48000.0f); + assert(r.voiced); + assert(r.frequency == 220.0f); + assertNear(r.confidence, 0.792f, 0.001f); + } - auto ry = yin.detect(buf.data(), n, sr); - auto rpy = pyin.detect(buf.data(), n, sr); + // Solo primary: mild dampening (0.8 * 0.9 = 0.72). + { + DetectorFusion fusion( + std::make_unique(DetectorResult{true, 330.0f, 0.8f}), + std::make_unique(DetectorResult{})); + auto r = fusion.detect(dummyFrame, 8, 48000.0f); + assert(r.voiced); + assert(r.frequency == 330.0f); + assertNear(r.confidence, 0.72f, 0.001f); + } - std::cout << "detector " << label << " @ " << freq << " Hz:\n" - << " YIN freq=" << ry.frequency << " conf=" << ry.confidence << "\n" - << " PYIN freq=" << rpy.frequency << " conf=" << rpy.confidence << "\n"; + // Solo corroborator: stronger dampening (0.8 * 0.7 = 0.56). + { + DetectorFusion fusion( + std::make_unique(DetectorResult{}), + std::make_unique(DetectorResult{true, 330.0f, 0.8f})); + auto r = fusion.detect(dummyFrame, 8, 48000.0f); + assert(r.voiced); + assert(r.frequency == 330.0f); + assertNear(r.confidence, 0.56f, 0.001f); + } - assert(ry.voiced); - assertNear(ry.frequency, freq, freq * 0.01f); // within 1% + // Neither voiced → unvoiced. + { + DetectorFusion fusion( + std::make_unique(DetectorResult{}), + std::make_unique(DetectorResult{})); + auto r = fusion.detect(dummyFrame, 8, 48000.0f); + assert(!r.voiced); + } - assert(rpy.voiced); - assertNear(rpy.frequency, freq, freq * 0.01f); // PYIN must agree with YIN + std::cout << "fusion decisions: all correct\n"; } -static void testEnsembleAgreementBonus() { - // When YIN and PYIN agree, the ensemble should produce confidence > either detector alone. - constexpr float sr = 48000.0f; - constexpr int n = 4096; - constexpr float f0 = 196.0f; // G3 +static void testFusionAgreementOnHarmonicTone() { + // On a harmonic-rich tone both detectors fire and agree, so the fused + // confidence must exceed what either detector reports alone. + constexpr float sr = 48000.0f; + constexpr int n = 4096; + constexpr float f0 = 196.0f; // G3 - auto buf = generateSine(f0, sr, n * 6); // 6 continuous frames for HPF warmup + auto buf = generateHarmonicTone(f0, sr, n * 6); // continuous for HPF warmup - std::vector> detectors; - detectors.push_back(std::make_unique(sr, n)); - detectors.push_back(std::make_unique(sr, n)); - detectors.push_back(std::make_unique(sr, n)); - EnsembleSelector ensemble(std::move(detectors)); + DetectorFusion fusion( + std::make_unique(sr, n), + std::make_unique(sr, n)); BiquadHpf hpf(sr, 70.0f); @@ -331,21 +434,21 @@ static void testEnsembleAgreementBonus() { for (int f = 0; f < 6; ++f) { std::vector frame(buf.begin() + f * n, buf.begin() + (f + 1) * n); hpf.process(frame.data(), n); - result = ensemble.detect(frame.data(), n, sr); + result = fusion.detect(frame.data(), n, sr); } - std::cout << "ensemble G3: voiced=" << result.voiced + std::cout << "fusion harmonic G3: voiced=" << result.voiced << " freq=" << result.frequency << " conf=" << result.confidence << "\n"; assert(result.voiced); assertNear(result.frequency, f0, f0 * 0.01f); - assert(result.confidence > 0.85f); // agreement bonus applied + assert(result.confidence > 0.85f); // corroboration raised the confidence } -static void testEnsembleOctaveSafety() { +static void testEngineOctaveSafety() { // D3 (146.83 Hz): tau0 and 2*tau0 both fit in the search range. - // The ensemble must return the fundamental, not the sub-octave. + // The engine must return the fundamental, not the sub-octave. constexpr float sr = 48000.0f; constexpr int n = 4096; @@ -357,7 +460,7 @@ static void testEnsembleOctaveSafety() { result = engine.process(buf.data() + f * n, n); } - std::cout << "ensemble octave safety D3: " + std::cout << "engine octave safety D3: " << result.noteName << result.octave << " freq=" << result.frequency << "\n"; @@ -572,12 +675,12 @@ static void testSetOverlapRatio() { int main() { testNoteMapper(); - testYin(82.41f); - testYin(110.0f); - testYin(146.83f); - testYin(196.0f); - testYin(246.94f); - testYin(329.63f); + testPyin(82.41f); + testPyin(110.0f); + testPyin(146.83f); + testPyin(196.0f); + testPyin(246.94f); + testPyin(329.63f); testTunerEngine(82.41f, "E", 2); testTunerEngine(110.0f, "A", 2); @@ -589,15 +692,17 @@ int main() { testTunerEngineSilence(); testTunerEngineQuietSignal(); - // M3 per-detector and ensemble tests + // M3 per-detector and fusion tests testDetectorOnSine(82.41f, "E2"); testDetectorOnSine(110.0f, "A2"); testDetectorOnSine(146.83f, "D3"); testDetectorOnSine(196.0f, "G3"); testDetectorOnSine(329.63f, "E4"); testDetectorOnSine(440.0f, "A4"); - testEnsembleAgreementBonus(); - testEnsembleOctaveSafety(); + testCepstrumOnHarmonicTone(); + testFusionDecisions(); + testFusionAgreementOnHarmonicTone(); + testEngineOctaveSafety(); // M2 DSP hardening tests testPipelineCleanSine(82.41f, "E", 2);