diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index 9be064e..1154231 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -23,8 +23,14 @@ target_sources(${PROJECT_NAME} PRIVATE source/PluginEditor.cpp source/PluginProcessor.cpp + source/DSP/MPMPitchDetector.cpp + source/DSP/BasicPitchDetector.cpp + source/Importer/WavImporter.cpp ${INCLUDE_DIR}/PluginEditor.h ${INCLUDE_DIR}/PluginProcessor.h + ${INCLUDE_DIR}/DSP/MPMPitchDetector.h + ${INCLUDE_DIR}/DSP/BasicPitchDetector.h + ${INCLUDE_DIR}/Importer/WavImporter.h ) # Sets the include directories of the plugin project. @@ -42,6 +48,7 @@ target_link_libraries(${PROJECT_NAME} juce::juce_recommended_config_flags juce::juce_recommended_lto_flags juce::juce_recommended_warning_flags + juce::juce_audio_basics ) # These definitions are recommended by JUCE. diff --git a/plugin/include/VocalAnalysis/DSP/BasicPitchDetector.h b/plugin/include/VocalAnalysis/DSP/BasicPitchDetector.h new file mode 100644 index 0000000..c5505af --- /dev/null +++ b/plugin/include/VocalAnalysis/DSP/BasicPitchDetector.h @@ -0,0 +1,13 @@ +#pragma once +#include + +class BasicPitchDetector +{ +public: + BasicPitchDetector(double sampleRate); + double detectPitch(const juce::AudioBuffer& buffer, int channel); +private: + double sampleRate; + std::vector computeAutocorrelation(const float* audioData, std::size_t numSamples); + int findBestLag(const std::vector& autocorrelation); +}; \ No newline at end of file diff --git a/plugin/include/VocalAnalysis/DSP/MPMPitchDetector.h b/plugin/include/VocalAnalysis/DSP/MPMPitchDetector.h new file mode 100644 index 0000000..5faef50 --- /dev/null +++ b/plugin/include/VocalAnalysis/DSP/MPMPitchDetector.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +/* McLeod Pitch Method */ +class MPMPitchDetector +{ +public: + MPMPitchDetector(double sampleRate); + ~MPMPitchDetector() = default; + + float detectPitch(const juce::AudioBuffer& buffer, int channel); + +private: + double sampleRate; + + void calculateDifferenceFunction(const float* data, int numSamples); + void calculateCumulativeDifferenceFunction(); + int findPitchPeriod() const; + + std::vector differenceFunction; + std::vector cumulativeDifferenceFunction; +}; diff --git a/plugin/include/VocalAnalysis/Exporter/WavExporter.h b/plugin/include/VocalAnalysis/Exporter/WavExporter.h new file mode 100644 index 0000000..c424047 --- /dev/null +++ b/plugin/include/VocalAnalysis/Exporter/WavExporter.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include +#include + +class WavExporter { +public: + static void exportBufferToWav(const juce::AudioBuffer& buffer, + const juce::File& file, + double sampleRate); +}; \ No newline at end of file diff --git a/plugin/include/VocalAnalysis/Importer/WavImporter.h b/plugin/include/VocalAnalysis/Importer/WavImporter.h new file mode 100644 index 0000000..ff9e0fb --- /dev/null +++ b/plugin/include/VocalAnalysis/Importer/WavImporter.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +class WavImporter +{ +public: + static bool loadWavFileIntoBuffer(const juce::File& file, juce::AudioBuffer& buffer, double& sampleRate); +}; \ No newline at end of file diff --git a/plugin/source/DSP/BasicPitchDetector.cpp b/plugin/source/DSP/BasicPitchDetector.cpp new file mode 100644 index 0000000..51a52d5 --- /dev/null +++ b/plugin/source/DSP/BasicPitchDetector.cpp @@ -0,0 +1,50 @@ +#include "VocalAnalysis/DSP/BasicPitchDetector.h" +#include +#include + +BasicPitchDetector::BasicPitchDetector(double currentSampleRate) { + jassert(currentSampleRate > 0); + sampleRate = currentSampleRate; +} + +double BasicPitchDetector::detectPitch(const juce::AudioBuffer& buffer, int channel) { + std::size_t numSamples = static_cast(buffer.getNumSamples()); + if (numSamples == 0 || sampleRate <= 0) return 0.0; + + const float* audioData = buffer.getReadPointer(channel); + std::vector autocorrelation = computeAutocorrelation(audioData, numSamples); + + int bestLag = findBestLag(autocorrelation); + return sampleRate / static_cast(bestLag); +} + +std::vector BasicPitchDetector::computeAutocorrelation(const float* audioData, std::size_t numSamples) { + std::vector autocorrelation(numSamples, 0.0); + + for (std::size_t lag = 0; lag < numSamples; ++lag) { + double sum = 0.0; + for (std::size_t i = 0; i < numSamples - lag; ++i) { + sum += audioData[i] * audioData[i + lag]; + } + autocorrelation[lag] = sum; + } + + return autocorrelation; +} + +int BasicPitchDetector::findBestLag(const std::vector& autocorrelation) { + int minLag = static_cast(sampleRate / 1000); // Ignore frequencies above 1000 Hz + int maxLag = static_cast(sampleRate / 50); // Ignore frequencies below 50 Hz + + int bestLag = minLag; + double maxCorrelation = 0.0; + + for (int lag = minLag; lag < maxLag; ++lag) { + if (autocorrelation[static_cast(lag)] > maxCorrelation) { + maxCorrelation = autocorrelation[static_cast(lag)]; + bestLag = lag; + } + } + + return bestLag; +} \ No newline at end of file diff --git a/plugin/source/DSP/MPMPitchDetector.cpp b/plugin/source/DSP/MPMPitchDetector.cpp new file mode 100644 index 0000000..138e550 --- /dev/null +++ b/plugin/source/DSP/MPMPitchDetector.cpp @@ -0,0 +1,78 @@ +#include "VocalAnalysis/DSP/MPMPitchDetector.h" + +MPMPitchDetector::MPMPitchDetector(double currentSampleRate) { + jassert(currentSampleRate > 0); + sampleRate = currentSampleRate; +} + + +// Main pitch detection method +float MPMPitchDetector::detectPitch(const juce::AudioBuffer& buffer, int channel) +{ + if (channel < 0 || channel >= buffer.getNumChannels()) + { + return 0.0f; + } + + auto* data = buffer.getReadPointer(channel); + int numSamples = buffer.getNumSamples(); + + // Resize buffers for the current block size + if (differenceFunction.size() < static_cast(numSamples)) + { + differenceFunction.resize(static_cast(numSamples), 0.0f); + cumulativeDifferenceFunction.resize(static_cast(numSamples), 0.0f); + } + + calculateDifferenceFunction(data, numSamples); + calculateCumulativeDifferenceFunction(); + int pitchPeriod = findPitchPeriod(); + if (pitchPeriod > 0) + { + return sampleRate / static_cast(pitchPeriod); + } + + return 0.0f; +} + +void MPMPitchDetector::calculateDifferenceFunction(const float* data, int numSamples) +{ + for (int tau = 0; tau < numSamples; ++tau) + { + float sum = 0.0f; + for (int j = 0; j < numSamples - tau; ++j) + { + float diff = data[j] - data[j + tau]; + sum += diff * diff; + } + differenceFunction[tau] = sum; + } +} + +void MPMPitchDetector::calculateCumulativeDifferenceFunction() +{ + cumulativeDifferenceFunction[0] = 1.0f; + for (int tau = 1; tau < static_cast(differenceFunction.size()); ++tau) + { + float cumulativeSum = 0.0f; + for (int j = 1; j <= tau; ++j) + { + cumulativeSum += differenceFunction[j]; + } + cumulativeDifferenceFunction[tau] = (cumulativeSum == 0.0f) ? 1.0f : (differenceFunction[tau] / (cumulativeSum / tau)); + } +} + +int MPMPitchDetector::findPitchPeriod() const +{ + const float threshold = 0.1f; + for (int tau = 2; tau < static_cast(cumulativeDifferenceFunction.size()); ++tau) + { + if (cumulativeDifferenceFunction[tau] < threshold && + cumulativeDifferenceFunction[tau] < cumulativeDifferenceFunction[tau - 1]) + { + return tau; + } + } + return -1; +} \ No newline at end of file diff --git a/plugin/source/Exporter/WavExporter.cpp b/plugin/source/Exporter/WavExporter.cpp new file mode 100644 index 0000000..de1e14d --- /dev/null +++ b/plugin/source/Exporter/WavExporter.cpp @@ -0,0 +1,32 @@ +#include "VocalAnalysis/Exporter/WavExporter.h" + +void WavExporter::exportBufferToWav(const juce::AudioBuffer& buffer, + const juce::File& file, + double sampleRate) +{ + if (file.existsAsFile()) { + //file.deleteFile(); // Ensure it's a new file + return; + } + + juce::FileOutputStream fileStream(file); + + if (!fileStream.openedOk()) { + DBG("Failed to open file for writing"); + return; + } + + juce::WavAudioFormat wavFormat; + std::unique_ptr writer( + wavFormat.createWriterFor(&fileStream, sampleRate, buffer.getNumChannels(), + 16, {}, 0) + ); + + if (!writer) { + DBG("Failed to create WAV writer"); + return; + } + + writer->writeFromAudioSampleBuffer(buffer, 0, buffer.getNumSamples()); + DBG("WAV file successfully written: " << file.getFullPathName()); +} diff --git a/plugin/source/Importer/WavImporter.cpp b/plugin/source/Importer/WavImporter.cpp new file mode 100644 index 0000000..b503a36 --- /dev/null +++ b/plugin/source/Importer/WavImporter.cpp @@ -0,0 +1,21 @@ +#include "VocalAnalysis/Importer/WavImporter.h" + +bool WavImporter::loadWavFileIntoBuffer(const juce::File& file, juce::AudioBuffer& buffer, double& sampleRate) { + juce::AudioFormatManager formatManager; + formatManager.registerBasicFormats(); + + std::unique_ptr reader(formatManager.createReaderFor(file)); + + if (reader == nullptr) { + return false; + } + + sampleRate = reader->sampleRate; + int numSamples = static_cast(reader->lengthInSamples); + int numChannels = static_cast(reader->numChannels); + + buffer.setSize(numChannels, numSamples); + reader->read(&buffer, 0, numSamples, 0, true, true); + + return true; +} \ No newline at end of file diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 704b2ff..1539a7f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -7,7 +7,11 @@ enable_testing() # Creates the test console application. add_executable(${PROJECT_NAME} - source/AudioProcessorTest.cpp) + source/AudioProcessorTest.cpp + source/DSP/MPMPitchDetectorTest.h + source/DSP/MPMPitchDetectorTest.cpp + source/DSP/BasicPitchDetectionTest.h + source/DSP/BasicPitchDetectionTest.cpp) # Sets the necessary include directories: ours, JUCE's, and googletest's. target_include_directories(${PROJECT_NAME} diff --git a/test/resources/.DS_Store b/test/resources/.DS_Store new file mode 100644 index 0000000..7337d2f Binary files /dev/null and b/test/resources/.DS_Store differ diff --git a/test/resources/audio/female_voice_D4_[16bit_44100Hz].wav b/test/resources/audio/female_voice_D4_[16bit_44100Hz].wav new file mode 100644 index 0000000..067e60b Binary files /dev/null and b/test/resources/audio/female_voice_D4_[16bit_44100Hz].wav differ diff --git a/test/source/DSP/BasicPitchDetectionTest.cpp b/test/source/DSP/BasicPitchDetectionTest.cpp new file mode 100644 index 0000000..687e02a --- /dev/null +++ b/test/source/DSP/BasicPitchDetectionTest.cpp @@ -0,0 +1,32 @@ +#include "BasicPitchDetectionTest.h" + +namespace pitch_detection_test { + TEST_F(BasicPitchDetectorTest, DetectsPitch440HzCorrectly) { + + const float frequency = 440.0f; + generateSineWave(frequency); + + float detectedPitch = pitchDetector->detectPitch(buffer, channel); + + EXPECT_NEAR(detectedPitch, frequency, 1.0f); // 1 Hz tolerance + } + + TEST_F(BasicPitchDetectorTest, DetectsPitch220HzCorrectly) { + + const float frequency = 220.0f; + generateSineWave(frequency); + + float detectedPitch = pitchDetector->detectPitch(buffer, channel); + + EXPECT_NEAR(detectedPitch, frequency, 1.0f); // 1 Hz tolerance + } + + TEST_F(BasicPitchDetectorTest, DetectsPitchD4FileCorrectly) { + const float expectedFrequency = 293.67f; // D4 + + loadAndProcessAudio(); + + float detectedPitch = pitchDetector->detectPitch(buffer, channel); + EXPECT_NEAR(detectedPitch, expectedFrequency, 1.0f); + } +} \ No newline at end of file diff --git a/test/source/DSP/BasicPitchDetectionTest.h b/test/source/DSP/BasicPitchDetectionTest.h new file mode 100644 index 0000000..895b585 --- /dev/null +++ b/test/source/DSP/BasicPitchDetectionTest.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +namespace pitch_detection_test { + class BasicPitchDetectorTest : public ::testing::Test { + protected: + void SetUp() override { + GTEST_SKIP() << "Skipping all pitch detection tests since the algorithm is not accurate enough."; + + sampleRate = 44100.0; + numTestSamples = 4096; + zeroPadding = numTestSamples; + totalTestSamples = zeroPadding + numTestSamples + zeroPadding; + buffer.setSize(1, totalTestSamples); + pitchDetector = std::make_unique(sampleRate); + } + + void TearDown() override { + pitchDetector.reset(); + } + + double sampleRate; + int channel = 0; + int numTestSamples; + int zeroPadding; + int totalTestSamples; + juce::AudioBuffer buffer; + std::unique_ptr pitchDetector; + public: + void generateSineWave(float frequency) { + const float amplitude = 0.5f; + buffer.clear(); + float* data = buffer.getWritePointer(0); + + for (size_t i = 0; i < static_cast(numTestSamples); ++i) + { + data[static_cast(zeroPadding) + i] = amplitude * std::sinf(2.0f * juce::MathConstants::pi * frequency * (i / static_cast(sampleRate))); + } + } + + void loadAndProcessAudio() { + juce::File wavFile = juce::File::getCurrentWorkingDirectory() + .getChildFile("test/resources/audio/female_voice_D4_[16bit_44100Hz].wav"); + + sampleRate = 0.0; + + if (WavImporter::loadWavFileIntoBuffer(wavFile, buffer, sampleRate)) { + DBG("Successfully loaded WAV file!"); + + } else { + FAIL() << "Failed to load WAV file."; + } + } + }; +} diff --git a/test/source/DSP/MPMPitchDetectorTest.cpp b/test/source/DSP/MPMPitchDetectorTest.cpp new file mode 100644 index 0000000..84d5936 --- /dev/null +++ b/test/source/DSP/MPMPitchDetectorTest.cpp @@ -0,0 +1,32 @@ +#include "MPMPitchDetectorTest.h" + +namespace pitch_detection_test { + TEST_F(MPMPitchDetectorTest, DetectsPitch440HzCorrectly) { + + const float frequency = 440.0f; + generateSineWave(frequency); + + float detectedPitch = pitchDetector->detectPitch(buffer, 0); + + EXPECT_NEAR(detectedPitch, frequency, 1.0f); // 1 Hz tolerance + } + + TEST_F(MPMPitchDetectorTest, DetectsPitch220HzCorrectly) { + + const float frequency = 220.0f; + generateSineWave(frequency); + + float detectedPitch = pitchDetector->detectPitch(buffer, 0); + + EXPECT_NEAR(detectedPitch, frequency, 1.0f); // 1 Hz tolerance + } + + TEST_F(MPMPitchDetectorTest, DetectsPitchD4FileCorrectly) { + const float expectedFrequency = 293.67f; // D4 + + loadAndProcessAudio(); + + float detectedPitch = pitchDetector->detectPitch(buffer, 0); + EXPECT_NEAR(detectedPitch, expectedFrequency, 1.0f); + } +} \ No newline at end of file diff --git a/test/source/DSP/MPMPitchDetectorTest.h b/test/source/DSP/MPMPitchDetectorTest.h new file mode 100644 index 0000000..77e07e5 --- /dev/null +++ b/test/source/DSP/MPMPitchDetectorTest.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +namespace pitch_detection_test { + class MPMPitchDetectorTest : public ::testing::Test { + protected: + void SetUp() override { + GTEST_SKIP() << "Skipping all pitch detection tests since the algorithm is not accurate enough."; + + sampleRate = 44100.0; + numTestSamples = 4096; + zeroPadding = numTestSamples; + totalTestSamples = zeroPadding + numTestSamples + zeroPadding; + buffer.setSize(1, totalTestSamples); + pitchDetector = std::make_unique(sampleRate); + } + + void TearDown() override { + pitchDetector.reset(); + } + + double sampleRate; + int numTestSamples; + int zeroPadding; + int totalTestSamples; + juce::AudioBuffer buffer; + std::unique_ptr pitchDetector; + public: + void generateSineWave(float frequency) { + const float amplitude = 0.5f; + buffer.clear(); + float* data = buffer.getWritePointer(0); + + for (size_t i = 0; i < static_cast(numTestSamples); ++i) + { + data[static_cast(zeroPadding) + i] = amplitude * std::sinf(2.0f * juce::MathConstants::pi * frequency * (i / static_cast(sampleRate))); + } + } + + void loadAndProcessAudio() { + juce::File wavFile = juce::File::getCurrentWorkingDirectory() + .getChildFile("resources/audio/female_voice_D4_[16bit_44100Hz].wav"); + sampleRate = 0.0; + + if (WavImporter::loadWavFileIntoBuffer(wavFile, buffer, sampleRate)) + { + DBG("Successfully loaded WAV file!"); + } + else + { + FAIL() << "Failed to load WAV file."; + } + } + }; +}