From eb9fb339ab68b07a71da8675611af2cfad3275a6 Mon Sep 17 00:00:00 2001 From: JonET Date: Wed, 15 Apr 2026 00:18:07 -0500 Subject: [PATCH 1/2] Add Pitch Bend parameter, routing toggle, and DSP test suite Adds a real Pitch Bend VST3 parameter (plus-minus 12 semitones, automatable) separate from Vowel. The hardware MIDI pitch wheel is routable to either Vowel (Classic / Delay Lama compat, default) or Pitch via a new right-click submenu; the host is notified via restartComponent(kMidiCCAssignmentChanged) so the switch takes effect without a plugin reload. The submenu label reflects the active mode. DSP changes apply the pitch offset in MIDI-note space inside the voice synthesis loop, upstream of the Hz-space unison detune, so all unison voices shift together while preserving their spread. Dead monk_synth_pitch_bend function (which just redirected to set_vowel) removed. Refactors the DSP sources into a monk_dsp static library so the plugin and a new opt-in test target can both link the same objects. Adds a minimal assert.h + CTest suite covering pitch-bend clamping, ADSR envelope boundaries, note-stack LIFO, unison detune math, and delay feedback stability. Tests are gated behind -DMONKSYNTH_BUILD_TESTS=ON and run on the Linux CI job before packaging so regressions block releases. Pitch Bend submenu localized into English, Japanese, and Korean. --- .github/workflows/build.yml | 8 ++ cpp/CMakeLists.txt | 32 +++++-- cpp/src/controller.cpp | 68 +++++++++++++- cpp/src/i18n.h | 3 + cpp/src/plugin_cids.h | 4 +- cpp/src/processor.cpp | 9 ++ cpp/src/processor.h | 7 +- cpp/src/strings_en.h | 6 ++ cpp/src/strings_ja.h | 6 ++ cpp/src/strings_ko.h | 6 ++ cpp/tests/test_delay.c | 171 ++++++++++++++++++++++++++++++++++++ cpp/tests/test_synth.c | 128 +++++++++++++++++++++++++++ cpp/tests/test_voice.c | 170 +++++++++++++++++++++++++++++++++++ dsp/synth.c | 50 +++-------- dsp/synth.h | 2 +- dsp/synth_internal.h | 49 +++++++++++ dsp/voice.c | 6 +- dsp/voice.h | 2 + 18 files changed, 675 insertions(+), 52 deletions(-) create mode 100644 cpp/tests/test_delay.c create mode 100644 cpp/tests/test_synth.c create mode 100644 cpp/tests/test_voice.c create mode 100644 dsp/synth_internal.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9920d37..26550ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,6 +145,14 @@ jobs: working-directory: cpp run: cmake --build build --config Release --target MonkSynth + - name: Build and run DSP unit tests + if: runner.os == 'Linux' + working-directory: cpp + run: | + cmake -B build-tests -DMONKSYNTH_BUILD_TESTS=ON + cmake --build build-tests --config Release + ctest --test-dir build-tests --output-on-failure + - name: Build AudioUnit if: runner.os == 'macOS' working-directory: cpp diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 0bead76..ab27549 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -69,9 +69,21 @@ configure_file( "${CMAKE_CURRENT_BINARY_DIR}/generated/version.h" ) -# --- DSP engine (plain C) --- +# --- DSP engine (plain C, standalone static library) --- +# Built as its own target so the VST3 plugin and the unit-test executables can +# both link against the same objects without duplicating the source list. set(DSP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../dsp") +add_library(monk_dsp STATIC + ${DSP_DIR}/voice.c + ${DSP_DIR}/delay.c + ${DSP_DIR}/synth.c +) +target_include_directories(monk_dsp PUBLIC "${DSP_DIR}") +if(UNIX) + target_link_libraries(monk_dsp PRIVATE m) +endif() + # --- VST3 Plugin --- smtg_add_vst3plugin(MonkSynth src/plugin_cids.h @@ -102,13 +114,10 @@ smtg_add_vst3plugin(MonkSynth src/strings_ja.h src/strings_ko.h src/stb_impl.c - ${DSP_DIR}/voice.c - ${DSP_DIR}/delay.c - ${DSP_DIR}/synth.c ) -target_include_directories(MonkSynth PRIVATE "${DSP_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/generated") -target_link_libraries(MonkSynth PRIVATE sdk vstgui_support) +target_include_directories(MonkSynth PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") +target_link_libraries(MonkSynth PRIVATE sdk vstgui_support monk_dsp) smtg_target_add_plugin_resources(MonkSynth RESOURCES @@ -149,6 +158,17 @@ if(UNIX) target_link_libraries(MonkSynth PRIVATE m) endif() +# --- DSP unit tests (opt-in) --- +option(MONKSYNTH_BUILD_TESTS "Build DSP unit tests" OFF) +if(MONKSYNTH_BUILD_TESTS) + enable_testing() + foreach(t test_voice test_synth test_delay) + add_executable(${t} tests/${t}.c) + target_link_libraries(${t} PRIVATE monk_dsp) + add_test(NAME ${t} COMMAND ${t}) + endforeach() +endif() + # Hide all symbols except VST3 entry points when statically linking on Linux. # This prevents bundled cairo/pango/glib symbols from conflicting with # other plugins or the host DAW. diff --git a/cpp/src/controller.cpp b/cpp/src/controller.cpp index 380e3c2..b6a1d84 100644 --- a/cpp/src/controller.cpp +++ b/cpp/src/controller.cpp @@ -360,6 +360,46 @@ COptionMenu *Controller::createContextMenu(const CPoint & /*pos*/, VST3Editor *e } menu->addEntry(langMenu, i18n::str(i18n::StringId::MenuLanguage)); + // ---- Pitch Bend routing submenu ---- + auto *pbMenu = new COptionMenu(); + const bool pbRoutingIsPitch = getParamNormalized(kPitchBendRouting) >= 0.5; + + struct PbOption { + i18n::StringId labelId; + bool isPitch; + }; + const PbOption pbOpts[] = { + {i18n::StringId::MenuPitchBendClassic, false}, + {i18n::StringId::MenuPitchBendPitch, true}, + }; + + for (const auto &opt : pbOpts) { + CCommandMenuItem::Desc desc(i18n::str(opt.labelId)); + if (pbRoutingIsPitch == opt.isPitch) + desc.flags |= CMenuItem::kChecked; + auto *pbItem = new CCommandMenuItem(std::move(desc)); + bool target = opt.isPitch; + pbItem->setActions([this, target](CCommandMenuItem *) { + // Push the value through beginEdit/performEdit/endEdit so the + // host records the change and forwards it to the processor; + // setParamNormalized separately updates our own state and fires + // the restartComponent(kMidiCCAssignmentChanged) notification. + ParamValue v = target ? 1.0 : 0.0; + beginEdit(kPitchBendRouting); + performEdit(kPitchBendRouting, v); + endEdit(kPitchBendRouting); + setParamNormalized(kPitchBendRouting, v); + }); + pbMenu->addEntry(pbItem); + } + // Surface the active mode in the parent menu label so users see the + // current state without opening the submenu. The kChecked flag on the + // submenu items alone is not reliably rendered by all hosts. + std::string pbLabel = std::string(i18n::str(i18n::StringId::MenuPitchBend)) + ": " + + i18n::str(pbRoutingIsPitch ? i18n::StringId::MenuPitchBendPitch + : i18n::StringId::MenuPitchBendClassic); + menu->addEntry(pbMenu, UTF8String(pbLabel)); + // No "Reset to Default" — there's no built-in theme. Users switch // between imported themes or re-import from the DLL. @@ -377,7 +417,17 @@ tresult PLUGIN_API Controller::getMidiControllerAssignment(int32 busIndex, int16 return kResultFalse; switch (midiControllerNumber) { - case ControllerNumbers::kPitchBend: id = kVowel; return kResultTrue; + case ControllerNumbers::kPitchBend: + // Dynamic routing: the kPitchBendRouting hidden parameter + // decides whether the hardware pitch wheel drives Vowel (Classic + // / Delay Lama compat) or PitchBend (standard synth). When the + // user flips the toggle, setParamNormalized below notifies the + // host via restartComponent(kMidiCCAssignmentChanged) so this + // function is re-queried. + id = (getParamNormalized(kPitchBendRouting) >= 0.5) + ? kPitchBend + : kVowel; + return kResultTrue; case ControllerNumbers::kCtrlModWheel: id = kVibrato; return kResultTrue; case ControllerNumbers::kCtrlPortaTime: id = kPortTime; return kResultTrue; case ControllerNumbers::kCtrlVolume: id = kLevel; return kResultTrue; @@ -427,6 +477,12 @@ tresult PLUGIN_API Controller::setParamNormalized(ParamID tag, ParamValue value) } else if (tag == kXYNoteOn || tag == kNoteActive) { if (monkView_) monkView_->setNoteActive(value > 0.5); + } else if (tag == kPitchBendRouting) { + // Tell the host to re-query IMidiMapping so the hardware pitch + // wheel assignment flips immediately instead of waiting for a + // plugin reload. + if (componentHandler) + componentHandler->restartComponent(kMidiCCAssignmentChanged); } } return result; @@ -513,6 +569,16 @@ tresult PLUGIN_API Controller::initialize(FUnknown *context) { parameters.addParameter(STR16("XY Pitch"), STR16(""), 0, 0.5, ParameterInfo::kCanAutomate, kXYPitchTarget); + parameters.addParameter( + new RangeParameter(STR16("Pitch Bend"), kPitchBend, STR16("st"), + -12.0, 12.0, 0.0, 0, ParameterInfo::kCanAutomate)); + + // Hidden routing toggle: 0 = Classic (hardware pitch wheel → Vowel, + // Delay Lama compat), 1 = Pitch (wheel → PitchBend). Persists in VST3 + // state so it travels with presets and DAW sessions. + parameters.addParameter(STR16("PB Routing"), STR16(""), 1, 0.0, + ParameterInfo::kIsHidden, kPitchBendRouting); + // Private parameters (not exposed to host automation) parameters.addParameter(STR16("XY Pitch Display"), STR16(""), 0, 0.5, ParameterInfo::kIsHidden, kXYPitch); diff --git a/cpp/src/i18n.h b/cpp/src/i18n.h index 3d30277..cecfe39 100644 --- a/cpp/src/i18n.h +++ b/cpp/src/i18n.h @@ -30,6 +30,9 @@ enum class StringId : int { MenuOpenFolder, MenuLanguage, MenuLanguageAuto, + MenuPitchBend, + MenuPitchBendClassic, + MenuPitchBendPitch, FileSelectThemeJson, FileSelectDll, FileExtJson, diff --git a/cpp/src/plugin_cids.h b/cpp/src/plugin_cids.h index 8d0d94f..928ca86 100644 --- a/cpp/src/plugin_cids.h +++ b/cpp/src/plugin_cids.h @@ -32,7 +32,9 @@ enum : Steinberg::Vst::ParamID { kXYNoteOn = 16, // 1.0 = note on, 0.0 = note off kXYVowel = 17, // target vowel from XY pad Y axis kXYPitchTarget = 18, // target pitch from XY pad X axis - kNumParams = 19, + kPitchBend = 19, // ±12 semitones, default 0 + kPitchBendRouting = 20, // 0 = Classic (Vowel), 1 = Pitch — hidden + kNumParams = 21, // Private parameters (not exposed to host automation) kXYPitch = 101, // smoothed pitch for indicator display diff --git a/cpp/src/processor.cpp b/cpp/src/processor.cpp index 8def694..e71d6ea 100644 --- a/cpp/src/processor.cpp +++ b/cpp/src/processor.cpp @@ -60,6 +60,7 @@ tresult PLUGIN_API Processor::setActive(TBool state) { monk_synth_set_sustain(synth_, paramValues_[kSustain]); monk_synth_set_release(synth_, paramValues_[kRelease] * 3.0f); monk_synth_set_level(synth_, paramValues_[kLevel]); + monk_synth_set_pitch_bend(synth_, (paramValues_[kPitchBend] - 0.5f) * 24.0f); } else { if (synth_) { monk_synth_reset(synth_); @@ -177,6 +178,14 @@ tresult PLUGIN_API Processor::process(ProcessData& data) { case kXYVowel: monk_synth_set_vowel(synth_, fval); break; + case kPitchBend: + // RangeParameter [-12,12]: normalized 0.5 = 0 semitones. + monk_synth_set_pitch_bend(synth_, (fval - 0.5f) * 24.0f); + break; + case kPitchBendRouting: + // Stored in paramValues_ only; the controller handles + // IMidiMapping re-query. No DSP side-effect from here. + break; default: break; } } diff --git a/cpp/src/processor.h b/cpp/src/processor.h index 40066b0..d6381f7 100644 --- a/cpp/src/processor.h +++ b/cpp/src/processor.h @@ -31,9 +31,12 @@ class Processor : public Steinberg::Vst::AudioEffect { private: MonkSynthEngine *synth_ = nullptr; - float paramValues_[19] = {0.5f, 0.5f, 0.8f, 0.5f, 0.0f, 0.5f, 0.5f, 0.0f, + // Order must match the ParamID enum in plugin_cids.h. + // PitchBend (idx 19): 0.5 = 0 semitones (RangeParameter midpoint). + // PitchBendRouting (idx 20): 0.0 = Classic (Vowel), preserves Delay Lama. + float paramValues_[21] = {0.5f, 0.5f, 0.8f, 0.5f, 0.0f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.5f, 1.0f, 0.0f, - 0.0f, 0.5f, 0.5f}; + 0.0f, 0.5f, 0.5f, 0.5f, 0.0f}; bool xyNoteActive_ = false; float xyPendingPitch_ = 0.5f; int midiNoteCount_ = 0; diff --git a/cpp/src/strings_en.h b/cpp/src/strings_en.h index 76bdb8c..9306a5c 100644 --- a/cpp/src/strings_en.h +++ b/cpp/src/strings_en.h @@ -58,6 +58,12 @@ constexpr const char *kStringsEn[] = { "Language", // MenuLanguageAuto "Auto", + // MenuPitchBend + "Pitch Bend", + // MenuPitchBendClassic + "Classic (Vowel)", + // MenuPitchBendPitch + "Pitch", // FileSelectThemeJson "Select theme.json in Theme Folder", // FileSelectDll diff --git a/cpp/src/strings_ja.h b/cpp/src/strings_ja.h index ac115f0..eec2533 100644 --- a/cpp/src/strings_ja.h +++ b/cpp/src/strings_ja.h @@ -65,6 +65,12 @@ constexpr const char *kStringsJa[] = { "言語", // MenuLanguageAuto "自動", + // MenuPitchBend + "ピッチベンド", + // MenuPitchBendClassic + "クラシック(母音)", + // MenuPitchBendPitch + "ピッチ", // FileSelectThemeJson "テーマフォルダ内の theme.json を選択", // FileSelectDll diff --git a/cpp/src/strings_ko.h b/cpp/src/strings_ko.h index e0ae760..f547732 100644 --- a/cpp/src/strings_ko.h +++ b/cpp/src/strings_ko.h @@ -65,6 +65,12 @@ constexpr const char *kStringsKo[] = { "언어", // MenuLanguageAuto "자동", + // MenuPitchBend + "피치 벤드", + // MenuPitchBendClassic + "클래식 (모음)", + // MenuPitchBendPitch + "피치", // FileSelectThemeJson "테마 폴더에서 theme.json 선택", // FileSelectDll diff --git a/cpp/tests/test_delay.c b/cpp/tests/test_delay.c new file mode 100644 index 0000000..144200e --- /dev/null +++ b/cpp/tests/test_delay.c @@ -0,0 +1,171 @@ +/* + * Stereo delay tests for MonkDelay. + * The delay struct has large buffers (~768 KB total), so we heap-allocate. + */ + +#include "delay.h" + +#include +#include +#include +#include +#include + +#define SR 44100.0f + +static int float_near(float a, float b, float tol) { + float d = a - b; + if (d < 0.0f) + d = -d; + return d <= tol; +} + +static void test_silence_in_silence_out(void) { + MonkDelay *d = calloc(1, sizeof(MonkDelay)); + assert(d); + monk_delay_init(d, SR); + monk_delay_set_mix(d, 0.0f); + d->feedback = 0.0f; + + float in[1024]; + float outL[1024]; + float outR[1024]; + memset(in, 0, sizeof(in)); + + monk_delay_process(d, in, outL, outR, 1024); + + for (int i = 0; i < 1024; i++) { + assert(float_near(outL[i], 0.0f, 1e-6f)); + assert(float_near(outR[i], 0.0f, 1e-6f)); + } + free(d); +} + +static void test_dry_passthrough_when_mix_is_zero(void) { + MonkDelay *d = calloc(1, sizeof(MonkDelay)); + assert(d); + monk_delay_init(d, SR); + monk_delay_set_mix(d, 0.0f); + d->feedback = 0.0f; + + float in[64]; + float outL[64]; + float outR[64]; + for (int i = 0; i < 64; i++) + in[i] = (i == 0) ? 1.0f : 0.0f; /* impulse */ + + monk_delay_process(d, in, outL, outR, 64); + + /* With mix=0, nothing enters the delay line, but the process function + * still adds the dry signal to the output. Impulse must pass through + * in the first sample. */ + assert(float_near(outL[0], 1.0f, 1e-6f)); + assert(float_near(outR[0], 1.0f, 1e-6f)); + for (int i = 1; i < 64; i++) { + assert(float_near(outL[i], 0.0f, 1e-6f)); + assert(float_near(outR[i], 0.0f, 1e-6f)); + } + free(d); +} + +static void test_impulse_appears_at_tap_time(void) { + MonkDelay *d = calloc(1, sizeof(MonkDelay)); + assert(d); + monk_delay_init(d, SR); + monk_delay_set_mix(d, 1.0f); + monk_delay_set_rate(d, 0.5f); /* default rate */ + d->feedback = 0.0f; + + /* Feed an impulse and run for enough samples to hear both taps. The + * default left tap at 44100 Hz is DELAY_TIME_L_44100 (13653) samples + * scaled by rate_scale; at rate=0.5 that's 13653 * 1.0 = 13653. */ + int n = 20000; + float *in = calloc(n, sizeof(float)); + float *outL = calloc(n, sizeof(float)); + float *outR = calloc(n, sizeof(float)); + assert(in && outL && outR); + + in[0] = 1.0f; + monk_delay_process(d, in, outL, outR, n); + + /* Find the peak in the region where the left tap should appear. */ + float peak = 0.0f; + int peak_idx = -1; + for (int i = 1000; i < n; i++) { + if (outL[i] > peak) { + peak = outL[i]; + peak_idx = i; + } + } + + /* The peak should be somewhere between 10000 and 16000 samples (giving + * the smoother time to settle) — the exact position moves with the + * smoothing filter, so we only assert the approximate region. */ + assert(peak > 0.5f); + assert(peak_idx > 10000 && peak_idx < 16000); + + free(in); + free(outL); + free(outR); + free(d); +} + +static void test_feedback_stability(void) { + MonkDelay *d = calloc(1, sizeof(MonkDelay)); + assert(d); + monk_delay_init(d, SR); + monk_delay_set_mix(d, 1.0f); + d->feedback = 0.9f; + + int n = 88200; /* 2 seconds */ + float *in = calloc(n, sizeof(float)); + float *outL = calloc(n, sizeof(float)); + float *outR = calloc(n, sizeof(float)); + assert(in && outL && outR); + in[0] = 1.0f; + + monk_delay_process(d, in, outL, outR, n); + + /* With feedback 0.9, a single impulse should produce a decaying train + * of echoes bounded well under 10.0. If anything blows up (NaN, Inf, + * or overflow) the delay line is broken. */ + for (int i = 0; i < n; i++) { + assert(!isnan(outL[i]) && !isnan(outR[i])); + assert(!isinf(outL[i]) && !isinf(outR[i])); + assert(outL[i] < 10.0f && outL[i] > -10.0f); + assert(outR[i] < 10.0f && outR[i] > -10.0f); + } + + free(in); + free(outL); + free(outR); + free(d); +} + +static void test_rate_setter_clamps(void) { + MonkDelay *d = calloc(1, sizeof(MonkDelay)); + assert(d); + monk_delay_init(d, SR); + + monk_delay_set_rate(d, 2.0f); + assert(d->rate == 1.0f); + monk_delay_set_rate(d, -1.0f); + assert(d->rate == 0.0f); + monk_delay_set_mix(d, 2.0f); + assert(d->mix == 1.0f); + monk_delay_set_mix(d, -1.0f); + assert(d->mix == 0.0f); + + free(d); +} + +int main(void) { + test_silence_in_silence_out(); + test_dry_passthrough_when_mix_is_zero(); + test_impulse_appears_at_tap_time(); + test_feedback_stability(); + test_rate_setter_clamps(); + + printf("test_delay: all tests passed\n"); + return 0; +} diff --git a/cpp/tests/test_synth.c b/cpp/tests/test_synth.c new file mode 100644 index 0000000..992c9c0 --- /dev/null +++ b/cpp/tests/test_synth.c @@ -0,0 +1,128 @@ +/* + * Synth-level tests for MonkSynthEngine. + * Uses synth_internal.h to peek at the held-note stack and unison voices. + */ + +#include "synth.h" +#include "synth_internal.h" + +#include +#include +#include + +#define SR 44100.0f + +static int float_near(float a, float b, float tol) { + float d = a - b; + if (d < 0.0f) + d = -d; + return d <= tol; +} + +static void test_note_stack_lifo(void) { + MonkSynthEngine *s = monk_synth_new(SR); + assert(s); + + monk_synth_note_on(s, 60, 1.0f); + monk_synth_note_on(s, 64, 1.0f); + monk_synth_note_on(s, 67, 1.0f); + + assert(s->held_count == 3); + assert(s->held[0].note == 60); + assert(s->held[1].note == 64); + assert(s->held[2].note == 67); + + /* Release the newest note: should fall back to 64 */ + monk_synth_note_off(s, 67); + assert(s->held_count == 2); + assert(s->held[s->held_count - 1].note == 64); + + /* Release the middle note: stack still has 60; top is 60 */ + monk_synth_note_off(s, 64); + assert(s->held_count == 1); + assert(s->held[s->held_count - 1].note == 60); + + monk_synth_free(s); +} + +static void test_note_stack_overflow(void) { + MonkSynthEngine *s = monk_synth_new(SR); + assert(s); + + /* Push 20 notes into a 16-slot stack */ + for (uint8_t n = 50; n < 70; n++) + monk_synth_note_on(s, n, 1.0f); + + /* The stack caps at 16; the first 16 notes are kept, later pushes drop. */ + assert(s->held_count == MONK_MAX_NOTES); + assert(s->held[0].note == 50); + assert(s->held[MONK_MAX_NOTES - 1].note == 65); + + monk_synth_free(s); +} + +static void test_unison_detune_propagates(void) { + MonkSynthEngine *s = monk_synth_new(SR); + assert(s); + + monk_synth_set_unison(s, 3); + monk_synth_set_unison_detune(s, 50.0f); /* ±50 cents */ + monk_synth_note_on(s, 69, 1.0f); /* A4 */ + + assert(s->unison_count == 3); + + /* Voice 0 should be detuned -50 cents, voice 2 +50 cents, voice 1 center. + * 50 cents = 0.5 semitones in MIDI note space. */ + float center = s->voices[1].target_pitch; + float v0 = s->voices[0].target_pitch; + float v2 = s->voices[2].target_pitch; + + assert(float_near(center, 69.0f, 0.01f)); + assert(float_near(v0, 69.0f - 0.5f, 0.01f)); + assert(float_near(v2, 69.0f + 0.5f, 0.01f)); + + monk_synth_free(s); +} + +static void test_pitch_bend_propagates_to_all_voices(void) { + MonkSynthEngine *s = monk_synth_new(SR); + assert(s); + + monk_synth_set_unison(s, 5); + monk_synth_note_on(s, 60, 1.0f); + monk_synth_set_pitch_bend(s, 7.0f); + + for (int i = 0; i < 5; i++) + assert(float_near(s->voices[i].pitch_bend_offset, 7.0f, 1e-6f)); + + monk_synth_set_pitch_bend(s, -3.5f); + for (int i = 0; i < 5; i++) + assert(float_near(s->voices[i].pitch_bend_offset, -3.5f, 1e-6f)); + + monk_synth_free(s); +} + +static void test_reset_clears_notes(void) { + MonkSynthEngine *s = monk_synth_new(SR); + assert(s); + + monk_synth_note_on(s, 60, 1.0f); + monk_synth_note_on(s, 64, 1.0f); + assert(s->held_count == 2); + + monk_synth_reset(s); + assert(s->held_count == 0); + + monk_synth_free(s); +} + +int main(void) { + test_note_stack_lifo(); + test_note_stack_overflow(); + test_unison_detune_propagates(); + test_pitch_bend_propagates_to_all_voices(); + test_reset_clears_notes(); + + printf("test_synth: all tests passed\n"); + return 0; +} diff --git a/cpp/tests/test_voice.c b/cpp/tests/test_voice.c new file mode 100644 index 0000000..1b8afca --- /dev/null +++ b/cpp/tests/test_voice.c @@ -0,0 +1,170 @@ +/* + * Voice-level DSP tests for MonkVoice. + * Plain C, uses only, returns 0 on success. + */ + +#include "voice.h" + +#include +#include +#include +#include +#include + +#define SR 44100.0f + +static int float_near(float a, float b, float tol) { + float d = a - b; + if (d < 0.0f) + d = -d; + return d <= tol; +} + +static void test_init_defaults(void) { + MonkVoice *v = calloc(1, sizeof(MonkVoice)); + assert(v); + monk_voice_init(v, SR); + + assert(v->sample_rate == SR); + assert(v->active == false); + assert(v->pitch_bend_offset == 0.0f); + assert(v->env_level == 0.0f); + assert(v->current_vowel == 0.5f); + assert(v->target_vowel == 0.5f); + free(v); +} + +static void test_pitch_bend_clamp(void) { + MonkVoice *v = calloc(1, sizeof(MonkVoice)); + assert(v); + monk_voice_init(v, SR); + + monk_voice_set_pitch_bend(v, 5.5f); + assert(float_near(v->pitch_bend_offset, 5.5f, 1e-6f)); + + monk_voice_set_pitch_bend(v, 13.0f); + assert(v->pitch_bend_offset == 12.0f); + + monk_voice_set_pitch_bend(v, -999.0f); + assert(v->pitch_bend_offset == -12.0f); + + monk_voice_set_pitch_bend(v, 0.0f); + assert(v->pitch_bend_offset == 0.0f); + + free(v); +} + +static void test_note_on_activates_voice(void) { + MonkVoice *v = calloc(1, sizeof(MonkVoice)); + assert(v); + monk_voice_init(v, SR); + + monk_voice_note_on(v, 440.0f, 1.0f); /* A4 */ + assert(v->active == true); + /* MIDI note 69 = A4 = 440 Hz */ + assert(float_near(v->target_pitch, 69.0f, 0.01f)); + assert(float_near(v->current_pitch, 69.0f, 0.01f)); + free(v); +} + +static void test_adsr_attack_ramp(void) { + MonkVoice *v = calloc(1, sizeof(MonkVoice)); + assert(v); + monk_voice_init(v, SR); + + monk_voice_set_attack(v, 0.02f); /* 20 ms */ + monk_voice_set_decay(v, 0.0f); + monk_voice_set_sustain(v, 1.0f); + monk_voice_set_release(v, 0.0f); + monk_voice_note_on(v, 220.0f, 1.0f); + + /* Process enough samples to complete the attack */ + float out[2048]; + monk_voice_process(v, out, 2048); /* ~46 ms */ + + assert(float_near(v->env_level, 1.0f, 0.001f)); + free(v); +} + +static void test_note_off_enters_release(void) { + MonkVoice *v = calloc(1, sizeof(MonkVoice)); + assert(v); + monk_voice_init(v, SR); + + monk_voice_set_attack(v, 0.001f); + monk_voice_set_release(v, 0.05f); + monk_voice_note_on(v, 220.0f, 1.0f); + + float out[256]; + monk_voice_process(v, out, 256); + monk_voice_note_off(v); + assert(v->env_stage == 4 /* ENV_RELEASE */); + free(v); +} + +static void test_pitch_bend_is_audible_offset(void) { + /* + * Indirect verification that pitch_bend_offset actually participates in + * grain rate: run two voices, one at MIDI 60 with bend +12, one at + * MIDI 72 with bend 0. Their synthesis loop should reach the same + * quantized pitch (60+12 == 72+0), so after equal samples their + * overlap_offset counters should advance in lockstep. + */ + MonkVoice *a = calloc(1, sizeof(MonkVoice)); + MonkVoice *b = calloc(1, sizeof(MonkVoice)); + assert(a && b); + monk_voice_init(a, SR); + monk_voice_init(b, SR); + + monk_voice_note_on(a, monk_note_to_hz(60.0f), 1.0f); + monk_voice_set_pitch_bend(a, 12.0f); + + monk_voice_note_on(b, monk_note_to_hz(72.0f), 1.0f); + monk_voice_set_pitch_bend(b, 0.0f); + + float out[512]; + monk_voice_process(a, out, 512); + monk_voice_process(b, out, 512); + + /* Both voices reach the same target pitch internally. We can't compare + * overlap state directly because vibrato adds a random jitter. Instead + * check the settled current_pitch field: both must be identical even + * though they were seeded with different base notes. */ + assert(float_near(a->current_pitch, 60.0f, 0.001f)); + assert(float_near(b->current_pitch, 72.0f, 0.001f)); + assert(a->pitch_bend_offset == 12.0f); + assert(b->pitch_bend_offset == 0.0f); + /* current_pitch + pitch_bend_offset must match between the two voices — + * that is the actual pitch feeding grain_period. */ + assert(float_near(a->current_pitch + a->pitch_bend_offset, + b->current_pitch + b->pitch_bend_offset, 0.001f)); + + free(a); + free(b); +} + +static void test_pitch_utilities(void) { + /* Round-trip: hz → note → hz */ + float hz = 440.0f; + float note = monk_hz_to_note(hz); + assert(float_near(note, 69.0f, 0.001f)); + float hz2 = monk_note_to_hz(note); + assert(float_near(hz2, 440.0f, 0.01f)); + + /* One octave up = 2x frequency */ + float a5 = monk_note_to_hz(81.0f); + assert(float_near(a5, 880.0f, 0.01f)); +} + +int main(void) { + test_init_defaults(); + test_pitch_bend_clamp(); + test_note_on_activates_voice(); + test_adsr_attack_ramp(); + test_note_off_enters_release(); + test_pitch_bend_is_audible_offset(); + test_pitch_utilities(); + + printf("test_voice: all tests passed\n"); + return 0; +} diff --git a/dsp/synth.c b/dsp/synth.c index fb0d788..ef4afdf 100644 --- a/dsp/synth.c +++ b/dsp/synth.c @@ -7,45 +7,15 @@ * creating a thicker choir-like sound. Count=1 is a single voice. */ -#include "synth.h" -#include "delay.h" -#include "voice.h" +#include "synth_internal.h" #include #include #include -#define MAX_BUF 8192 -#define MAX_NOTES 16 -#define MAX_UNISON 10 - -typedef struct { - uint8_t note; - float velocity; -} HeldNote; - -struct MonkSynthEngine { - MonkVoice voices[MAX_UNISON]; - int unison_count; - float unison_detune; /* max detune spread in cents */ - float unison_voice_spread; /* voice character spread across unison (0-1) */ - float base_voice; /* center voice value before spread */ - float last_base_hz; /* last note frequency for live detune updates */ - - MonkDelay delay; - - HeldNote held[MAX_NOTES]; - uint32_t held_count; - float cc_volume; - float level; /* output level 0-1 (GUI knob) */ - float current_voice_gain; /* smoothed unison gain */ - float target_voice_gain; - - float scratch_mono[MAX_BUF]; - float scratch_voice[MAX_BUF]; /* per-voice temp buffer */ - float scratch_l[MAX_BUF]; - float scratch_r[MAX_BUF]; -}; +#define MAX_BUF MONK_MAX_BUF +#define MAX_NOTES MONK_MAX_NOTES +#define MAX_UNISON MONK_MAX_UNISON /* Remove a note from the held-note stack, compacting in place. */ static void remove_note(MonkSynthEngine *s, uint8_t note) { @@ -247,6 +217,12 @@ void monk_synth_set_vibrato_rate(MonkSynthEngine *s, float v) { for (int i = 0; i < MAX_UNISON; i++) monk_voice_set_vibrato_rate(&s->voices[i], v); } +void monk_synth_set_pitch_bend(MonkSynthEngine *s, float semitones) { + if (!s) + return; + for (int i = 0; i < MAX_UNISON; i++) + monk_voice_set_pitch_bend(&s->voices[i], semitones); +} void monk_synth_set_aspiration(MonkSynthEngine *s, float v) { if (!s) return; @@ -399,12 +375,6 @@ float monk_synth_get_pitch_normalized(MonkSynthEngine *s) { return norm; } -void monk_synth_pitch_bend(MonkSynthEngine *s, float value) { - if (!s) - return; - monk_synth_set_vowel(s, value); -} - /* ---- Audio processing ---- */ /* Process pipeline: sum unison voices → stereo delay → gain staging. */ diff --git a/dsp/synth.h b/dsp/synth.h index 8f0c479..fbf21b9 100644 --- a/dsp/synth.h +++ b/dsp/synth.h @@ -34,6 +34,7 @@ void monk_synth_set_voice(MonkSynthEngine *s, float value); void monk_synth_set_glide(MonkSynthEngine *s, float value); void monk_synth_set_vibrato(MonkSynthEngine *s, float value); void monk_synth_set_vibrato_rate(MonkSynthEngine *s, float value); +void monk_synth_set_pitch_bend(MonkSynthEngine *s, float semitones); void monk_synth_set_aspiration(MonkSynthEngine *s, float value); void monk_synth_set_attack(MonkSynthEngine *s, float seconds); void monk_synth_set_decay(MonkSynthEngine *s, float seconds); @@ -49,7 +50,6 @@ void monk_synth_set_level(MonkSynthEngine *s, float value); /* MIDI routing */ void monk_synth_midi_cc(MonkSynthEngine *s, uint8_t cc, float value); -void monk_synth_pitch_bend(MonkSynthEngine *s, float value); /* State readback (for UI animation) */ float monk_synth_get_vowel(MonkSynthEngine *s); diff --git a/dsp/synth_internal.h b/dsp/synth_internal.h new file mode 100644 index 0000000..ebee3dc --- /dev/null +++ b/dsp/synth_internal.h @@ -0,0 +1,49 @@ +/* + * Private header for MonkSynthEngine internals. + * + * This exposes the struct definition and the internal constants so that + * tests in cpp/tests/ can inspect and assert on the engine's state. It + * is NOT part of the public DSP API — the plugin shell should continue + * to use synth.h only. + */ + +#ifndef MONK_SYNTH_INTERNAL_H +#define MONK_SYNTH_INTERNAL_H + +#include "delay.h" +#include "synth.h" +#include "voice.h" + +#define MONK_MAX_BUF 8192 +#define MONK_MAX_NOTES 16 +#define MONK_MAX_UNISON 10 + +typedef struct { + uint8_t note; + float velocity; +} MonkHeldNote; + +struct MonkSynthEngine { + MonkVoice voices[MONK_MAX_UNISON]; + int unison_count; + float unison_detune; /* max detune spread in cents */ + float unison_voice_spread; /* voice character spread across unison (0-1) */ + float base_voice; /* center voice value before spread */ + float last_base_hz; /* last note frequency for live detune updates */ + + MonkDelay delay; + + MonkHeldNote held[MONK_MAX_NOTES]; + uint32_t held_count; + float cc_volume; + float level; /* output level 0-1 (GUI knob) */ + float current_voice_gain; /* smoothed unison gain */ + float target_voice_gain; + + float scratch_mono[MONK_MAX_BUF]; + float scratch_voice[MONK_MAX_BUF]; + float scratch_l[MONK_MAX_BUF]; + float scratch_r[MONK_MAX_BUF]; +}; + +#endif diff --git a/dsp/voice.c b/dsp/voice.c index 4d7e0c9..d042aac 100644 --- a/dsp/voice.c +++ b/dsp/voice.c @@ -471,6 +471,10 @@ void monk_voice_set_vibrato_rate(MonkVoice *v, float rate) { v->vibrato_rate = clampf(rate, 0.0f, 1.0f); } +void monk_voice_set_pitch_bend(MonkVoice *v, float semitones) { + v->pitch_bend_offset = clampf(semitones, -12.0f, 12.0f); +} + void monk_voice_set_aspiration(MonkVoice *v, float amp) { v->aspiration_amp = clampf(amp, 0.0f, 1.0f); } @@ -501,7 +505,7 @@ void monk_voice_process(MonkVoice *v, float *output, uint32_t n) { apply_portamento(v); float vib = compute_vibrato(v); - float pitch = v->current_pitch + vib; + float pitch = v->current_pitch + v->pitch_bend_offset + vib; uint32_t period = (uint32_t)grain_period(v, pitch); if (v->overlap_offset >= period || v->grain_dirty) { diff --git a/dsp/voice.h b/dsp/voice.h index a231aba..107855b 100644 --- a/dsp/voice.h +++ b/dsp/voice.h @@ -16,6 +16,7 @@ typedef struct { bool active; float current_pitch; /* MIDI note space */ float target_pitch; + float pitch_bend_offset; /* semitones, clamped to [-12, 12] */ float glide_param; float min_glide; /* minimum glide for XY pad smoothing, independent of knob */ @@ -78,6 +79,7 @@ void monk_voice_set_voice(MonkVoice *v, float voice); void monk_voice_set_glide(MonkVoice *v, float glide); void monk_voice_set_vibrato(MonkVoice *v, float depth); void monk_voice_set_vibrato_rate(MonkVoice *v, float rate); +void monk_voice_set_pitch_bend(MonkVoice *v, float semitones); void monk_voice_set_aspiration(MonkVoice *v, float amp); void monk_voice_set_attack(MonkVoice *v, float seconds); void monk_voice_set_decay(MonkVoice *v, float seconds); From 94cb976ed188b8bed547ab0fcd88d1111305d78b Mon Sep 17 00:00:00 2001 From: JonET Date: Wed, 15 Apr 2026 00:18:10 -0500 Subject: [PATCH 2/2] Update README and changelog for Pitch Bend and DSP tests --- CHANGELOG.md | 9 +++++++++ README.md | 30 +++++++++++++++--------------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9ccd7..8090e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to MonkSynth will be documented in this file. ## [Unreleased] +### Added +- `Pitch Bend` parameter (±12 semitones, automatable) that actually bends the pitch of held notes, independent of the existing `Vowel` parameter +- Right-click → **Pitch Bend** submenu with `Classic (Vowel)` and `Pitch` options; toggle controls where the hardware MIDI pitch wheel lands. Classic is the default and preserves Delay Lama compatibility. The host is notified via `restartComponent(kMidiCCAssignmentChanged)` so the switch takes effect without reloading the plugin. +- Minimal DSP unit test suite (`cpp/tests/test_voice.c`, `test_synth.c`, `test_delay.c`) covering ADSR envelope boundaries, note stack LIFO, unison detune math, pitch-bend propagation, and delay-line feedback stability. Opt-in via `-DMONKSYNTH_BUILD_TESTS=ON`; runs on the Linux CI job before packaging. + +### Changed +- DSP sources refactored into a `monk_dsp` static library so the plugin and the unit tests can link against the same objects without duplicating the source list. +- Removed dead `monk_synth_pitch_bend` DSP function that was never called and just redirected to `set_vowel`. + ## [0.2.0-beta.7] - 2026-04-14 ### Added diff --git a/README.md b/README.md index e308b21..17d3c71 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ A monophonic vocal synthesizer that sounds like a monk chanting. Built using for - FOF synthesis engine producing realistic vocal formants - XY pad for real-time pitch and vowel control - Built-in stereo delay effect -- MIDI support: note on/off, pitch bend (vowel), CC1 (vibrato), CC5 (glide), CC7 (volume), CC12 (delay), CC13 (voice) +- MIDI support: note on/off, pitch wheel, CC1 (vibrato), CC5 (glide), CC7 (volume), CC12 (delay), CC13 (voice) +- Automatable **Pitch Bend** parameter (±12 semitones). The hardware pitch wheel is routable to either Vowel (Classic / Delay Lama compat, the default) or Pitch via right-click → Pitch Bend - ADSR envelope with configurable attack, decay, sustain, release - Unison mode with up to 10 detuned voices and voice spread - Theme system with right-click context menu for custom skins @@ -51,26 +52,25 @@ cmake -B build -G Xcode -DSMTG_AUDIOUNIT_SDK_PATH=/path/to/AudioUnitSDK cmake --build build --config Release --target MonkSynth-au ``` +### DSP unit tests + +The pure-C DSP layer (`dsp/`) has a small unit test suite exercising ADSR envelope boundaries, the note stack, unison detune math, pitch-bend propagation, and delay-line feedback stability. Tests are opt-in so they don't affect normal plugin builds: + +```bash +cd cpp +cmake -B build-tests -DMONKSYNTH_BUILD_TESTS=ON +cmake --build build-tests --config Release +ctest --test-dir build-tests --output-on-failure +``` + +CI runs the test suite on the Linux job before packaging each release, so any DSP regression blocks the build. + ## Installation - **macOS:** Run the `.pkg` installer — installs both VST3 and AU plugins - **Windows:** Run the `.exe` installer — installs the VST3 plugin - **Linux:** Extract and copy `MonkSynth.vst3` to `~/.vst3/` -## Repository Layout - -``` -dsp/ - voice.c, voice.h FOF synthesis engine (formants, overlap-add grains) - delay.c, delay.h Stereo delay with feedback - synth.c, synth.h Public API: note stack, MIDI routing, gain staging -cpp/ - src/ VST3 plugin shell (processor, controller, GUI) - resources/ VSTGUI editor description and placeholder assets - CMakeLists.txt Build system (fetches VST3 SDK via FetchContent) -presets/ VST3 factory preset files -``` - ## Themes MonkSynth ships without a built-in skin. On first launch, it shows a setup screen where you can import the classic look from the original Delay Lama DLL (available as freeware from [audionerdz.nl](http://www.audionerdz.nl/download.htm)).