From 60ab93b24023ea3c0abe274afe5217a6f3d62c08 Mon Sep 17 00:00:00 2001 From: Artur Gorbachev Date: Sat, 3 Oct 2026 16:31:27 +0200 Subject: [PATCH 1/6] Prevent analyzer refresh from truncating bow attack during sustain --- Source/BowEnvelope.h | 6 ++++-- tests/PitchDetectorTests.cpp | 26 +++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Source/BowEnvelope.h b/Source/BowEnvelope.h index 801abd6..1c22791 100644 --- a/Source/BowEnvelope.h +++ b/Source/BowEnvelope.h @@ -34,8 +34,10 @@ class BowEnvelope { } void sustain() { - if (state != State::Idle) - state = State::Sustain; + // An analyzer refresh may arrive before the configured attack has + // completed. Keep that attack running; process() will enter Sustain + // naturally once the target is reached. Forcing Sustain here would + // turn a 50 ms attack into one analysis hop. } void setSustainLevel(float newLevel) { diff --git a/tests/PitchDetectorTests.cpp b/tests/PitchDetectorTests.cpp index 87c2e30..b8c616c 100644 --- a/tests/PitchDetectorTests.cpp +++ b/tests/PitchDetectorTests.cpp @@ -1,3 +1,4 @@ +#include "../Source/BowEnvelope.h" #include "../Source/InputDynamics.h" #include "../Source/PitchDetector.h" #include "../Source/PolyphonicAnalyzer.h" @@ -250,7 +251,30 @@ int main() { } // ------------------------------------------------------------ - // 6. Musical input dynamics are independent from note triggering + // 6. Analyzer refreshes must not truncate a configured bow attack + // ------------------------------------------------------------ + + { + BowEnvelope envelope; + envelope.prepare(sampleRate); + envelope.setAttackMs(50.0f); + envelope.trigger(1.0f); + + for (int i = 0; i < 512; ++i) + envelope.process(); + + envelope.sustain(); + + const float levelAfterRefresh = envelope.process(); + + if (levelAfterRefresh >= 0.5f) { + std::cerr << "Analyzer refresh truncated the bow attack\n"; + return 1; + } + } + + // ------------------------------------------------------------ + // 7. Musical input dynamics are independent from note triggering // ------------------------------------------------------------ { From ecdff71df743627da56c83904d4aa0259bbfbbc3 Mon Sep 17 00:00:00 2001 From: Artur Gorbachev Date: Sat, 3 Oct 2026 16:35:16 +0200 Subject: [PATCH 2/6] Enhance BowTrigger to support retriggering during sustained notes --- Source/BowTrigger.h | 38 ++++++++++++++++++++++++++++++++++++ tests/PitchDetectorTests.cpp | 38 +++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Source/BowTrigger.h b/Source/BowTrigger.h index 0c1549b..ed0275d 100644 --- a/Source/BowTrigger.h +++ b/Source/BowTrigger.h @@ -15,8 +15,11 @@ class BowTrigger { void reset() { envelope = 0.0f; + onsetBaseline = 0.0f; wasAboveThreshold = false; releaseCounter = 0; + retriggerCooldown = 0; + retriggerArmed = true; justTriggered = false; justReleased = false; } @@ -34,10 +37,33 @@ class BowTrigger { const float level = std::clamp(envelope * sensitivity, 0.0f, 1.0f); + // Keep a deliberately slower reference level for detecting a new + // physical pluck while another string is still audible. The normal + // threshold edge below handles the first attack; this differential + // path is only for a later, distinct rise above an existing sustain. + onsetBaseline += (level - onsetBaseline) * timeToCoefficient(onsetBaselineMs); + + if (retriggerCooldown > 0) + --retriggerCooldown; + + if (!retriggerArmed && level - onsetBaseline < retriggerRearmThreshold) + retriggerArmed = true; + if (!wasAboveThreshold && level >= triggerThreshold) { wasAboveThreshold = true; releaseCounter = 0; justTriggered = true; + retriggerCooldown = retriggerCooldownSamples(); + retriggerArmed = false; + } else if (wasAboveThreshold && retriggerArmed && retriggerCooldown == 0 && + level >= triggerThreshold && level - onsetBaseline >= retriggerRiseThreshold) { + // Do not require every sounding string to decay to silence + // before allowing another note in a chord or repeated pluck. + // The cooldown turns the rise of one strong transient into one + // event rather than a sequence of duplicate attacks. + justTriggered = true; + retriggerCooldown = retriggerCooldownSamples(); + retriggerArmed = false; } if (wasAboveThreshold && level <= releaseThreshold) { @@ -91,8 +117,10 @@ class BowTrigger { float detectorAttackMs = 2.0f; float detectorReleaseMs = 30.0f; + float onsetBaselineMs = 80.0f; float envelope = 0.0f; + float onsetBaseline = 0.0f; bool wasAboveThreshold = false; @@ -100,6 +128,16 @@ class BowTrigger { bool justReleased = false; int releaseCounter = 0; + int retriggerCooldown = 0; + bool retriggerArmed = true; static constexpr int releaseSamplesRequired = 256; + static constexpr float retriggerRiseThreshold = 0.035f; + static constexpr float retriggerRearmThreshold = 0.015f; + static constexpr float retriggerCooldownMs = 30.0f; + + int retriggerCooldownSamples() const noexcept { + return std::max(1, static_cast(std::lround( + sampleRate * static_cast(retriggerCooldownMs) * 0.001))); + } }; diff --git a/tests/PitchDetectorTests.cpp b/tests/PitchDetectorTests.cpp index b8c616c..7f71c66 100644 --- a/tests/PitchDetectorTests.cpp +++ b/tests/PitchDetectorTests.cpp @@ -1,4 +1,5 @@ #include "../Source/BowEnvelope.h" +#include "../Source/BowTrigger.h" #include "../Source/InputDynamics.h" #include "../Source/PitchDetector.h" #include "../Source/PolyphonicAnalyzer.h" @@ -274,7 +275,42 @@ int main() { } // ------------------------------------------------------------ - // 7. Musical input dynamics are independent from note triggering + // 7. A later pluck must be detected while an earlier note sustains + // ------------------------------------------------------------ + + { + BowTrigger trigger; + trigger.prepare(sampleRate); + + int triggerCount = 0; + for (int i = 0; i < 4096; ++i) + triggerCount += trigger.processSample(0.20f) ? 1 : 0; + + // The first strong attack is one event, not a string of events while + // its envelope rises. + if (triggerCount != 1) { + std::cerr << "Strong pluck produced duplicate trigger events\n"; + return 1; + } + + // Leave a quiet sustained input above release threshold, then add a + // second physical pluck. This is the condition needed to add a note + // while another voice remains active. + for (int i = 0; i < 8192; ++i) + trigger.processSample(0.04f); + + bool laterPluckDetected = false; + for (int i = 0; i < 2048; ++i) + laterPluckDetected |= trigger.processSample(0.45f); + + if (!laterPluckDetected) { + std::cerr << "Later pluck was missed while sustain remained active\n"; + return 1; + } + } + + // ------------------------------------------------------------ + // 8. Musical input dynamics are independent from note triggering // ------------------------------------------------------------ { From 779702c44f1c8a0a07a00d278fd95735ec7844ab Mon Sep 17 00:00:00 2001 From: Artur Gorbachev Date: Sat, 3 Oct 2026 16:40:56 +0200 Subject: [PATCH 3/6] Update PitchDetectorTests to enhance analysis and bow trigger functionality --- CMakeLists.txt | 18 ++- tests/PitchDetectorTests.cpp | 254 ++++++++++++++++++++++++++++++++++- 2 files changed, 266 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e6d2b88..282e994 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -317,11 +317,14 @@ target_link_libraries(ElectroBow if(BUILD_TESTING) add_executable(ElectroBowPitchDetectorTests - tests/PitchDetectorTests.cpp) + tests/PitchDetectorTests.cpp + Source/PluginProcessor.cpp + Source/PluginEditor.cpp) target_include_directories(ElectroBowPitchDetectorTests PRIVATE - "${AUBIO_SRC_DIR}/src") + "${AUBIO_SRC_DIR}/src" + "${STK_SRC_DIR}/include") target_compile_features(ElectroBowPitchDetectorTests PRIVATE @@ -329,13 +332,18 @@ if(BUILD_TESTING) target_compile_definitions(ElectroBowPitchDetectorTests PRIVATE - JUCE_WEB_BROWSER=0 - JUCE_USE_CURL=0) + JUCE_WEB_BROWSER=1 + JUCE_USE_CURL=0 + JucePlugin_Name="ElectroBow") target_link_libraries(ElectroBowPitchDetectorTests PRIVATE juce::juce_core - aubio_static) + juce::juce_audio_utils + juce::juce_gui_extra + ElectroBowWebUIData + aubio_static + stk_bowed) add_test( NAME ElectroBowPitchDetectorTests diff --git a/tests/PitchDetectorTests.cpp b/tests/PitchDetectorTests.cpp index 7f71c66..890252d 100644 --- a/tests/PitchDetectorTests.cpp +++ b/tests/PitchDetectorTests.cpp @@ -2,11 +2,14 @@ #include "../Source/BowTrigger.h" #include "../Source/InputDynamics.h" #include "../Source/PitchDetector.h" +#include "../Source/PluginProcessor.h" #include "../Source/PolyphonicAnalyzer.h" +#include "../Source/VoiceManager.h" #include #include #include +#include #include #include @@ -74,6 +77,19 @@ bool containsPeak(const PolyphonicAnalyzer& analyzer, float targetFrequency, flo return false; } +void pushUntilAnalysis(PolyphonicAnalyzer& analyzer, const std::vector& samples) { + analyzer.push(samples.data(), static_cast(samples.size())); +} + +std::vector makeChord(std::initializer_list notes, float amplitude = 0.2f, + int frames = 2) { + std::vector result(static_cast(PolyphonicAnalyzer::kFFTSize * frames), + 0.0f); + for (const auto note : notes) + addNote(result, note, amplitude); + return result; +} + void printPitchResult(const char* name, const PitchDetector& detector) { std::cout << name << ": " << detector.getFrequencyHz() << " Hz" << " confidence=" << detector.getConfidence() << " midi=" << detector.getMidiNote() @@ -310,7 +326,60 @@ int main() { } // ------------------------------------------------------------ - // 8. Musical input dynamics are independent from note triggering + // 8. Bow trigger release is delivered once after sustained silence + // ------------------------------------------------------------ + + { + BowTrigger trigger; + trigger.prepare(sampleRate); + + for (int i = 0; i < 2048; ++i) + trigger.processSample(0.2f); + + int releases = 0; + for (int i = 0; i < 8192; ++i) { + trigger.processSample(0.0f); + releases += trigger.consumeRelease() ? 1 : 0; + } + + if (releases != 1 || trigger.isNoteActive()) { + std::cerr << "Bow trigger release was not a single completed event\n"; + return 1; + } + } + + // ------------------------------------------------------------ + // 9. Bow envelope handles immediate attack, sustain, and release + // ------------------------------------------------------------ + + { + BowEnvelope envelope; + envelope.prepare(sampleRate); + envelope.setAttackMs(0.0f); + envelope.setNaturalResonanceMs(0.0f); + envelope.trigger(0.25f); + + if (!approximately(envelope.process(), 0.25f, 0.0001f)) { + std::cerr << "Zero-time bow attack did not reach its target\n"; + return 1; + } + + envelope.setSustainLevel(0.75f); + if (!approximately(envelope.process(), 0.75f, 0.0001f)) { + std::cerr << "Bow sustain did not follow the live target\n"; + return 1; + } + + envelope.release(); + envelope.process(); + if (envelope.isActive() || envelope.getLevel() != 0.0f) { + std::cerr << "Zero-time bow release did not finish\n"; + return 1; + } + } + + // ------------------------------------------------------------ + // 10. Musical input dynamics are independent from note triggering // ------------------------------------------------------------ { @@ -338,6 +407,22 @@ int main() { } } + // ------------------------------------------------------------ + // 11. Dynamics rejects non-finite input without corrupting state + // ------------------------------------------------------------ + + { + InputDynamics dynamics; + dynamics.prepare(sampleRate); + dynamics.processSample(0.25f); + dynamics.processSample(std::numeric_limits::quiet_NaN()); + + if (!std::isfinite(dynamics.getLevel()) || dynamics.getLevel() < 0.0f) { + std::cerr << "Input dynamics accepted a non-finite value\n"; + return 1; + } + } + // ------------------------------------------------------------ // 7. Polyphonic analyzer must produce independent voices // ------------------------------------------------------------ @@ -594,6 +679,173 @@ int main() { } } + // ------------------------------------------------------------ + // 12. Analyzer retains a missing voice only for its bounded grace period + // ------------------------------------------------------------ + + { + PolyphonicAnalyzer analyzer; + analyzer.prepare(sampleRate); + + const auto note = makeChord({57}); + pushUntilAnalysis(analyzer, note); + + if (analyzer.getVoices().empty() || !analyzer.isAnalysisReliable()) { + std::cerr << "Analyzer did not publish its initial voice\n"; + return 1; + } + + std::vector silence(static_cast(PolyphonicAnalyzer::kHopSize), 0.0f); + pushUntilAnalysis(analyzer, silence); + + if (analyzer.getVoices().empty()) { + std::cerr << "Analyzer released a voice before its grace period\n"; + return 1; + } + + // Four hops flush the overlapping FFT window. The remaining hops + // exceed the analyzer's eight-generation missing-voice allowance. + for (int i = 0; i < 20; ++i) + pushUntilAnalysis(analyzer, silence); + + if (!analyzer.getVoices().empty() || analyzer.isAnalysisReliable()) { + std::cerr << "Analyzer did not expire a missing voice\n"; + return 1; + } + } + + // ------------------------------------------------------------ + // 13. VoiceManager starts independent STK voices and respects admission + // ------------------------------------------------------------ + + { + PolyphonicAnalyzer twoNotes; + twoNotes.prepare(sampleRate); + const auto chord = makeChord({50, 57}); + pushUntilAnalysis(twoNotes, chord); + + VoiceManager manager; + manager.prepare(sampleRate, 0.0f, 0.0f); + manager.setInputLevel(1.0f); + manager.setBowParameters(0.5f, 0.5f, 0.127f); + manager.updateDetectedVoices(twoNotes, true); + + if (manager.getActiveVoiceCount() != 2) { + std::cerr << "VoiceManager did not start both analyzed notes\n"; + return 1; + } + + float renderedEnergy = 0.0f; + for (int i = 0; i < 2048; ++i) + renderedEnergy += std::abs(manager.processSample()); + + if (!(renderedEnergy > 0.0f)) { + std::cerr << "Active STK voices produced no output\n"; + return 1; + } + + PolyphonicAnalyzer threeNotes; + threeNotes.prepare(sampleRate); + const auto expandedChord = makeChord({50, 57, 60}); + pushUntilAnalysis(threeNotes, expandedChord); + manager.updateDetectedVoices(threeNotes, false); + + if (manager.getActiveVoiceCount() != 2) { + std::cerr << "VoiceManager admitted a voice without a note-on\n"; + return 1; + } + } + + // ------------------------------------------------------------ + // 14. VoiceManager releases only the voice lost by the analyzer + // ------------------------------------------------------------ + + { + PolyphonicAnalyzer chordAnalyzer; + chordAnalyzer.prepare(sampleRate); + const auto chord = makeChord({50, 57}); + pushUntilAnalysis(chordAnalyzer, chord); + + VoiceManager manager; + manager.prepare(sampleRate, 0.0f, 0.0f); + manager.setInputLevel(1.0f); + manager.updateDetectedVoices(chordAnalyzer, true); + + PolyphonicAnalyzer oneNoteAnalyzer; + oneNoteAnalyzer.prepare(sampleRate); + const auto oneNote = makeChord({50}); + pushUntilAnalysis(oneNoteAnalyzer, oneNote); + + // Feed three distinct analyzer generations. The A3 is unmatched in + // each one and must release without taking D3 with it. + for (int i = 0; i < 3; ++i) { + pushUntilAnalysis(oneNoteAnalyzer, + std::vector(PolyphonicAnalyzer::kHopSize, 0.0f)); + manager.updateDetectedVoices(oneNoteAnalyzer, false); + } + + for (int i = 0; i < 16; ++i) + manager.processSample(); + + if (manager.getActiveVoiceCount() != 1 || + !approximately(manager.getVoiceFrequencyHz(0), midiToFrequency(50), 10.0f)) { + std::cerr << "VoiceManager did not preserve the continuing voice on release\n"; + return 1; + } + } + + // ------------------------------------------------------------ + // 15. Processor integrates input, analysis, STK rendering, and release + // ------------------------------------------------------------ + + { + ElectroBowAudioProcessor processor; + constexpr int blockSize = 256; + processor.prepareToPlay(sampleRate, blockSize); + + juce::MidiBuffer midi; + double renderedEnergy = 0.0; + + for (int block = 0; block < 64; ++block) { + juce::AudioBuffer buffer(2, blockSize); + + for (int sample = 0; sample < blockSize; ++sample) { + const int index = block * blockSize + sample; + const float value = 0.4f * std::sin(2.0f * pi * 220.0f * static_cast(index) / + static_cast(sampleRate)); + buffer.setSample(0, sample, value); + buffer.setSample(1, sample, value); + } + + processor.processBlock(buffer, midi); + + for (int sample = 0; sample < blockSize; ++sample) { + const float left = buffer.getSample(0, sample); + const float right = buffer.getSample(1, sample); + + if (!std::isfinite(left) || !std::isfinite(right) || + !approximately(left, right, 0.000001f)) { + std::cerr << "Processor produced invalid or mismatched stereo output\n"; + return 1; + } + + renderedEnergy += std::abs(left); + } + } + + if (processor.getActiveVoiceCount() != 1 || !(renderedEnergy > 0.0)) { + std::cerr << "Processor did not create and render its analyzed voice\n"; + return 1; + } + + processor.releaseResources(); + + if (processor.getActiveVoiceCount() != 0) { + std::cerr << "Processor did not clear voices on resource release\n"; + return 1; + } + } + std::cout << "\nAll PitchDetector / PolyphonicAnalyzer tests passed.\n"; return 0; From 97b22c278796c67ca7658e265a67e0229a036adc Mon Sep 17 00:00:00 2001 From: Artur Gorbachev Date: Sat, 3 Oct 2026 17:02:39 +0200 Subject: [PATCH 4/6] Add PluginEditorStub for testing purposes and update test executable --- CMakeLists.txt | 3 +-- tests/PluginEditorStub.cpp | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 tests/PluginEditorStub.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 282e994..e57dec2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -319,7 +319,7 @@ if(BUILD_TESTING) add_executable(ElectroBowPitchDetectorTests tests/PitchDetectorTests.cpp Source/PluginProcessor.cpp - Source/PluginEditor.cpp) + tests/PluginEditorStub.cpp) target_include_directories(ElectroBowPitchDetectorTests PRIVATE @@ -340,7 +340,6 @@ if(BUILD_TESTING) PRIVATE juce::juce_core juce::juce_audio_utils - juce::juce_gui_extra ElectroBowWebUIData aubio_static stk_bowed) diff --git a/tests/PluginEditorStub.cpp b/tests/PluginEditorStub.cpp new file mode 100644 index 0000000..f67ec91 --- /dev/null +++ b/tests/PluginEditorStub.cpp @@ -0,0 +1,16 @@ +#include "../Source/PluginEditor.h" + +ElectroBowAudioProcessorEditor::ElectroBowAudioProcessorEditor(ElectroBowAudioProcessor& processor) + : AudioProcessorEditor(&processor), processorRef(processor) { + setSize(1, 1); +} + +ElectroBowAudioProcessorEditor::~ElectroBowAudioProcessorEditor() = default; + +void ElectroBowAudioProcessorEditor::paint(juce::Graphics& graphics) { + graphics.fillAll(juce::Colours::black); +} + +void ElectroBowAudioProcessorEditor::resized() {} + +void ElectroBowAudioProcessorEditor::timerCallback() {} From 4dcc0606e14df655117c856689b0359167e99049 Mon Sep 17 00:00:00 2001 From: Artur Gorbachev Date: Sat, 3 Oct 2026 17:16:06 +0200 Subject: [PATCH 5/6] Refactor test build configuration and remove unused PluginEditorStub --- CMakeLists.txt | 9 ++++----- Source/PluginProcessor.cpp | 6 ++++++ tests/PluginEditorStub.cpp | 16 ---------------- 3 files changed, 10 insertions(+), 21 deletions(-) delete mode 100644 tests/PluginEditorStub.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e57dec2..20b23d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -318,8 +318,7 @@ target_link_libraries(ElectroBow if(BUILD_TESTING) add_executable(ElectroBowPitchDetectorTests tests/PitchDetectorTests.cpp - Source/PluginProcessor.cpp - tests/PluginEditorStub.cpp) + Source/PluginProcessor.cpp) target_include_directories(ElectroBowPitchDetectorTests PRIVATE @@ -332,15 +331,15 @@ if(BUILD_TESTING) target_compile_definitions(ElectroBowPitchDetectorTests PRIVATE - JUCE_WEB_BROWSER=1 + ELECTROBOW_TEST_BUILD=1 JUCE_USE_CURL=0 JucePlugin_Name="ElectroBow") target_link_libraries(ElectroBowPitchDetectorTests PRIVATE juce::juce_core - juce::juce_audio_utils - ElectroBowWebUIData + juce::juce_graphics + juce::juce_audio_processors_headless aubio_static stk_bowed) diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 316d23b..0b61dc0 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -1,5 +1,7 @@ #include "PluginProcessor.h" +#ifndef ELECTROBOW_TEST_BUILD #include "PluginEditor.h" +#endif #include #include @@ -280,7 +282,11 @@ void ElectroBowAudioProcessor::processBlock(juce::AudioBuffer& buffer, } juce::AudioProcessorEditor* ElectroBowAudioProcessor::createEditor() { +#ifdef ELECTROBOW_TEST_BUILD + return nullptr; +#else return new ElectroBowAudioProcessorEditor(*this); +#endif } bool ElectroBowAudioProcessor::hasEditor() const { diff --git a/tests/PluginEditorStub.cpp b/tests/PluginEditorStub.cpp deleted file mode 100644 index f67ec91..0000000 --- a/tests/PluginEditorStub.cpp +++ /dev/null @@ -1,16 +0,0 @@ -#include "../Source/PluginEditor.h" - -ElectroBowAudioProcessorEditor::ElectroBowAudioProcessorEditor(ElectroBowAudioProcessor& processor) - : AudioProcessorEditor(&processor), processorRef(processor) { - setSize(1, 1); -} - -ElectroBowAudioProcessorEditor::~ElectroBowAudioProcessorEditor() = default; - -void ElectroBowAudioProcessorEditor::paint(juce::Graphics& graphics) { - graphics.fillAll(juce::Colours::black); -} - -void ElectroBowAudioProcessorEditor::resized() {} - -void ElectroBowAudioProcessorEditor::timerCallback() {} From 4a15cdb241344dcd995d6929d212195e0e5c138e Mon Sep 17 00:00:00 2001 From: Artur Gorbachev Date: Sat, 3 Oct 2026 17:24:46 +0200 Subject: [PATCH 6/6] Define M_SQRT1_2 for ElectroBowPitchDetectorTests to ensure consistent calculations --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 20b23d8..925a23e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -332,6 +332,7 @@ if(BUILD_TESTING) target_compile_definitions(ElectroBowPitchDetectorTests PRIVATE ELECTROBOW_TEST_BUILD=1 + M_SQRT1_2=0.70710678118654752440 JUCE_USE_CURL=0 JucePlugin_Name="ElectroBow")