Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion source/dsp/PitchSmoother.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 33 additions & 16 deletions source/dsp/YinPitchDetector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,28 @@ class YinPitchDetector::AnalysisThread : public juce::Thread

o.buffer[static_cast<size_t>(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);
}
Expand Down Expand Up @@ -91,9 +99,11 @@ void YinPitchDetector::prepare(double sampleRate)

analysisSR = decimatedSR;

halfWindow = static_cast<int>(std::ceil(decimatedSR / 60.0));
halfWindow = static_cast<int>(std::ceil(decimatedSR / 80.0));
windowSize = 2 * halfWindow;
hopSize = static_cast<int>(std::ceil(decimatedSR * 0.012));
hopSize = static_cast<int>(std::ceil(decimatedSR * 0.003));
minAnalysisWindow = 2 * static_cast<int>(std::ceil(decimatedSR / 200.0));
minAnalysisWindow = std::min(minAnalysisWindow, windowSize);

fftOrder = static_cast<int>(std::ceil(std::log2(2.0 * windowSize)));
fftSize = 1 << fftOrder;
Expand All @@ -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);
Expand All @@ -130,21 +141,27 @@ void YinPitchDetector::flushForTest()
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

void YinPitchDetector::analyse()
void YinPitchDetector::analyse(int samplesToAnalyse)
{
auto n = static_cast<size_t>(halfWindow);
int activeHalfWindow = std::clamp(samplesToAnalyse / 2, 2, halfWindow);
int activeWindow = 2 * activeHalfWindow;
auto n = static_cast<size_t>(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)
fftInput[i] = linearBuffer[i];
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<size_t>(i)] = linearBuffer[static_cast<size_t>(i)];
fft->performRealOnlyForwardTransform(fftOutput.data());

Expand Down
8 changes: 6 additions & 2 deletions source/dsp/YinPitchDetector.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class YinPitchDetector
void flushForTest();

private:
void analyse();
void analyse(int samplesToAnalyse);

class AnalysisThread;
friend class AnalysisThread;
Expand All @@ -52,7 +52,9 @@ class YinPitchDetector
std::vector<float> linearBuffer;
int writePos = 0;
int hopCounter = 0;
bool windowFilled = false;
int activeWindowSize = 0;
int minAnalysisWindow = 0;
float activityEnvelope = 0.0f;

std::unique_ptr<juce::dsp::FFT> fft;
int fftOrder = 0;
Expand All @@ -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<float> fifoBuffer;
Expand Down
37 changes: 37 additions & 0 deletions tests/TestPitchSmoother.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>(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<double>(result),
Catch::Matchers::WithinAbs(440.0, 0.1));
}

TEST_CASE("PitchSmoother: zero smoothing passes frequency through immediately")
{
PitchSmoother smoother;
Expand Down
51 changes: 49 additions & 2 deletions tests/TestYinPitchDetector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(std::ceil(decimatedSR / 60.0));
int halfWindow = static_cast<int>(std::ceil(decimatedSR / minimumTrackedFrequency));
return 2 * halfWindow;
}

static int computeHopSize(double sampleRate)
{
double decimatedSR = sampleRate / decimationFactor;
return static_cast<int>(std::ceil(decimatedSR * 0.012));
return static_cast<int>(std::ceil(decimatedSR * hopSeconds));
}

static void feedSine(YinPitchDetector& yin, double sampleRate, float freq, int numSamples)
Expand Down Expand Up @@ -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<double>(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<double>(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<double>(result.frequency),
Catch::Matchers::WithinRel(82.4, 0.03));
}

TEST_CASE("YIN: detects pitch change after silence")
{
YinPitchDetector yin;
Expand Down
Loading