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
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)).
Expand Down
32 changes: 26 additions & 6 deletions cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
68 changes: 67 additions & 1 deletion cpp/src/controller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions cpp/src/i18n.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ enum class StringId : int {
MenuOpenFolder,
MenuLanguage,
MenuLanguageAuto,
MenuPitchBend,
MenuPitchBendClassic,
MenuPitchBendPitch,
FileSelectThemeJson,
FileSelectDll,
FileExtJson,
Expand Down
4 changes: 3 additions & 1 deletion cpp/src/plugin_cids.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions cpp/src/processor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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_);
Expand Down Expand Up @@ -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;
}
}
Expand Down
7 changes: 5 additions & 2 deletions cpp/src/processor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions cpp/src/strings_en.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions cpp/src/strings_ja.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ constexpr const char *kStringsJa[] = {
"言語",
// MenuLanguageAuto
"自動",
// MenuPitchBend
"ピッチベンド",
// MenuPitchBendClassic
"クラシック(母音)",
// MenuPitchBendPitch
"ピッチ",
// FileSelectThemeJson
"テーマフォルダ内の theme.json を選択",
// FileSelectDll
Expand Down
6 changes: 6 additions & 0 deletions cpp/src/strings_ko.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ constexpr const char *kStringsKo[] = {
"언어",
// MenuLanguageAuto
"자동",
// MenuPitchBend
"피치 벤드",
// MenuPitchBendClassic
"클래식 (모음)",
// MenuPitchBendPitch
"피치",
// FileSelectThemeJson
"테마 폴더에서 theme.json 선택",
// FileSelectDll
Expand Down
Loading
Loading