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
7 changes: 5 additions & 2 deletions source/dsp/PitchSmoother.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,17 @@ class PitchSmoother
return std::exp2(smoothed);
}

smoothed += alpha * (logFreq - smoothed);
float delta = std::abs(logFreq - smoothed);
float effectiveAlpha = delta > 0.08f ? 1.0f : alpha;

smoothed += effectiveAlpha * (logFreq - smoothed);
return std::exp2(smoothed);
}

private:
inline void recomputeAlpha()
{
float tau = smoothingAmount * 0.2f;
float tau = smoothingAmount * 0.05f;
if (tau < 1e-6f)
alpha = 1.0f;
else
Expand Down
95 changes: 61 additions & 34 deletions source/dsp/YinPitchDetector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,46 @@

void YinPitchDetector::prepare(double sampleRate)
{
decimation = (sampleRate > 50000.0) ? 4 : 2;
decimationCounter = 0;
decimationAccum = 0.0f;
analysisSR = sampleRate / decimation;
analysisSR = sampleRate;

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded windowSize = 1024 ignores sample rate -- E2 detection will fail at 96kHz.

At 44.1kHz, 1024 samples = ~23ms, which covers ~1.9 periods of E2 (82.4Hz). That's tight but workable with parabolic interpolation.

At 96kHz, 1024 samples = ~10.7ms, which covers only ~0.88 periods of E2. The YIN algorithm needs at least 2 periods in the analysis window (halfWindow = 512 samples = ~5.3ms at 96kHz = 0.44 periods). This is fundamentally insufficient -- YIN cannot find a valid tau for E2 because the period (~1164 samples at 96kHz) exceeds halfWindow.

The old code handled this via decimation (effectively analyzing at 24kHz at 96k SR). Since decimation is removed, the window size should scale with sample rate to maintain the same frequency coverage:

windowSize = static_cast<int>(sampleRate / 44100.0 * 1024);

This gives 1024 at 44.1k, 1115 at 48k, 2227 at 96k -- preserving the ~23ms analysis window regardless of sample rate.

The hopSize should probably scale proportionally too to maintain the same ~3ms update interval.

windowSize = 2048;
halfWindow = windowSize / 2;
halfWindow = static_cast<int>(std::ceil(sampleRate / 70.0));
windowSize = 2 * halfWindow;
hopSize = static_cast<int>(std::ceil(sampleRate * 0.003));

fftOrder = static_cast<int>(std::ceil(std::log2(2.0 * windowSize)));
fftSize = 1 << fftOrder;
fft = std::make_unique<juce::dsp::FFT>(fftOrder);
fftInput.resize(static_cast<size_t>(fftSize * 2), 0.0f);
fftOutput.resize(static_cast<size_t>(fftSize * 2), 0.0f);

buffer.assign(static_cast<size_t>(windowSize), 0.0f);
linearBuffer.resize(static_cast<size_t>(windowSize));
diff.resize(static_cast<size_t>(halfWindow));
cmndf.resize(static_cast<size_t>(halfWindow));

writePos = 0;
bufferFull = false;
hopCounter = 0;
windowFilled = false;
lastResult = {};
}

void YinPitchDetector::feedSample(float sample)
{
decimationAccum += sample;
if (++decimationCounter < decimation)
return;

float decimatedSample = decimationAccum / static_cast<float>(decimation);
decimationAccum = 0.0f;
decimationCounter = 0;
buffer[static_cast<size_t>(writePos)] = sample;
writePos = (writePos + 1) % windowSize;
++hopCounter;

buffer[static_cast<size_t>(writePos)] = decimatedSample;
++writePos;
if (!windowFilled)
{
if (writePos == 0)
windowFilled = true;
else
return;
}

if (writePos >= windowSize)
if (hopCounter >= hopSize)
{
writePos = 0;
bufferFull = true;
hopCounter = 0;
analyse();
}
}
Expand All @@ -49,24 +55,47 @@ PitchResult YinPitchDetector::getResult() const

void YinPitchDetector::analyse()
{
if (!bufferFull)
return;

auto n = static_cast<size_t>(halfWindow);

// Step 1: Difference function
for (size_t tau = 0; tau < n; ++tau)
for (int i = 0; i < windowSize; ++i)
linearBuffer[static_cast<size_t>(i)] = buffer[static_cast<size_t>((writePos + i) % windowSize)];
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ring buffer linearization copies the full window every hop -- consider whether this matters for Phase 2.

Right now this copies 1024 floats every 128 samples, which is fine. But worth noting: when Phase 2 adds FFT-accelerated autocorrelation, you'll need to copy data into the FFT buffer anyway. At that point this linearBuffer copy becomes redundant -- the FFT input buffer serves the same purpose.

Not a blocker for this phase, just flagging so Phase 2 doesn't end up doing two copies.


std::fill(fftInput.begin(), fftInput.end(), 0.0f);
for (size_t i = 0; i < n; ++i)
fftInput[i] = linearBuffer[i];
fft->performRealOnlyForwardTransform(fftInput.data());

std::fill(fftOutput.begin(), fftOutput.end(), 0.0f);
for (int i = 0; i < windowSize; ++i)
fftOutput[static_cast<size_t>(i)] = linearBuffer[static_cast<size_t>(i)];
fft->performRealOnlyForwardTransform(fftOutput.data());

for (int k = 0; k < fftSize; ++k)
{
float sum = 0.0f;
for (size_t j = 0; j < n; ++j)
{
float d = buffer[j] - buffer[j + tau];
sum += d * d;
}
diff[tau] = sum;
float aRe = fftInput[static_cast<size_t>(2 * k)];
float aIm = fftInput[static_cast<size_t>(2 * k + 1)];
float bRe = fftOutput[static_cast<size_t>(2 * k)];
float bIm = fftOutput[static_cast<size_t>(2 * k + 1)];
fftInput[static_cast<size_t>(2 * k)] = aRe * bRe + aIm * bIm;
fftInput[static_cast<size_t>(2 * k + 1)] = aRe * bIm - aIm * bRe;
}

fft->performRealOnlyInverseTransform(fftInput.data());

float powerTerm0 = 0.0f;
for (size_t j = 0; j < n; ++j)
powerTerm0 += linearBuffer[j] * linearBuffer[j];

float powerTermTau = powerTerm0;

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];
}

