diff --git a/README.md b/README.md index 361736c..014dd36 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ ctest --test-dir build --build-config Release --output-on-failure | Manual Rate | 20 - 5000 Hz | 440 Hz | Fixed oscillator frequency (Manual mode) | | Mode | Pitch Track / Manual | Pitch Track | Pitch source selection | | Smoothing | 0 - 100% | 50% | Pitch tracking smoothing amount | -| Sensitivity | 0 - 100% | 50% | Minimum confidence for accepting pitch updates | +| Sensitivity | 0 - 100% | 50% | Minimum confidence for accepting pitch updates; higher values require stronger detections | | Waveform | Sine / Triangle / Square / Saw | Sine | Ring modulator oscillator shape | ## How It Works diff --git a/source/dsp/PitchSmoother.h b/source/dsp/PitchSmoother.h index fe1021b..efa5d72 100644 --- a/source/dsp/PitchSmoother.h +++ b/source/dsp/PitchSmoother.h @@ -25,7 +25,7 @@ class PitchSmoother inline void setSensitivity(float sensitivity01) { - sensitivityThreshold = 1.0f - sensitivity01; + sensitivityThreshold = std::clamp(sensitivity01, 0.0f, 1.0f); } inline float process(float detectedFreq, float confidence) diff --git a/source/dsp/YinPitchDetector.cpp b/source/dsp/YinPitchDetector.cpp index f9f91ac..cc1d4b8 100644 --- a/source/dsp/YinPitchDetector.cpp +++ b/source/dsp/YinPitchDetector.cpp @@ -42,20 +42,28 @@ class YinPitchDetector::AnalysisThread : public juce::Thread o.buffer[static_cast(o.writePos)] = decimated; if (++o.writePos >= o.windowSize) o.writePos = 0; - ++o.hopCounter; - if (!o.windowFilled) + o.activityEnvelope = std::max(std::abs(decimated), o.activityEnvelope * o.activityRelease); + if (o.activityEnvelope < o.silenceThreshold) { - if (o.writePos == 0) - o.windowFilled = true; - else - return; + o.activeWindowSize = 0; + o.hopCounter = 0; + o.lastResult = {}; + o.atomicFreq.store(0.0f, std::memory_order_relaxed); + o.atomicConf.store(0.0f, std::memory_order_relaxed); + return; } + o.activeWindowSize = std::min(o.activeWindowSize + 1, o.windowSize); + ++o.hopCounter; + + if (o.activeWindowSize < o.minAnalysisWindow) + return; + if (o.hopCounter >= o.hopSize) { o.hopCounter = 0; - o.analyse(); + o.analyse(o.activeWindowSize); o.atomicFreq.store(o.lastResult.frequency, std::memory_order_relaxed); o.atomicConf.store(o.lastResult.confidence, std::memory_order_relaxed); } @@ -91,9 +99,11 @@ void YinPitchDetector::prepare(double sampleRate) analysisSR = decimatedSR; - halfWindow = static_cast(std::ceil(decimatedSR / 60.0)); + halfWindow = static_cast(std::ceil(decimatedSR / 80.0)); windowSize = 2 * halfWindow; - hopSize = static_cast(std::ceil(decimatedSR * 0.012)); + hopSize = static_cast(std::ceil(decimatedSR * 0.003)); + minAnalysisWindow = 2 * static_cast(std::ceil(decimatedSR / 200.0)); + minAnalysisWindow = std::min(minAnalysisWindow, windowSize); fftOrder = static_cast(std::ceil(std::log2(2.0 * windowSize))); fftSize = 1 << fftOrder; @@ -112,7 +122,8 @@ void YinPitchDetector::prepare(double sampleRate) writePos = 0; hopCounter = 0; - windowFilled = false; + activeWindowSize = 0; + activityEnvelope = 0.0f; lastResult = {}; atomicFreq.store(0.0f, std::memory_order_relaxed); atomicConf.store(0.0f, std::memory_order_relaxed); @@ -130,13 +141,19 @@ void YinPitchDetector::flushForTest() std::this_thread::sleep_for(std::chrono::milliseconds(10)); } -void YinPitchDetector::analyse() +void YinPitchDetector::analyse(int samplesToAnalyse) { - auto n = static_cast(halfWindow); + int activeHalfWindow = std::clamp(samplesToAnalyse / 2, 2, halfWindow); + int activeWindow = 2 * activeHalfWindow; + auto n = static_cast(activeHalfWindow); + + int start = writePos - activeWindow; + if (start < 0) + start += windowSize; - int tail = windowSize - writePos; - std::copy_n(buffer.data() + writePos, tail, linearBuffer.data()); - std::copy_n(buffer.data(), writePos, linearBuffer.data() + tail); + int tail = std::min(activeWindow, windowSize - start); + std::copy_n(buffer.data() + start, tail, linearBuffer.data()); + std::copy_n(buffer.data(), activeWindow - tail, linearBuffer.data() + tail); juce::FloatVectorOperations::clear(fftInput.data(), fftSize * 2); for (size_t i = 0; i < n; ++i) @@ -144,7 +161,7 @@ void YinPitchDetector::analyse() fft->performRealOnlyForwardTransform(fftInput.data()); juce::FloatVectorOperations::clear(fftOutput.data(), fftSize * 2); - for (int i = 0; i < windowSize; ++i) + for (int i = 0; i < activeWindow; ++i) fftOutput[static_cast(i)] = linearBuffer[static_cast(i)]; fft->performRealOnlyForwardTransform(fftOutput.data()); diff --git a/source/dsp/YinPitchDetector.h b/source/dsp/YinPitchDetector.h index 4bdb8ca..d4ed96e 100644 --- a/source/dsp/YinPitchDetector.h +++ b/source/dsp/YinPitchDetector.h @@ -38,7 +38,7 @@ class YinPitchDetector void flushForTest(); private: - void analyse(); + void analyse(int samplesToAnalyse); class AnalysisThread; friend class AnalysisThread; @@ -52,7 +52,9 @@ class YinPitchDetector std::vector linearBuffer; int writePos = 0; int hopCounter = 0; - bool windowFilled = false; + int activeWindowSize = 0; + int minAnalysisWindow = 0; + float activityEnvelope = 0.0f; std::unique_ptr fft; int fftOrder = 0; @@ -66,6 +68,8 @@ class YinPitchDetector PitchResult lastResult; static constexpr float threshold = 0.15f; + static constexpr float silenceThreshold = 1e-5f; + static constexpr float activityRelease = 0.995f; juce::AbstractFifo fifo { 0 }; std::vector fifoBuffer; diff --git a/tests/TestPitchSmoother.cpp b/tests/TestPitchSmoother.cpp index 463a946..2864870 100644 --- a/tests/TestPitchSmoother.cpp +++ b/tests/TestPitchSmoother.cpp @@ -69,6 +69,43 @@ TEST_CASE("PitchSmoother: sensitivity threshold gates low-confidence input") REQUIRE(result == 0.0f); } +TEST_CASE("PitchSmoother: maximum sensitivity rejects low-confidence input") +{ + PitchSmoother smoother; + smoother.prepare(44100.0); + smoother.setSmoothingAmount(0.0f); + smoother.setSensitivity(1.0f); + + float result = smoother.process(440.0f, 0.1f); + REQUIRE(result == 0.0f); +} + +TEST_CASE("PitchSmoother: zero sensitivity accepts low-confidence input") +{ + PitchSmoother smoother; + smoother.prepare(44100.0); + smoother.setSmoothingAmount(0.0f); + smoother.setSensitivity(0.0f); + + float result = smoother.process(440.0f, 0.1f); + REQUIRE_THAT(static_cast(result), + Catch::Matchers::WithinAbs(440.0, 0.1)); +} + +TEST_CASE("PitchSmoother: midpoint sensitivity accepts only midpoint confidence or higher") +{ + PitchSmoother smoother; + smoother.prepare(44100.0); + smoother.setSmoothingAmount(0.0f); + smoother.setSensitivity(0.5f); + + REQUIRE(smoother.process(440.0f, 0.49f) == 0.0f); + + float result = smoother.process(440.0f, 0.5f); + REQUIRE_THAT(static_cast(result), + Catch::Matchers::WithinAbs(440.0, 0.1)); +} + TEST_CASE("PitchSmoother: zero smoothing passes frequency through immediately") { PitchSmoother smoother; diff --git a/tests/TestYinPitchDetector.cpp b/tests/TestYinPitchDetector.cpp index 66a7053..7c8c936 100644 --- a/tests/TestYinPitchDetector.cpp +++ b/tests/TestYinPitchDetector.cpp @@ -5,18 +5,20 @@ static constexpr double twoPi = 6.283185307179586476925; static constexpr int decimationFactor = 2; +static constexpr double minimumTrackedFrequency = 80.0; +static constexpr double hopSeconds = 0.003; static int computeWindowSize(double sampleRate) { double decimatedSR = sampleRate / decimationFactor; - int halfWindow = static_cast(std::ceil(decimatedSR / 60.0)); + int halfWindow = static_cast(std::ceil(decimatedSR / minimumTrackedFrequency)); return 2 * halfWindow; } static int computeHopSize(double sampleRate) { double decimatedSR = sampleRate / decimationFactor; - return static_cast(std::ceil(decimatedSR * 0.012)); + return static_cast(std::ceil(decimatedSR * hopSeconds)); } static void feedSine(YinPitchDetector& yin, double sampleRate, float freq, int numSamples) @@ -220,6 +222,51 @@ TEST_CASE("YIN: first detection within one window fill") REQUIRE(yin.getResult().frequency > 0.0f); } +TEST_CASE("YIN: first 440 Hz detection is under 20 ms") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + feedSine(yin, 44100.0, 440.0f, 882); + + auto result = yin.getResult(); + REQUIRE(result.frequency > 0.0f); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(440.0, 0.03)); +} + +TEST_CASE("YIN: locks to 440 Hz within 20 ms after silence") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + for (int i = 0; i < 44100; ++i) + yin.feedSample(0.0f); + yin.flushForTest(); + + REQUIRE(yin.getResult().frequency == 0.0f); + + feedSine(yin, 44100.0, 440.0f, 882); + + auto result = yin.getResult(); + REQUIRE(result.frequency > 0.0f); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(440.0, 0.03)); +} + +TEST_CASE("YIN: detects E2 within 30 ms") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + feedSine(yin, 44100.0, 82.4f, 1323); + + auto result = yin.getResult(); + REQUIRE(result.frequency > 0.0f); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(82.4, 0.03)); +} + TEST_CASE("YIN: detects pitch change after silence") { YinPitchDetector yin;