From bb419c9cad52b12d0b8310419128186106a03555 Mon Sep 17 00:00:00 2001 From: user1303836 Date: Thu, 6 Aug 2026 12:18:51 -0400 Subject: [PATCH] Improve low-frequency pitch tracking --- README.md | 2 + source/dsp/PitchSmoother.h | 24 +- source/dsp/YinPitchDetector.cpp | 499 +++++++++++++++++++++++++------- source/dsp/YinPitchDetector.h | 91 ++++-- tests/TestPitchSmoother.cpp | 16 + tests/TestYinPitchDetector.cpp | 431 +++++++++++++++++++++++++++ 6 files changed, 932 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 014dd36..79332e5 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ ctest --test-dir build --build-config Release --output-on-failure In **Pitch Track** mode, the plugin detects the pitch of the incoming audio using the YIN algorithm, then ring-modulates the signal with an oscillator locked to that pitch (multiplied by the Rate Multiplier). The effect stays dry only until the tracker has a valid pitch, then follows the tracked carrier directly. +The tracker uses short analysis windows for normal guitar notes and runs longer analyses for low or uncertain pitches, with slower background audits for octave errors. It supports fundamentals down to 20 Hz without imposing the low-frequency analysis rate on the common path. Acquisition can take roughly 110 ms near 20 Hz because one cycle alone is 50 ms. + In **Manual** mode, the oscillator runs at a fixed frequency set by the Manual Rate knob. ## License diff --git a/source/dsp/PitchSmoother.h b/source/dsp/PitchSmoother.h index efa5d72..0afac7b 100644 --- a/source/dsp/PitchSmoother.h +++ b/source/dsp/PitchSmoother.h @@ -12,6 +12,10 @@ class PitchSmoother hasValue = false; smoothed = 0.0f; cachedFreq = 0.0f; + lastDetectedFreq = 0.0f; + cachedLogFreq = 0.0f; + invalidSamples = 0; + releaseSamples = std::max(1, static_cast(sr * 0.05f)); recomputeAlpha(); } @@ -31,9 +35,23 @@ class PitchSmoother inline float process(float detectedFreq, float confidence) { if (detectedFreq <= 0.0f || confidence < sensitivityThreshold) + { + if (hasValue && ++invalidSamples >= releaseSamples) + { + hasValue = false; + smoothed = 0.0f; + cachedFreq = 0.0f; + } return cachedFreq; + } - float logFreq = std::log2(detectedFreq); + invalidSamples = 0; + if (detectedFreq != lastDetectedFreq) + { + lastDetectedFreq = detectedFreq; + cachedLogFreq = std::log2(detectedFreq); + } + float logFreq = cachedLogFreq; if (!hasValue) { @@ -71,5 +89,9 @@ class PitchSmoother float alpha = 0.5f; float sensitivityThreshold = 0.5f; float cachedFreq = 0.0f; + float lastDetectedFreq = 0.0f; + float cachedLogFreq = 0.0f; + int invalidSamples = 0; + int releaseSamples = 2205; bool hasValue = false; }; diff --git a/source/dsp/YinPitchDetector.cpp b/source/dsp/YinPitchDetector.cpp index cc1d4b8..38bd8c9 100644 --- a/source/dsp/YinPitchDetector.cpp +++ b/source/dsp/YinPitchDetector.cpp @@ -1,8 +1,41 @@ #include "YinPitchDetector.h" #include +#include #include #include -#include + +namespace +{ +constexpr float candidateToleranceOctaves = 50.0f / 1200.0f; +constexpr float movingCandidateToleranceOctaves = 150.0f / 1200.0f; +constexpr float quickPathFrequency = 300.0f; +constexpr float strongCandidateConfidence = 0.9f; +constexpr float qualifiedCandidateConfidence = 0.8f; +constexpr float fallbackCandidateConfidence = 0.75f; +constexpr float confidenceSelectionMargin = 0.1f; +constexpr float guitarSelectionMargin = 0.05f; +constexpr float lowTrackingRangeRatio = 0.6f; +constexpr int maximumWorkerBatchSize = 512; +constexpr int lowCandidateMissLimit = 8; + +bool hasPitch(PitchResult result) +{ + return result.frequency > 0.0f && result.confidence > 0.0f; +} + +bool frequenciesAreWithin(float first, float second, float toleranceOctaves) +{ + if (first <= 0.0f || second <= 0.0f) + return false; + + return std::abs(std::log2(first / second)) <= toleranceOctaves; +} + +bool frequenciesAreClose(float first, float second) +{ + return frequenciesAreWithin(first, second, candidateToleranceOctaves); +} +} class YinPitchDetector::AnalysisThread : public juce::Thread { @@ -14,59 +47,221 @@ class YinPitchDetector::AnalysisThread : public juce::Thread { while (!threadShouldExit()) { + if (o.fifoOverflowed.exchange(false, std::memory_order_acq_rel)) + { + discardSamples(o.fifo.getNumReady()); + resetStreamState(); + continue; + } + int ready = o.fifo.getNumReady(); + if (ready > o.maxBacklogSamples) + { + discardSamples(ready - o.maxBacklogSamples); + resetStreamState(); + ready = o.fifo.getNumReady(); + } + if (ready == 0) { wait(1); continue; } + ready = std::min(ready, maximumWorkerBatchSize); int start1, size1, start2, size2; o.fifo.prepareToRead(ready, start1, size1, start2, size2); - for (int i = 0; i < size1; ++i) + int processed = 0; + bool overflowed = false; + for (int i = 0; i < size1 && !threadShouldExit(); ++i) + { + if (o.fifoOverflowed.load(std::memory_order_acquire)) + { + overflowed = true; + break; + } processSample(o.fifoBuffer[static_cast(start1 + i)]); - for (int i = 0; i < size2; ++i) + ++processed; + } + for (int i = 0; i < size2 && !threadShouldExit() && !overflowed; ++i) + { + if (o.fifoOverflowed.load(std::memory_order_acquire)) + { + overflowed = true; + break; + } processSample(o.fifoBuffer[static_cast(start2 + i)]); + ++processed; + } - o.fifo.finishedRead(size1 + size2); + o.fifo.finishedRead(processed); + if (overflowed || o.fifoOverflowed.exchange(false, std::memory_order_acq_rel)) + { + discardSamples(o.fifo.getNumReady()); + resetStreamState(); + } } } private: + void discardSamples(int count) + { + int start1, size1, start2, size2; + o.fifo.prepareToRead(count, start1, size1, start2, size2); + o.fifo.finishedRead(size1 + size2); + } + + void resetStreamState() + { + o.decimator.reset(); + o.activeWindowSize = 0; + o.quickHopCounter = 0; + o.guitarAuditCounter = 0; + o.lowAnalysisCounter = 0; + o.activityEnvelope = 0.0f; + o.clearResult(); + } + void processSample(float sample) { + if (!std::isfinite(sample)) + sample = 0.0f; + if (!o.decimator.processSample(sample)) return; - float decimated = o.decimator.getOutput(); + float decimated = o.decimator.getOutput(); o.buffer[static_cast(o.writePos)] = decimated; - if (++o.writePos >= o.windowSize) o.writePos = 0; + if (++o.writePos >= static_cast(o.buffer.size())) + o.writePos = 0; o.activityEnvelope = std::max(std::abs(decimated), o.activityEnvelope * o.activityRelease); if (o.activityEnvelope < o.silenceThreshold) { 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); + o.quickHopCounter = 0; + o.guitarAuditCounter = 0; + o.lowAnalysisCounter = 0; + o.clearResult(); return; } - o.activeWindowSize = std::min(o.activeWindowSize + 1, o.windowSize); - ++o.hopCounter; + o.activeWindowSize = std::min(o.activeWindowSize + 1, static_cast(o.buffer.size())); + o.quickHopCounter = std::min(o.quickHopCounter + 1, o.quickHopSize); + o.guitarAuditCounter = std::min(o.guitarAuditCounter + 1, o.guitarAuditSize); + o.lowAnalysisCounter = std::min(o.lowAnalysisCounter + 1, o.lowAuditSize); - if (o.activeWindowSize < o.minAnalysisWindow) + if (o.activeWindowSize < o.quickAnalysis.windowSize || o.quickHopCounter < o.quickHopSize) return; - if (o.hopCounter >= o.hopSize) + o.quickHopCounter = 0; + auto quick = o.analyse(o.quickAnalysis, o.quickAnalysis.windowSize); + auto selected = quick; + + bool quickNeedsGuitar = !hasPitch(quick.result) + || quick.boundary + || !quick.thresholdQualified + || quick.result.confidence < strongCandidateConfidence + || quick.result.frequency < quickPathFrequency + || (o.lastResult.frequency > 0.0f + && o.lastResult.frequency < quickPathFrequency); + + PitchCandidate guitar; + bool ranGuitar = o.activeWindowSize >= o.guitarAnalysis.windowSize + && (quickNeedsGuitar || o.guitarAuditCounter >= o.guitarAuditSize); + + if (ranGuitar) { - o.hopCounter = 0; - o.analyse(o.activeWindowSize); - o.atomicFreq.store(o.lastResult.frequency, std::memory_order_relaxed); - o.atomicConf.store(o.lastResult.confidence, std::memory_order_relaxed); + o.guitarAuditCounter = 0; + guitar = o.analyse(o.guitarAnalysis, o.guitarAnalysis.windowSize); + + if (hasPitch(guitar.result) && !guitar.boundary) + { + if (!hasPitch(quick.result) || quick.boundary) + { + selected = guitar; + } + else if (frequenciesAreClose(quick.result.frequency, guitar.result.frequency)) + { + selected = guitar.result.confidence >= quick.result.confidence ? guitar : quick; + } + else if ((guitar.thresholdQualified && !quick.thresholdQualified) + || (guitar.result.frequency < quickPathFrequency + && guitar.result.confidence + guitarSelectionMargin + >= quick.result.confidence)) + { + selected = guitar; + } + } + } + + bool selectedNeedsLow = !hasPitch(selected.result) + || selected.boundary + || !selected.thresholdQualified + || selected.result.confidence < qualifiedCandidateConfidence + || selected.result.frequency <= lowSelectionMaximumFrequency + || (o.lastResult.frequency > 0.0f + && o.lastResult.frequency <= lowSelectionMaximumFrequency); + int lowInterval = selectedNeedsLow ? o.lowHopSize : o.lowAuditSize; + + bool ranLow = false; + if (o.activeWindowSize > o.guitarAnalysis.windowSize + && o.lowAnalysisCounter >= lowInterval) + { + ranLow = true; + o.lowAnalysisCounter = 0; + + int lowWindowSize = std::min(o.activeWindowSize, o.lowAnalysis.windowSize); + float lowReference = o.lastResult.frequency <= lowSelectionMaximumFrequency + ? o.lastResult.frequency + : o.pendingCandidate.result.frequency; + if (lowReference > 0.0f + && lowReference <= lowSelectionMaximumFrequency + && o.missedCandidates == 0 + && (!hasPitch(o.pendingCandidate.result) + || o.pendingCandidate.result.frequency + >= lowReference * lowTrackingRangeRatio)) + { + float trackingMinimum = std::max(minimumOutputFrequency, + lowReference * lowTrackingRangeRatio); + int trackingHalfWindow = static_cast(std::ceil(o.analysisSR + / trackingMinimum)); + lowWindowSize = std::min(lowWindowSize, 2 * trackingHalfWindow); + } + + auto low = o.analyse(o.lowAnalysis, lowWindowSize); + + bool trustedLow = hasPitch(low.result) + && !low.boundary + && low.result.frequency <= lowSelectionMaximumFrequency + && ((low.thresholdQualified + && low.result.confidence >= qualifiedCandidateConfidence) + || low.result.confidence >= fallbackCandidateConfidence); + + if (trustedLow) + { + bool shortResultClearlyStronger = hasPitch(selected.result) + && selected.thresholdQualified + && selected.result.confidence + > low.result.confidence + confidenceSelectionMargin; + if (!shortResultClearlyStronger) + selected = low; + } } + + bool retainingLowCandidate = (o.lastResult.frequency > 0.0f + && o.lastResult.frequency <= lowSelectionMaximumFrequency) + || (o.pendingCandidate.result.frequency > 0.0f + && o.pendingCandidate.result.frequency <= lowSelectionMaximumFrequency); + if (retainingLowCandidate + && !ranLow + && (!hasPitch(selected.result) + || !selected.thresholdQualified + || selected.result.confidence < strongCandidateConfidence)) + return; + + o.considerCandidate(selected); } YinPitchDetector& o; @@ -76,62 +271,75 @@ YinPitchDetector::YinPitchDetector() = default; YinPitchDetector::~YinPitchDetector() { - if (analysisThread) - { - analysisThread->signalThreadShouldExit(); - analysisThread->notify(); - analysisThread->waitForThreadToExit(1000); - } + stopAnalysisThread(); +} + +void YinPitchDetector::stopAnalysisThread() +{ + if (!analysisThread) + return; + + analysisThread->signalThreadShouldExit(); + analysisThread->notify(); + if (!analysisThread->waitForThreadToExit(1000)) + analysisThread->stopThread(-1); + analysisThread.reset(); } void YinPitchDetector::prepare(double sampleRate) { - if (analysisThread) - { - analysisThread->signalThreadShouldExit(); - analysisThread->notify(); - analysisThread->waitForThreadToExit(1000); - analysisThread.reset(); - } + stopAnalysisThread(); - double decimatedSR = sampleRate / 2.0; + analysisSR = sampleRate / 2.0; + activityRelease = std::exp(-1.0f / (static_cast(analysisSR) * activityReleaseSeconds)); decimator.reset(); - analysisSR = decimatedSR; - - halfWindow = static_cast(std::ceil(decimatedSR / 80.0)); - windowSize = 2 * halfWindow; - hopSize = static_cast(std::ceil(decimatedSR * 0.003)); - minAnalysisWindow = 2 * static_cast(std::ceil(decimatedSR / 200.0)); - minAnalysisWindow = std::min(minAnalysisWindow, windowSize); + prepareWorkspace(quickAnalysis, quickMinimumFrequency); + prepareWorkspace(guitarAnalysis, guitarMinimumFrequency); + prepareWorkspace(lowAnalysis, lowAnalysisMinimumFrequency); - fftOrder = static_cast(std::ceil(std::log2(2.0 * windowSize))); - fftSize = 1 << fftOrder; - fft = std::make_unique(fftOrder); - fftInput.resize(static_cast(fftSize * 2), 0.0f); - fftOutput.resize(static_cast(fftSize * 2), 0.0f); + buffer.assign(static_cast(lowAnalysis.windowSize), 0.0f); - buffer.assign(static_cast(windowSize), 0.0f); - linearBuffer.resize(static_cast(windowSize)); - diff.resize(static_cast(halfWindow)); - cmndf.resize(static_cast(halfWindow)); + quickHopSize = static_cast(std::ceil(analysisSR * 0.003)); + guitarAuditSize = static_cast(std::ceil(analysisSR * 0.012)); + lowHopSize = static_cast(std::ceil(analysisSR * 0.012)); + lowAuditSize = static_cast(std::ceil(analysisSR * 0.096)); + maxBacklogSamples = static_cast(std::ceil(sampleRate * 0.25)); int fifoSize = std::max(8192, static_cast(sampleRate * 2.5)); fifo.setTotalSize(fifoSize); fifoBuffer.resize(static_cast(fifoSize)); + fifoOverflowed.store(false, std::memory_order_relaxed); writePos = 0; - hopCounter = 0; activeWindowSize = 0; + quickHopCounter = 0; + guitarAuditCounter = 0; + lowAnalysisCounter = 0; activityEnvelope = 0.0f; - lastResult = {}; - atomicFreq.store(0.0f, std::memory_order_relaxed); - atomicConf.store(0.0f, std::memory_order_relaxed); + clearResult(); analysisThread = std::make_unique(*this); analysisThread->startThread(juce::Thread::Priority::normal); } +void YinPitchDetector::prepareWorkspace(AnalysisWorkspace& workspace, double minimumFrequency) +{ + workspace.halfWindow = static_cast(std::ceil(analysisSR / minimumFrequency)); + workspace.windowSize = 2 * workspace.halfWindow; + + int requiredFftSize = 2 * workspace.halfWindow; + int fftOrder = static_cast(std::ceil(std::log2(static_cast(requiredFftSize)))); + workspace.fftSize = 1 << fftOrder; + workspace.fft = std::make_unique(fftOrder); + + workspace.linearBuffer.resize(static_cast(workspace.windowSize)); + workspace.fftInput.resize(static_cast(workspace.fftSize * 2), 0.0f); + workspace.fftOutput.resize(static_cast(workspace.fftSize * 2), 0.0f); + workspace.diff.resize(static_cast(workspace.halfWindow)); + workspace.cmndf.resize(static_cast(workspace.halfWindow)); +} + void YinPitchDetector::flushForTest() { if (analysisThread) @@ -141,125 +349,196 @@ void YinPitchDetector::flushForTest() std::this_thread::sleep_for(std::chrono::milliseconds(10)); } -void YinPitchDetector::analyse(int samplesToAnalyse) +YinPitchDetector::PitchCandidate YinPitchDetector::analyse(AnalysisWorkspace& workspace, + int samplesToAnalyse) { - int activeHalfWindow = std::clamp(samplesToAnalyse / 2, 2, halfWindow); + PitchCandidate candidate; + int activeHalfWindow = std::clamp(samplesToAnalyse / 2, 2, workspace.halfWindow); int activeWindow = 2 * activeHalfWindow; auto n = static_cast(activeHalfWindow); int start = writePos - activeWindow; if (start < 0) - start += windowSize; + start += static_cast(buffer.size()); - 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); + int tail = std::min(activeWindow, static_cast(buffer.size()) - start); + std::copy_n(buffer.data() + start, tail, workspace.linearBuffer.data()); + std::copy_n(buffer.data(), activeWindow - tail, workspace.linearBuffer.data() + tail); - juce::FloatVectorOperations::clear(fftInput.data(), fftSize * 2); - for (size_t i = 0; i < n; ++i) - fftInput[i] = linearBuffer[i]; - fft->performRealOnlyForwardTransform(fftInput.data()); + juce::FloatVectorOperations::clear(workspace.fftInput.data(), workspace.fftSize * 2); + std::copy_n(workspace.linearBuffer.data(), activeHalfWindow, workspace.fftInput.data()); + workspace.fft->performRealOnlyForwardTransform(workspace.fftInput.data(), true); - juce::FloatVectorOperations::clear(fftOutput.data(), fftSize * 2); - for (int i = 0; i < activeWindow; ++i) - fftOutput[static_cast(i)] = linearBuffer[static_cast(i)]; - fft->performRealOnlyForwardTransform(fftOutput.data()); + juce::FloatVectorOperations::clear(workspace.fftOutput.data(), workspace.fftSize * 2); + std::copy_n(workspace.linearBuffer.data(), activeWindow, workspace.fftOutput.data()); + workspace.fft->performRealOnlyForwardTransform(workspace.fftOutput.data(), true); - for (int k = 0; k < fftSize; ++k) + for (int k = 0; k <= workspace.fftSize / 2; ++k) { - float aRe = fftInput[static_cast(2 * k)]; - float aIm = fftInput[static_cast(2 * k + 1)]; - float bRe = fftOutput[static_cast(2 * k)]; - float bIm = fftOutput[static_cast(2 * k + 1)]; - fftInput[static_cast(2 * k)] = aRe * bRe + aIm * bIm; - fftInput[static_cast(2 * k + 1)] = aRe * bIm - aIm * bRe; + float aRe = workspace.fftInput[static_cast(2 * k)]; + float aIm = workspace.fftInput[static_cast(2 * k + 1)]; + float bRe = workspace.fftOutput[static_cast(2 * k)]; + float bIm = workspace.fftOutput[static_cast(2 * k + 1)]; + workspace.fftInput[static_cast(2 * k)] = aRe * bRe + aIm * bIm; + workspace.fftInput[static_cast(2 * k + 1)] = aRe * bIm - aIm * bRe; } - fft->performRealOnlyInverseTransform(fftInput.data()); + workspace.fft->performRealOnlyInverseTransform(workspace.fftInput.data()); float powerTerm0 = 0.0f; for (size_t j = 0; j < n; ++j) - powerTerm0 += linearBuffer[j] * linearBuffer[j]; + powerTerm0 += workspace.linearBuffer[j] * workspace.linearBuffer[j]; float powerTermTau = powerTerm0; - - diff[0] = 0.0f; + workspace.diff[0] = 0.0f; for (size_t tau = 1; tau < n; ++tau) { - powerTermTau += linearBuffer[n + tau - 1] * linearBuffer[n + tau - 1] - - linearBuffer[tau - 1] * linearBuffer[tau - 1]; - diff[tau] = powerTerm0 + powerTermTau - 2.0f * fftInput[tau]; + powerTermTau += workspace.linearBuffer[n + tau - 1] + * workspace.linearBuffer[n + tau - 1] + - workspace.linearBuffer[tau - 1] + * workspace.linearBuffer[tau - 1]; + float difference = powerTerm0 + powerTermTau - 2.0f * workspace.fftInput[tau]; + workspace.diff[tau] = std::max(0.0f, difference); } - cmndf[0] = 1.0f; + workspace.cmndf[0] = 1.0f; float runningSum = 0.0f; - for (size_t tau = 1; tau < n; ++tau) { - runningSum += diff[tau]; - if (runningSum > 0.0f) - cmndf[tau] = diff[tau] * static_cast(tau) / runningSum; - else - cmndf[tau] = 1.0f; + runningSum += workspace.diff[tau]; + workspace.cmndf[tau] = runningSum > 0.0f + ? workspace.diff[tau] * static_cast(tau) / runningSum + : 1.0f; } + auto firstTau = static_cast(std::max( + 2, static_cast(std::floor(analysisSR / maximumOutputFrequency)))); size_t tauEstimate = 0; - for (size_t tau = 2; tau < n; ++tau) + + for (size_t tau = firstTau; tau < n; ++tau) { - if (cmndf[tau] < threshold) + if (workspace.cmndf[tau] < threshold) { - while (tau + 1 < n && cmndf[tau + 1] < cmndf[tau]) + while (tau + 1 < n && workspace.cmndf[tau + 1] < workspace.cmndf[tau]) ++tau; tauEstimate = tau; + candidate.thresholdQualified = true; break; } } if (tauEstimate == 0) { - float minVal = 1.0f; - for (size_t tau = 2; tau < n; ++tau) + float minValue = 1.0f; + for (size_t tau = firstTau; tau < n; ++tau) { - if (cmndf[tau] < minVal) + if (workspace.cmndf[tau] < minValue) { - minVal = cmndf[tau]; + minValue = workspace.cmndf[tau]; tauEstimate = tau; } } } if (tauEstimate == 0) - { - lastResult = { 0.0f, 0.0f }; - return; - } + return candidate; + candidate.boundary = tauEstimate + 2 >= n; float betterTau = static_cast(tauEstimate); if (tauEstimate > 0 && tauEstimate < n - 1) { - float s0 = cmndf[tauEstimate - 1]; - float s1 = cmndf[tauEstimate]; - float s2 = cmndf[tauEstimate + 1]; - float denom = 2.0f * (2.0f * s1 - s2 - s0); - if (std::abs(denom) > 1e-12f) - betterTau += (s2 - s0) / denom; + float s0 = workspace.cmndf[tauEstimate - 1]; + float s1 = workspace.cmndf[tauEstimate]; + float s2 = workspace.cmndf[tauEstimate + 1]; + float denominator = 2.0f * (2.0f * s1 - s2 - s0); + if (std::abs(denominator) > 1e-12f) + betterTau += (s2 - s0) / denominator; } if (betterTau < 1.0f) + return {}; + + float frequency = static_cast(analysisSR) / betterTau; + float confidence = 1.0f - workspace.cmndf[tauEstimate]; + + if (!std::isfinite(frequency) || !std::isfinite(confidence) + || frequency < minimumOutputFrequency || frequency > maximumOutputFrequency) + return {}; + + candidate.result = { frequency, std::clamp(confidence, 0.0f, 1.0f) }; + return candidate; +} + +void YinPitchDetector::considerCandidate(const PitchCandidate& candidate) +{ + if (!hasPitch(candidate.result) || candidate.boundary) { - lastResult = { 0.0f, 0.0f }; + pendingCandidate = {}; + pendingCount = 0; + int missLimit = (lastResult.frequency > 0.0f + && lastResult.frequency <= lowSelectionMaximumFrequency) + ? lowCandidateMissLimit : 2; + if (++missedCandidates >= missLimit) + publishResult({}); return; } - float freq = static_cast(analysisSR) / betterTau; - float conf = 1.0f - cmndf[tauEstimate]; + missedCandidates = 0; - if (freq < 20.0f || freq > 5000.0f) + if (lastResult.frequency <= 0.0f) + { + if (candidate.thresholdQualified + && candidate.result.confidence >= qualifiedCandidateConfidence) + { + publishResult(candidate.result); + return; + } + } + else if (frequenciesAreClose(lastResult.frequency, candidate.result.frequency)) + { + publishResult(candidate.result); + return; + } + else if (!candidate.thresholdQualified + && candidate.result.confidence < fallbackCandidateConfidence) { - lastResult = { 0.0f, 0.0f }; + pendingCandidate = {}; + pendingCount = 0; return; } - lastResult = { freq, std::clamp(conf, 0.0f, 1.0f) }; + int confirmationsRequired = (candidate.thresholdQualified + || pendingCandidate.thresholdQualified) ? 2 : 4; + if (hasPitch(pendingCandidate.result) + && frequenciesAreWithin(pendingCandidate.result.frequency, + candidate.result.frequency, + movingCandidateToleranceOctaves)) + { + pendingCandidate = candidate; + if (++pendingCount >= confirmationsRequired) + publishResult(candidate.result); + } + else + { + pendingCandidate = candidate; + pendingCount = 1; + } +} + +void YinPitchDetector::publishResult(PitchResult result) +{ + lastResult = result; + pendingCandidate = {}; + pendingCount = 0; + atomicResult.store(packResult(result), std::memory_order_release); +} + +void YinPitchDetector::clearResult() +{ + lastResult = {}; + pendingCandidate = {}; + pendingCount = 0; + missedCandidates = 0; + atomicResult.store(packResult({}), std::memory_order_release); } diff --git a/source/dsp/YinPitchDetector.h b/source/dsp/YinPitchDetector.h index d4ed96e..9f83c45 100644 --- a/source/dsp/YinPitchDetector.h +++ b/source/dsp/YinPitchDetector.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include "HalfbandDecimator.h" @@ -26,58 +28,107 @@ class YinPitchDetector fifo.prepareToWrite(1, start1, size1, start2, size2); if (size1 > 0) fifoBuffer[static_cast(start1)] = sample; + else if (size2 > 0) + fifoBuffer[static_cast(start2)] = sample; + else + fifoOverflowed.store(true, std::memory_order_release); fifo.finishedWrite(size1 + size2); } inline PitchResult getResult() const { - return { atomicFreq.load(std::memory_order_relaxed), - atomicConf.load(std::memory_order_relaxed) }; + return unpackResult(atomicResult.load(std::memory_order_acquire)); } void flushForTest(); private: - void analyse(int samplesToAnalyse); + struct AnalysisWorkspace + { + int halfWindow = 0; + int windowSize = 0; + int fftSize = 0; + std::unique_ptr fft; + std::vector linearBuffer; + std::vector fftInput; + std::vector fftOutput; + std::vector diff; + std::vector cmndf; + }; + + struct PitchCandidate + { + PitchResult result; + bool thresholdQualified = false; + bool boundary = false; + }; + + void stopAnalysisThread(); + void prepareWorkspace(AnalysisWorkspace& workspace, double minimumFrequency); + PitchCandidate analyse(AnalysisWorkspace& workspace, int samplesToAnalyse); + void considerCandidate(const PitchCandidate& candidate); + void publishResult(PitchResult result); + void clearResult(); + + static uint64_t packResult(PitchResult result) + { + auto frequency = std::bit_cast(result.frequency); + auto confidence = std::bit_cast(result.confidence); + return static_cast(frequency) | (static_cast(confidence) << 32); + } + + static PitchResult unpackResult(uint64_t packed) + { + return { std::bit_cast(static_cast(packed)), + std::bit_cast(static_cast(packed >> 32)) }; + } class AnalysisThread; friend class AnalysisThread; double analysisSR = 44100.0; - int windowSize = 0; - int halfWindow = 0; - int hopSize = 0; + int quickHopSize = 0; + int guitarAuditSize = 0; + int lowHopSize = 0; + int lowAuditSize = 0; + int maxBacklogSamples = 0; std::vector buffer; - std::vector linearBuffer; int writePos = 0; - int hopCounter = 0; int activeWindowSize = 0; - int minAnalysisWindow = 0; + int quickHopCounter = 0; + int guitarAuditCounter = 0; + int lowAnalysisCounter = 0; float activityEnvelope = 0.0f; + float activityRelease = 0.995f; - std::unique_ptr fft; - int fftOrder = 0; - int fftSize = 0; - std::vector fftInput; - std::vector fftOutput; - - std::vector diff; - std::vector cmndf; + AnalysisWorkspace quickAnalysis; + AnalysisWorkspace guitarAnalysis; + AnalysisWorkspace lowAnalysis; PitchResult lastResult; + PitchCandidate pendingCandidate; + int pendingCount = 0; + int missedCandidates = 0; static constexpr float threshold = 0.15f; static constexpr float silenceThreshold = 1e-5f; - static constexpr float activityRelease = 0.995f; + static constexpr float activityReleaseSeconds = 0.009f; + static constexpr float quickMinimumFrequency = 200.0f; + static constexpr float guitarMinimumFrequency = 80.0f; + static constexpr float lowSelectionMaximumFrequency = 82.0f; + static constexpr float lowAnalysisMinimumFrequency = 18.0f; + static constexpr float minimumOutputFrequency = 20.0f; + static constexpr float maximumOutputFrequency = 5000.0f; juce::AbstractFifo fifo { 0 }; std::vector fifoBuffer; + std::atomic fifoOverflowed { false }; std::unique_ptr analysisThread; - std::atomic atomicFreq { 0.0f }; - std::atomic atomicConf { 0.0f }; + static_assert(std::atomic::is_always_lock_free); + std::atomic atomicResult { 0 }; HalfbandDecimator decimator; }; diff --git a/tests/TestPitchSmoother.cpp b/tests/TestPitchSmoother.cpp index 2864870..5cffa30 100644 --- a/tests/TestPitchSmoother.cpp +++ b/tests/TestPitchSmoother.cpp @@ -44,6 +44,22 @@ TEST_CASE("PitchSmoother: holds last frequency when confidence drops") Catch::Matchers::WithinAbs(440.0, 0.1)); } +TEST_CASE("PitchSmoother: releases a stale pitch after invalid input") +{ + PitchSmoother smoother; + smoother.prepare(44100.0); + smoother.setSmoothingAmount(0.0f); + smoother.setSensitivity(0.5f); + + smoother.process(440.0f, 1.0f); + float held = 0.0f; + for (int i = 0; i < 2204; ++i) + held = smoother.process(0.0f, 0.0f); + + REQUIRE(held > 0.0f); + REQUIRE(smoother.process(0.0f, 0.0f) == 0.0f); +} + TEST_CASE("PitchSmoother: prepare resets state") { PitchSmoother smoother; diff --git a/tests/TestYinPitchDetector.cpp b/tests/TestYinPitchDetector.cpp index 7c8c936..cf8d219 100644 --- a/tests/TestYinPitchDetector.cpp +++ b/tests/TestYinPitchDetector.cpp @@ -53,6 +53,35 @@ static void feedHarmonicComplex(YinPitchDetector& yin, double sampleRate, float yin.flushForTest(); } +static void feedUpperHarmonics(YinPitchDetector& yin, double sampleRate, float freq, + int numSamples, double fundamentalAmplitude, double& phase) +{ + double inc = twoPi * static_cast(freq) / sampleRate; + + for (int i = 0; i < numSamples; ++i) + { + double s = fundamentalAmplitude * std::sin(phase) + + std::sin(2.0 * phase) + + 0.7 * std::sin(3.0 * phase) + + 0.4 * std::sin(4.0 * phase); + yin.feedSample(static_cast(s * 0.2)); + phase += inc; + } +} + +static void feedWeakFundamental(YinPitchDetector& yin, double sampleRate, float freq, int numSamples) +{ + double phase = 0.0; + feedUpperHarmonics(yin, sampleRate, freq, numSamples, 0.2, phase); + yin.flushForTest(); +} + +static bool withinCents(float actual, float expected, float cents) +{ + return actual > 0.0f + && std::abs(std::log2(actual / expected)) <= cents / 1200.0f; +} + static int feedSineUntilDetection(YinPitchDetector& yin, double sampleRate, float freq, int maxSamples) { double phase = 0.0; @@ -133,6 +162,125 @@ TEST_CASE("YIN: detects E2 (82.4 Hz) at 44100 Hz") Catch::Matchers::WithinRel(82.4, 0.03)); } +TEST_CASE("YIN: detects eight-string and bass-range fundamentals") +{ + constexpr float frequencies[] { 81.0f, 80.0f, 79.0f, 46.25f, 41.20f, 30.87f, 23.12f, 20.0f }; + + for (float frequency : frequencies) + { + YinPitchDetector yin; + yin.prepare(44100.0); + + feedSine(yin, 44100.0, frequency, 44100); + + auto result = yin.getResult(); + CAPTURE(frequency, result.frequency, result.confidence); + REQUIRE(result.frequency > 0.0f); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(static_cast(frequency), 0.03)); + REQUIRE(result.confidence > 0.8f); + } +} + +TEST_CASE("YIN: low tracking is stable across phase and level") +{ + constexpr double phases[] { 0.0, 0.7, 1.5707963267948966 }; + constexpr double amplitudes[] { 0.5, 0.01, 0.001 }; + + for (double initialPhase : phases) + { + for (double amplitude : amplitudes) + { + YinPitchDetector yin; + yin.prepare(44100.0); + + double phase = initialPhase; + double increment = twoPi * 46.25 / 44100.0; + for (int i = 0; i < 13230; ++i) + { + yin.feedSample(static_cast(amplitude * std::sin(phase))); + phase += increment; + } + yin.flushForTest(); + + auto result = yin.getResult(); + CAPTURE(initialPhase, amplitude, result.frequency, result.confidence); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(46.25, 0.03)); + REQUIRE(result.confidence > 0.8f); + } + } +} + +TEST_CASE("YIN: resolution boundary is covered across sample rates and phases") +{ + constexpr double sampleRates[] { 44100.0, 48000.0, 96000.0 }; + constexpr double phases[] { 0.0, 0.7 }; + + for (double sampleRate : sampleRates) + { + for (double initialPhase : phases) + { + YinPitchDetector yin; + yin.prepare(sampleRate); + + double phase = initialPhase; + double increment = twoPi * 80.0 / sampleRate; + for (int i = 0; i < static_cast(sampleRate * 0.3); ++i) + { + yin.feedSample(static_cast(0.2 * std::sin(phase))); + phase += increment; + } + yin.flushForTest(); + + auto result = yin.getResult(); + CAPTURE(sampleRate, initialPhase, result.frequency, result.confidence); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(80.0, 0.03)); + REQUIRE(result.confidence > 0.8f); + } + } +} + +TEST_CASE("YIN: low tracking tolerates detector-path DC offset") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + double phase = 0.0; + double increment = twoPi * 46.25 / 44100.0; + for (int i = 0; i < 13230; ++i) + { + yin.feedSample(static_cast(0.5 + 0.01 * std::sin(phase))); + phase += increment; + } + yin.flushForTest(); + + auto result = yin.getResult(); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(46.25, 0.03)); + REQUIRE(result.confidence > 0.8f); +} + +TEST_CASE("YIN: detects F sharp 1 across supported sample rates") +{ + constexpr double sampleRates[] { 44100.0, 48000.0, 96000.0 }; + + for (double sampleRate : sampleRates) + { + YinPitchDetector yin; + yin.prepare(sampleRate); + + feedSine(yin, sampleRate, 46.25f, static_cast(sampleRate)); + + auto result = yin.getResult(); + CAPTURE(sampleRate, result.frequency, result.confidence); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(46.25, 0.03)); + REQUIRE(result.confidence > 0.8f); + } +} + TEST_CASE("YIN: detects 880 Hz sine at 48000 Hz") { YinPitchDetector yin; @@ -326,6 +474,289 @@ TEST_CASE("YIN: fallback detects harmonically complex low signal") REQUIRE(result.frequency < 200.0f); } +TEST_CASE("YIN: detects a weak fundamental on the lowest eight-string note") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + feedWeakFundamental(yin, 44100.0, 46.25f, 44100); + + auto result = yin.getResult(); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(46.25, 0.03)); + REQUIRE(result.confidence > 0.8f); +} + +TEST_CASE("YIN: detects a missing fundamental on the lowest eight-string note") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + double phase = 0.0; + feedUpperHarmonics(yin, 44100.0, 46.25f, 44100, 0.0, phase); + yin.flushForTest(); + + auto result = yin.getResult(); + REQUIRE_THAT(static_cast(result.frequency), + Catch::Matchers::WithinRel(46.25, 0.03)); + REQUIRE(result.confidence > 0.8f); +} + +TEST_CASE("YIN: rejects periodic input below the supported range") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + feedSine(yin, 44100.0, 15.0f, 44100); + + auto result = yin.getResult(); + REQUIRE(result.frequency == 0.0f); + REQUIRE(result.confidence == 0.0f); +} + +TEST_CASE("YIN: acquires F sharp 1 within two periods plus one low-resolution hop") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + int samplesNeeded = feedSineUntilDetection(yin, 44100.0, 46.25f, 6615); + int bound = static_cast(std::ceil(2.0 * 44100.0 / 46.25)) + + static_cast(std::ceil(44100.0 * 0.012)); + + CAPTURE(samplesNeeded, bound); + REQUIRE(samplesNeeded <= bound); + REQUIRE_THAT(static_cast(yin.getResult().frequency), + Catch::Matchers::WithinRel(46.25, 0.03)); +} + +TEST_CASE("YIN: continuous fast pitch step does not publish intermediate guesses") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + double phase = 0.0; + feedUpperHarmonics(yin, 44100.0, 164.81f, 11025, 0.35, phase); + yin.flushForTest(); + REQUIRE(withinCents(yin.getResult().frequency, 164.81f, 50.0f)); + + bool reachedTarget = false; + for (int chunk = 0; chunk < 15; ++chunk) + { + feedUpperHarmonics(yin, 44100.0, 110.0f, 132, 0.35, phase); + yin.flushForTest(); + + auto result = yin.getResult(); + CAPTURE(chunk, result.frequency, result.confidence); + bool heldPrevious = withinCents(result.frequency, 164.81f, 50.0f); + bool reachedNew = withinCents(result.frequency, 110.0f, 50.0f); + REQUIRE((heldPrevious || reachedNew)); + if (chunk == 0) + REQUIRE(heldPrevious); + reachedTarget = reachedTarget || reachedNew; + } + + REQUIRE(reachedTarget); +} + +TEST_CASE("YIN: low-to-high pitch step switches without octave glitches") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + double phase = 0.0; + feedUpperHarmonics(yin, 44100.0, 46.25f, 11025, 0.2, phase); + yin.flushForTest(); + REQUIRE(withinCents(yin.getResult().frequency, 46.25f, 50.0f)); + + bool reachedTarget = false; + for (int chunk = 0; chunk < 7; ++chunk) + { + feedUpperHarmonics(yin, 44100.0, 220.0f, 132, 0.2, phase); + yin.flushForTest(); + + auto result = yin.getResult(); + CAPTURE(chunk, result.frequency, result.confidence); + bool heldPrevious = withinCents(result.frequency, 46.25f, 50.0f); + bool reachedNew = withinCents(result.frequency, 220.0f, 50.0f); + REQUIRE((heldPrevious || reachedNew)); + if (chunk == 0) + REQUIRE(heldPrevious); + reachedTarget = reachedTarget || reachedNew; + } + + REQUIRE(reachedTarget); +} + +TEST_CASE("YIN: direct step to tracking floor reacquires without an octave guess") +{ + constexpr double sampleRates[] { 44100.0, 48000.0, 96000.0 }; + + for (double sampleRate : sampleRates) + { + YinPitchDetector yin; + yin.prepare(sampleRate); + + double phase = 0.0; + feedUpperHarmonics(yin, sampleRate, 46.25f, + static_cast(sampleRate * 0.25), 0.2, phase); + yin.flushForTest(); + REQUIRE(withinCents(yin.getResult().frequency, 46.25f, 50.0f)); + + bool reachedTarget = false; + int chunkSize = static_cast(std::ceil(sampleRate * hopSeconds)); + for (int chunk = 0; chunk < 40; ++chunk) + { + feedUpperHarmonics(yin, sampleRate, 20.0f, chunkSize, 0.2, phase); + yin.flushForTest(); + + auto result = yin.getResult(); + CAPTURE(sampleRate, chunk, result.frequency, result.confidence); + bool heldPrevious = withinCents(result.frequency, 46.25f, 50.0f); + bool reachedNew = withinCents(result.frequency, 20.0f, 50.0f); + REQUIRE((heldPrevious || reachedNew)); + reachedTarget = reachedTarget || reachedNew; + } + + CAPTURE(sampleRate); + REQUIRE(reachedTarget); + } +} + +TEST_CASE("YIN: tracked low pitch releases after an unsupported downward step") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + double phase = 0.0; + feedUpperHarmonics(yin, 44100.0, 46.25f, 11025, 0.2, phase); + yin.flushForTest(); + REQUIRE(withinCents(yin.getResult().frequency, 46.25f, 50.0f)); + + bool released = false; + for (int chunk = 0; chunk < 15; ++chunk) + { + feedUpperHarmonics(yin, 44100.0, 15.0f, 528, 0.2, phase); + yin.flushForTest(); + + auto result = yin.getResult(); + CAPTURE(chunk, result.frequency, result.confidence); + REQUIRE((result.frequency == 0.0f + || withinCents(result.frequency, 46.25f, 50.0f))); + released = released || result.frequency == 0.0f; + } + + REQUIRE(released); +} + +TEST_CASE("YIN: fast downward pitch bend does not jump upward") +{ + YinPitchDetector yin; + yin.prepare(44100.0); + + double phase = 0.0; + feedUpperHarmonics(yin, 44100.0, 220.0f, 11025, 0.2, phase); + yin.flushForTest(); + + float previousPublished = yin.getResult().frequency; + constexpr int chunkSize = 132; + constexpr int chunks = 40; + for (int chunk = 0; chunk < chunks; ++chunk) + { + for (int i = 0; i < chunkSize; ++i) + { + double progress = static_cast(chunk * chunkSize + i) + / static_cast(chunks * chunkSize - 1); + double frequency = 220.0 * std::pow(46.25 / 220.0, progress); + double sample = 0.2 * std::sin(phase) + + std::sin(2.0 * phase) + + 0.7 * std::sin(3.0 * phase); + yin.feedSample(static_cast(sample * 0.2)); + phase += twoPi * frequency / 44100.0; + } + yin.flushForTest(); + + auto result = yin.getResult(); + CAPTURE(chunk, previousPublished, result.frequency, result.confidence); + if (result.frequency > 0.0f) + { + REQUIRE(result.frequency <= previousPublished * 1.08f); + previousPublished = result.frequency; + } + } + + feedUpperHarmonics(yin, 44100.0, 46.25f, 6615, 0.2, phase); + yin.flushForTest(); + REQUIRE(withinCents(yin.getResult().frequency, 46.25f, 50.0f)); +} + +TEST_CASE("YIN: fast bend below eight-string range keeps tracking downward") +{ + constexpr double sampleRates[] { 44100.0, 48000.0, 96000.0 }; + constexpr int chunks = 40; + + for (double sampleRate : sampleRates) + { + YinPitchDetector yin; + yin.prepare(sampleRate); + + double phase = 0.0; + feedUpperHarmonics(yin, sampleRate, 46.25f, + static_cast(sampleRate * 0.25), 0.2, phase); + yin.flushForTest(); + + int chunkSize = static_cast(std::ceil(sampleRate * hopSeconds)); + float previousPublished = yin.getResult().frequency; + float halfwayResult = previousPublished; + float threeQuarterResult = previousPublished; + int downwardUpdates = 0; + for (int chunk = 0; chunk < chunks; ++chunk) + { + for (int i = 0; i < chunkSize; ++i) + { + double progress = static_cast(chunk * chunkSize + i) + / static_cast(chunks * chunkSize - 1); + double frequency = 46.25 * std::pow(30.0 / 46.25, progress); + double sample = 0.2 * std::sin(phase) + + std::sin(2.0 * phase) + + 0.7 * std::sin(3.0 * phase); + yin.feedSample(static_cast(sample * 0.2)); + phase += twoPi * frequency / sampleRate; + } + yin.flushForTest(); + + auto result = yin.getResult(); + CAPTURE(sampleRate, chunk, previousPublished, + result.frequency, result.confidence); + if (result.frequency > 0.0f) + { + REQUIRE(result.frequency <= previousPublished * 1.08f); + if (result.frequency < previousPublished * std::exp2(-10.0f / 1200.0f)) + ++downwardUpdates; + previousPublished = result.frequency; + } + if (chunk == chunks / 2 - 1) + halfwayResult = result.frequency; + if (chunk == 3 * chunks / 4 - 1) + threeQuarterResult = result.frequency; + } + + CAPTURE(sampleRate, halfwayResult, threeQuarterResult, downwardUpdates); + REQUIRE(halfwayResult < 46.0f); + REQUIRE(threeQuarterResult < 43.0f); + REQUIRE(downwardUpdates >= 2); + + auto bentResult = yin.getResult(); + CAPTURE(sampleRate, bentResult.frequency, bentResult.confidence); + REQUIRE(bentResult.frequency < 40.0f); + REQUIRE(bentResult.frequency > 25.0f); + + feedUpperHarmonics(yin, sampleRate, 30.0f, + static_cast(sampleRate * 0.1), 0.2, phase); + yin.flushForTest(); + REQUIRE(withinCents(yin.getResult().frequency, 30.0f, 50.0f)); + } +} + TEST_CASE("YIN: silence still returns zero with fallback") { YinPitchDetector yin;