// Step 2: Cumulative mean normalized difference function
cmndf[0] = 1.0f;
float runningSum = 0.0f;

Expand All @@ -79,7 +108,6 @@ void YinPitchDetector::analyse()
cmndf[tau] = 1.0f;
}

// Step 3: Absolute threshold
size_t tauEstimate = 0;
for (size_t tau = 2; tau < n; ++tau)
{
Expand All @@ -98,7 +126,6 @@ void YinPitchDetector::analyse()
return;
}

// Step 4: Parabolic interpolation
float betterTau = static_cast<float>(tauEstimate);

if (tauEstimate > 0 && tauEstimate < n - 1)
Expand Down
21 changes: 14 additions & 7 deletions source/dsp/YinPitchDetector.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#pragma once

#include <juce_dsp/juce_dsp.h>
#include <memory>
#include <vector>

struct PitchResult
Expand All @@ -19,16 +21,21 @@ class YinPitchDetector
void analyse();

double analysisSR = 44100.0;
int windowSize = 2048;
int halfWindow = 1024;

int decimation = 1;
int decimationCounter = 0;
float decimationAccum = 0.0f;
int windowSize = 0;
int halfWindow = 0;
int hopSize = 0;

std::vector<float> buffer;
std::vector<float> linearBuffer;
int writePos = 0;
bool bufferFull = false;
int hopCounter = 0;
bool windowFilled = false;

std::unique_ptr<juce::dsp::FFT> fft;
int fftOrder = 0;
int fftSize = 0;
std::vector<float> fftInput;
std::vector<float> fftOutput;

std::vector<float> diff;
std::vector<float> cmndf;
Expand Down
20 changes: 14 additions & 6 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ FetchContent_Declare(
)
FetchContent_MakeAvailable(Catch2)

add_executable(HdnRingmodTests
juce_add_console_app(HdnRingmodTests
PRODUCT_NAME "HDN Ring Modulator Tests"
)

target_sources(HdnRingmodTests PRIVATE
TestOscillator.cpp
TestYinPitchDetector.cpp
TestPitchSmoother.cpp
TestParameters.cpp
)

# PitchSmoother is header-only so doesn't need a source entry here
target_sources(HdnRingmodTests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../source/dsp/Oscillator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../source/dsp/YinPitchDetector.cpp
)
Expand All @@ -25,7 +25,15 @@ target_include_directories(HdnRingmodTests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../source
)

target_link_libraries(HdnRingmodTests PRIVATE Catch2::Catch2WithMain)
target_compile_definitions(HdnRingmodTests PRIVATE
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
)

target_link_libraries(HdnRingmodTests PRIVATE
juce::juce_dsp
Catch2::Catch2WithMain
)

include(CTest)
include(Catch)
Expand Down
76 changes: 63 additions & 13 deletions tests/TestPitchSmoother.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ TEST_CASE("PitchSmoother: zero smoothing passes frequency through immediately")
Catch::Matchers::WithinAbs(440.0, 0.1));
}

TEST_CASE("PitchSmoother: smoothing slows convergence")
TEST_CASE("PitchSmoother: smoothing slows convergence within a semitone")
{
PitchSmoother fast, slow;
fast.prepare(44100.0);
Expand All @@ -92,32 +92,82 @@ TEST_CASE("PitchSmoother: smoothing slows convergence")
slow.setSmoothingAmount(0.9f);
slow.setSensitivity(1.0f);

fast.process(220.0f, 1.0f);
slow.process(220.0f, 1.0f);
fast.process(440.0f, 1.0f);
slow.process(440.0f, 1.0f);

float fastOut = 0.0f, slowOut = 0.0f;
for (int i = 0; i < 1000; ++i)
for (int i = 0; i < 100; ++i)
{
fastOut = fast.process(440.0f, 1.0f);
slowOut = slow.process(440.0f, 1.0f);
fastOut = fast.process(443.0f, 1.0f);
slowOut = slow.process(443.0f, 1.0f);
}

REQUIRE(std::abs(fastOut - 440.0f) < std::abs(slowOut - 440.0f));
REQUIRE(std::abs(fastOut - 443.0f) < std::abs(slowOut - 443.0f));
}

TEST_CASE("PitchSmoother: works in log-frequency domain (octave-uniform)")
TEST_CASE("PitchSmoother: smoothing operates in log-frequency domain")
{
PitchSmoother smoother;
smoother.prepare(44100.0);
smoother.setSmoothingAmount(0.5f);
smoother.setSensitivity(1.0f);

smoother.process(220.0f, 1.0f);
smoother.process(440.0f, 1.0f);

float output = 0.0f;
for (int i = 0; i < 4410; ++i)
output = smoother.process(440.0f, 1.0f);
for (int i = 0; i < 100; ++i)
output = smoother.process(445.0f, 1.0f);

REQUIRE(output > 440.0f);
REQUIRE(output < 445.0f);
}

TEST_CASE("PitchSmoother: instant lock on note change")
{
PitchSmoother smoother;
smoother.prepare(44100.0);
smoother.setSmoothingAmount(1.0f);
smoother.setSensitivity(1.0f);

REQUIRE(output > 220.0f);
REQUIRE(output < 440.0f);
smoother.process(440.0f, 1.0f);

float output = smoother.process(880.0f, 1.0f);
REQUIRE_THAT(static_cast<double>(output),
Catch::Matchers::WithinAbs(880.0, 0.1));
}

TEST_CASE("PitchSmoother: holds during alternating confidence gaps")
{
PitchSmoother smoother;
smoother.prepare(44100.0);
smoother.setSmoothingAmount(0.0f);
smoother.setSensitivity(0.5f);

smoother.process(440.0f, 1.0f);

for (int i = 0; i < 10; ++i)
{
float held = smoother.process(0.0f, 0.0f);
REQUIRE_THAT(static_cast<double>(held),
Catch::Matchers::WithinAbs(440.0, 0.1));

float active = smoother.process(440.0f, 1.0f);
REQUIRE_THAT(static_cast<double>(active),
Catch::Matchers::WithinAbs(440.0, 0.1));
}
}

TEST_CASE("PitchSmoother: stable output on sustained note")
{
PitchSmoother smoother;
smoother.prepare(44100.0);
smoother.setSmoothingAmount(0.5f);
smoother.setSensitivity(1.0f);

for (int i = 0; i < 1000; ++i)
smoother.process(440.0f, 1.0f);

float output = smoother.process(440.0f, 1.0f);
REQUIRE_THAT(static_cast<double>(output),
Catch::Matchers::WithinAbs(440.0, 0.01));
}
Loading
Loading