commit 8734ac25f1dd268e7c1c48dcd951c0056ba5fd8b Author: SM-26 Date: Fri Sep 11 16:38:28 2026 +0200 SM-26 synthesizer: initial implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07f9b35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +build/ +deps/ +*.o +*.d +*.dylib +.DS_Store +*.dSYM/ +*.vst3/ +*.component/ +CMakeCache.txt +CMakeFiles/ +Makefile.cmake +JuceLibraryCode/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e96e93d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,88 @@ +cmake_minimum_required(VERSION 3.22) +project(SM26 VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT DEFINED JUCE_ROOT) + set(JUCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/deps/juce") +endif() + +if(NOT DEFINED CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +if(NOT EXISTS "${JUCE_ROOT}/CMakeLists.txt") + message(FATAL_ERROR "JUCE not found at ${JUCE_ROOT}. Run 'make deps' first.") +endif() + +add_subdirectory("${JUCE_ROOT}" juce_build) + +if(APPLE) + set(SM26_FORMATS AU VST3) +elseif(UNIX) + set(SM26_FORMATS VST3) +else() + set(SM26_FORMATS VST3) +endif() + +juce_add_plugin(SM-26 + VENDOR_NAME "SM" + VENDOR_WEBSITE "https://example.org/sm-26" + VENDOR_EMAIL "dev@example.org" + VENDOR_VERSION_CODE 0x0100 + PLUGIN_MANUFACTURER_CODE "SM26" + PLUGIN_CODE "SM26" + PRODUCT_NAME "SM-26" + PRODUCT_DESCRIPTION "SM-26 synthesizer" + VERSION "1.0.0" + IS_SYNTH TRUE + NEEDS_MIDI_INPUT TRUE + NEEDS_MIDI_OUTPUT FALSE + IS_MIDI_EFFECT FALSE + EDITOR_NAME "SM26AudioProcessorEditor" + EDITOR_NEEDS_KEYBOARD_FOCUS TRUE + FORMATS ${SM26_FORMATS} +) + +target_sources(SM-26 PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/SynthParams.cpp + Source/SynthEngine.cpp + Source/Presets.cpp + Source/LcdDisplay.cpp + Source/MenuSystem.cpp +) + +target_include_directories(SM-26 PRIVATE Source) + +target_compile_definitions(SM-26 PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_ENABLE_GUI_TELNET_CONSOLE=0 + JUCE_VST3_CAN_REPLACE_VST2=0 + JUCE_MODAL_LOOPS_PERMITTED=1 +) + +juce_generate_juce_header(SM-26) + +add_custom_target(gen_buildinfo ALL + COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_buildinfo.sh" + "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_BINARY_DIR}/gen/BuildInfo_generated.h" + COMMENT "Generating build info header (git version + build time)" +) +add_dependencies(SM-26 gen_buildinfo) + +target_include_directories(SM-26 PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/gen") + +set_target_properties(SM-26 PROPERTIES OUTPUT_NAME "SM-26") + +if(APPLE) + set_target_properties(SM-26 PROPERTIES + MICROPHONE_PERMISSION_ENABLED FALSE + WEBKIT_PERMISSION_ENABLED FALSE + ) +endif() \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ddd6490 --- /dev/null +++ b/Makefile @@ -0,0 +1,100 @@ +SHELL := /bin/sh +UNAME_S := $(shell uname -s) +UNAME_M := $(shell uname -m) + +JUCE_ROOT ?= deps/juce +BUILD_DIR ?= build +CONFIG := Release +JOBS := $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) + +BUNDLE := SM-26 + +ifeq ($(UNAME_S),Darwin) + OSX_ARCH := arm64 + FOLLOW_BRANCH := 1 +endif + +.PHONY: all deps configure build install uninstall clean distclean + +all: build + +deps: + @if [ ! -f "$(JUCE_ROOT)/CMakeLists.txt" ]; then \ + echo ">> Cloning JUCE into $(JUCE_ROOT) ..."; \ + mkdir -p "$(JUCE_ROOT)"; \ + git clone --depth 1 https://github.com/juce-framework/JUCE.git "$(JUCE_ROOT)"; \ + fi + @echo ">> JUCE present." + +configure: deps + @mkdir -p "$(BUILD_DIR)" + @if [ "$(UNAME_S)" = "Darwin" ]; then \ + cmake -S . -B "$(BUILD_DIR)" \ + -DCMAKE_BUILD_TYPE="$(CONFIG)" \ + -DCMAKE_OSX_ARCHITECTURES="$(OSX_ARCH)" \ + -DJUCE_ROOT="$(abspath $(JUCE_ROOT))"; \ + else \ + cmake -S . -B "$(BUILD_DIR)" \ + -DCMAKE_BUILD_TYPE="$(CONFIG)" \ + -DJUCE_ROOT="$(abspath $(JUCE_ROOT))"; \ + fi + +build: configure + cmake --build "$(BUILD_DIR)" --config "$(CONFIG)" -j "$(JOBS)" + +# After install the plugin gets ad-hoc code-signed (required for AU hosts and +# VST3 sandboxing on macOS) and any quarantine xattr is stripped. +DEST_VST3 ?= +DEST_AU ?= +ifeq ($(UNAME_S),Darwin) + DEST_VST3 := "$(HOME)/Library/Audio/Plug-Ins/VST3/$(BUNDLE).vst3" + DEST_AU := "$(HOME)/Library/Audio/Plug-Ins/Components/$(BUNDLE).component" +else ifeq ($(UNAME_S),Linux) + DEST_VST3 := "$(HOME)/.vst3/$(BUNDLE).vst3" +endif + +help_install_sources := $(shell find "$(BUILD_DIR)" -type d -name "*.vst3" 2>/dev/null; find "$(BUILD_DIR)" -type d -name "*.component" 2>/dev/null) + +install: build + @if [ "$(UNAME_S)" = "Darwin" ]; then \ + mkdir -p "$(HOME)/Library/Audio/Plug-Ins/VST3" "$(HOME)/Library/Audio/Plug-Ins/Components"; \ + VST3=$$(find "$(BUILD_DIR)" -type d -name "$(BUNDLE).vst3" | head -n1); \ + AU=$$(find "$(BUILD_DIR)" -type d -name "$(BUNDLE).component" | head -n1); \ + if [ -n "$$VST3" ]; then \ + echo ">> Installing VST3"; \ + rm -rf "$(DEST_VST3)"; cp -R "$$VST3" "$(HOME)/Library/Audio/Plug-Ins/VST3/"; \ + codesign --force --deep --sign - "$(DEST_VST3)" && echo ">> Signed VST3 (ad-hoc)"; \ + codesign --verify --deep "$(DEST_VST3)" 2>/dev/null && echo ">> VST3 signature verified"; \ + xattr -dr com.apple.quarantine "$(DEST_VST3)" 2>/dev/null || true; \ + echo ">> Quarantine removed."; \ + fi; \ + if [ -n "$$AU" ]; then \ + echo ">> Installing AU"; \ + rm -rf "$(DEST_AU)"; cp -R "$$AU" "$(HOME)/Library/Audio/Plug-Ins/Components/"; \ + codesign --force --deep --sign - "$(DEST_AU)" && echo ">> Signed AU (ad-hoc)"; \ + codesign --verify --deep "$(DEST_AU)" 2>/dev/null && echo ">> AU signature verified"; \ + xattr -dr com.apple.quarantine "$(DEST_AU)" 2>/dev/null || true; \ + echo ">> Quarantine removed."; \ + fi; \ + elif [ "$(UNAME_S)" = "Linux" ]; then \ + echo ">> Installing VST3"; \ + mkdir -p "$(HOME)/.vst3"; \ + VST3=$$(find "$(BUILD_DIR)" -type d -name "$(BUNDLE).vst3" | head -n1); \ + if [ -n "$$VST3" ]; then rm -rf "$(DEST_VST3)"; cp -R "$$VST3" "$(HOME)/.vst3/"; else echo "!! VST3 not found in build dir"; exit 1; fi; \ + fi + @echo ">> SM-26 installed." + +uninstall: + @if [ "$(UNAME_S)" = "Darwin" ]; then \ + rm -rf "$(DEST_VST3)" "$(DEST_AU)"; \ + echo ">> Removed $(BUNDLE) AU + VST3"; \ + elif [ "$(UNAME_S)" = "Linux" ]; then \ + rm -rf "$(DEST_VST3)"; \ + echo ">> Removed $(BUNDLE) VST3"; \ + fi + +clean: + rm -rf "$(BUILD_DIR)"/SM-26_artefacts + +distclean: + rm -rf "$(BUILD_DIR)" \ No newline at end of file diff --git a/Source/LcdDisplay.cpp b/Source/LcdDisplay.cpp new file mode 100644 index 0000000..077173f --- /dev/null +++ b/Source/LcdDisplay.cpp @@ -0,0 +1,155 @@ +#include "LcdDisplay.h" + +LcdDisplay::LcdDisplay() +{ + clear(); + startTimerHz (30); +} + +juce::Font LcdDisplay::getMonoFont() const +{ + return juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(), + (float) kCharH - 1.0f, juce::Font::plain }); +} + +void LcdDisplay::rasterize (juce::uint32 c) +{ + if (c >= 256 || glyphCache[c].cached) return; + + juce::Image img (juce::Image::ARGB, kCharW * 2, kCharH * 2, true); + { + juce::Graphics g (img); + g.setColour (juce::Colours::white); + g.setFont (getMonoFont()); + juce::String s; + s << (juce_wchar) c; + g.drawText (s, img.getBounds().removeFromBottom (kCharH * 2 - 2), + juce::Justification::centredBottom, false); + } + + const float sx = (float) img.getWidth() / (float) kCharW; + const float sy = (float) img.getHeight() / (float) kCharH; + + for (int row = 0; row < 8; ++row) + glyphCache[c].rows[row] = 0; + + for (int row = 0; row < 8; ++row) + { + uint8_t bits = 0; + for (int col = 0; col < 5; ++col) + { + int count = 0, total = 0; + int px0 = (int) (col * sx); + int py0 = (int) (row * sy); + int px1 = juce::jmin ((int) ((col + 1) * sx), img.getWidth() - 1); + int py1 = juce::jmin ((int) ((row + 1) * sy), img.getHeight() - 1); + for (int y = py0; y <= py1; ++y) + for (int x = px0; x <= px1; ++x) + { + total++; + if (img.getPixelAt (x, y).getAlpha() > 127) + count++; + } + if (total > 0 && count > total / 3) + bits |= (uint8_t) (1u << (4 - col)); + } + glyphCache[c].rows[row] = bits; + } + glyphCache[c].cached = true; +} + +uint8_t* LcdDisplay::getGlyph (juce::uint32 c) +{ + if (c >= 256) c = '?'; + if (! glyphCache[c].cached) + rasterize (c); + return glyphCache[c].rows; +} + +void LcdDisplay::setLine (int row, const juce::String& s) +{ + if (row >= 0 && row < kRows) + lines[row] = s; +} + +void LcdDisplay::showCursor (bool show) { cursorVisible = show; } + +void LcdDisplay::clear() +{ + for (int r = 0; r < kRows; ++r) + lines[r] = ""; + cursorCol = 0; + cursorRow = 0; + cursorVisible = false; +} + +void LcdDisplay::paint (juce::Graphics& g) +{ + const float cw = (float) kCharW; + const float ch = (float) kCharH; + const float w = (float) getWidth(); + const float h = (float) getHeight(); + + const juce::Colour bgCol (0xFF0D1A05); + const juce::Colour gridCol (0xFF15260A); + const juce::Colour pixOn (0xFF4AE038); + const juce::Colour pixOnDim (0xFF1E3A12); + const juce::Colour pixOff (0xFF0F1C07); + + g.fillAll (bgCol); + + g.setColour (gridCol); + float totalW = (float) kCols * cw; + float totalH = (float) kRows * ch; + float ox = (w - totalW) / 2.0f; + float oy = (h - totalH) / 2.0f; + g.fillRect (ox - 1.0f, oy - 1.0f, totalW + 2.0f, totalH + 2.0f); + + bool blinkOn = cursorVisible && + ((int) (juce::Time::getMillisecondCounterHiRes() / 400.0) % 2 == 0); + + for (int row = 0; row < kRows; ++row) + { + const juce::String& line = lines[row]; + for (int col = 0; col < kCols; ++col) + { + juce_wchar ch2 = col < line.length() ? line [(size_t) col] : (juce_wchar) ' '; + uint8_t* glyph = getGlyph ((juce::uint32) ch2); + + float cx = ox + (float) col * cw; + float cy = oy + (float) row * ch; + + g.setColour (pixOff); + g.fillRect (cx + 0.5f, cy + 0.5f, cw - 1.0f, ch - 1.0f); + + for (int gRow = 0; gRow < 8; ++gRow) + { + uint8_t bits = glyph[gRow]; + if (bits == 0) continue; + for (int gCol = 0; gCol < 5; ++gCol) + { + if (bits & (1u << (4 - gCol))) + { + float px = cx + 1.0f + (float) gCol * (cw - 2.0f) / 5.0f; + float py = cy + 1.0f + (float) gRow * (ch - 2.0f) / 9.0f; + float pw = (cw - 2.0f) / 5.0f - 0.5f; + float ph = (ch - 2.0f) / 9.0f - 0.3f; + + g.setColour (pixOnDim); + g.fillRect (px - 0.4f, py - 0.3f, pw + 0.8f, ph + 0.6f); + g.setColour (pixOn); + g.fillRect (px, py, pw, ph); + } + } + } + + if (blinkOn && col == cursorCol && row == cursorRow) + { + g.setColour (pixOn); + g.fillRect (cx + 1.0f, cy + ch - 3.0f, cw - 2.0f, 2.0f); + } + } + } +} + +void LcdDisplay::timerCallback() { repaint(); } \ No newline at end of file diff --git a/Source/LcdDisplay.h b/Source/LcdDisplay.h new file mode 100644 index 0000000..f960636 --- /dev/null +++ b/Source/LcdDisplay.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +class LcdDisplay : public juce::Component, + private juce::Timer +{ +public: + static constexpr int kCols = 80; + static constexpr int kRows = 4; + + LcdDisplay(); + + void setLine (int row, const juce::String& s); + void showCursor (bool show); + void clear(); + void paint (juce::Graphics&) override; + +private: + void timerCallback() override; + + static constexpr int kCharW = 6; + static constexpr int kCharH = 9; + + juce::String lines[kRows]; + int cursorCol = 0; + int cursorRow = 0; + bool cursorVisible = false; + + struct GlyphCache { + uint8_t rows[8] = {}; + bool cached = false; + }; + + GlyphCache glyphCache[256]; + + void rasterize (juce::uint32 c); + uint8_t* getGlyph (juce::uint32 c); + juce::Font getMonoFont() const; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (LcdDisplay) +}; \ No newline at end of file diff --git a/Source/MenuSystem.cpp b/Source/MenuSystem.cpp new file mode 100644 index 0000000..e4b0e77 --- /dev/null +++ b/Source/MenuSystem.cpp @@ -0,0 +1,124 @@ +#include "MenuSystem.h" + +MenuSystem::MenuSystem() {} + +void MenuSystem::setRootScreenBuilder (ScreenBuilder fn) { rootBuilder = fn; } + +void MenuSystem::rebuildRoot() +{ + if (rootBuilder) + { + MenuScreen root = rootBuilder(); + stack.clear(); + stack.push_back (root); + } +} + +void MenuSystem::open() +{ + wasOpen = true; + rebuildRoot(); +} + +void MenuSystem::close() +{ + stack.clear(); + wasOpen = false; +} + +void MenuSystem::pushScreen (const MenuScreen& screen) { stack.push_back (screen); } + +const MenuScreen& MenuSystem::current() const +{ + if (stack.empty()) + { + static MenuScreen empty; + empty.title = ""; + empty.selected = 0; + return empty; + } + return stack.back(); +} + +void MenuSystem::refreshCurrent() +{ + if (! stack.empty()) + { + int sel = stack.back().selected; + stack.pop_back(); + if (stack.empty() && rootBuilder) + { + MenuScreen root = rootBuilder(); + root.selected = sel; + stack.push_back (root); + } + else if (! stack.empty()) + { + stack.back().selected = sel; + } + } +} + +void MenuSystem::up() +{ + if (stack.empty()) return; + auto& s = stack.back(); + if (s.items.empty()) return; + + int newSel = s.selected - 1; + while (newSel >= 0 && s.items[(size_t) newSel].kind == MenuItem::Separator) + --newSel; + if (newSel >= 0) + s.selected = newSel; +} + +void MenuSystem::down() +{ + if (stack.empty()) return; + auto& s = stack.back(); + if (s.items.empty()) return; + + int newSel = s.selected + 1; + while (newSel < (int) s.items.size() && s.items[(size_t) newSel].kind == MenuItem::Separator) + ++newSel; + if (newSel < (int) s.items.size()) + s.selected = newSel; +} + +void MenuSystem::left() +{ + if (stack.empty()) return; + auto& s = stack.back(); + if (s.items.empty()) return; + auto& item = s.items[(size_t) s.selected]; + if (item.kind == MenuItem::Value && item.onAdjust) + item.onAdjust (-0.02f); +} + +void MenuSystem::right() +{ + if (stack.empty()) return; + auto& s = stack.back(); + if (s.items.empty()) return; + auto& item = s.items[(size_t) s.selected]; + if (item.kind == MenuItem::Value && item.onAdjust) + item.onAdjust (0.02f); +} + +void MenuSystem::enter() +{ + if (stack.empty()) return; + auto& s = stack.back(); + if (s.items.empty()) return; + auto& item = s.items[(size_t) s.selected]; + if (item.onEnter) + item.onEnter(); +} + +void MenuSystem::escape() +{ + if (stack.size() > 1) + stack.pop_back(); + else if (stack.size() == 1) + close(); +} \ No newline at end of file diff --git a/Source/MenuSystem.h b/Source/MenuSystem.h new file mode 100644 index 0000000..f7c8ae2 --- /dev/null +++ b/Source/MenuSystem.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include "SynthParams.h" + +class MenuSystem; + +struct MenuItem +{ + enum Kind { Action, Value, Submenu, Separator }; + + juce::String label; + Kind kind = Action; + int paramId = -1; + + std::function onEnter; + std::function onAdjust; + std::function valueText; + + MenuItem& action (std::function fn) { onEnter = fn; return *this; } + MenuItem& param (int id, float lo, float hi) { paramId = id; return *this; } + MenuItem& value (std::function fn) { valueText = fn; kind = Value; return *this; } + MenuItem& submenu() { kind = Submenu; return *this; } + MenuItem& sep() { kind = Separator; return *this; } +}; + +struct MenuScreen +{ + juce::String title; + std::vector items; + int selected = 0; +}; + +class MenuSystem +{ +public: + MenuSystem(); + + void open(); + void close(); + bool isOpen() const { return ! stack.empty(); } + + void up(); + void down(); + void left(); + void right(); + void enter(); + void escape(); + + const MenuScreen& current() const; + + void pushScreen (const MenuScreen& screen); + + using ScreenBuilder = std::function; + + void setRootScreenBuilder (ScreenBuilder fn); + + void refreshCurrent(); + +private: + std::vector stack; + ScreenBuilder rootBuilder; + bool wasOpen = false; + + void rebuildRoot(); +}; \ No newline at end of file diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..862940c --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,588 @@ +#include "PluginEditor.h" +#include "BuildInfo_generated.h" + +static juce::String padRight (const juce::String& s, int width) +{ + if (s.length() >= width) return s.substring (0, width); + return s + juce::String::repeatedString (" ", width - s.length()); +} + +SM26AudioProcessorEditor::SM26AudioProcessorEditor (SM26AudioProcessor& p) + : AudioProcessorEditor (p), proc (p) +{ + setSize (920, 540); + setResizable (true, false); + setResizeLimits (720, 400, 1400, 900); + + addAndMakeVisible (lcd); + setupKnobs(); + setupNavButtons(); + + footer.setText (buildGitFooter(), juce::dontSendNotification); + footer.setJustificationType (juce::Justification::centredLeft); + footer.setColour (juce::Label::textColourId, juce::Colour (0xFF90B080)); + footer.setFont (juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(), 11.0f, juce::Font::plain })); + addAndMakeVisible (footer); + + buildRootMenu(); + startTimerHz (20); +} + +SM26AudioProcessorEditor::~SM26AudioProcessorEditor() +{ + stopTimer(); +} + +juce::String SM26AudioProcessorEditor::buildGitFooter() +{ + return juce::String ("SM-26 v") + SM26_GIT_VERSION + + " | branch " + SM26_GIT_BRANCH + + " | built " + SM26_BUILD_TIME + + " | arm64"; +} + +void SM26AudioProcessorEditor::setupKnobs() +{ + for (int i = 0; i < 4; ++i) + { + knobSliders[i].setSliderStyle (juce::Slider::RotaryVerticalDrag); + knobSliders[i].setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); + knobSliders[i].setRange (0.0, 1.0, 0.001); + knobSliders[i].setColour (juce::Slider::rotarySliderFillColourId, juce::Colour (0xFF3A8A28)); + knobSliders[i].setColour (juce::Slider::rotarySliderOutlineColourId, juce::Colour (0xFF1A2E0A)); + knobSliders[i].setColour (juce::Slider::thumbColourId, juce::Colour (0xFFE0E0C0)); + knobSliders[i].addListener (this); + addAndMakeVisible (knobSliders[i]); + + knobLabels[i].setJustificationType (juce::Justification::centred); + knobLabels[i].setColour (juce::Label::textColourId, juce::Colour (0xFF90B080)); + knobLabels[i].setFont (juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(), 11.0f, juce::Font::plain })); + addAndMakeVisible (knobLabels[i]); + + knobValueLabels[i].setJustificationType (juce::Justification::centred); + knobValueLabels[i].setColour (juce::Label::textColourId, juce::Colour (0xFFC0E0A0)); + knobValueLabels[i].setFont (juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(), 12.0f, juce::Font::bold })); + addAndMakeVisible (knobValueLabels[i]); + + knobSliders[i].setValue (0.5, juce::dontSendNotification); + } +} + +void SM26AudioProcessorEditor::setupNavButtons() +{ + auto setup = [this](juce::TextButton& btn, juce::Colour bg) + { + btn.setColour (juce::TextButton::buttonColourId, bg); + btn.setColour (juce::TextButton::textColourOffId, juce::Colour (0xFFE0E0C0)); + btn.setColour (juce::ComboBox::outlineColourId, juce::Colour (0xFF2A4A18)); + addAndMakeVisible (btn); + }; + + setup (navUp, juce::Colour (0xFF2A4A18)); + setup (navDown, juce::Colour (0xFF2A4A18)); + setup (navLeft, juce::Colour (0xFF2A4A18)); + setup (navRight, juce::Colour (0xFF2A4A18)); + setup (navEnter, juce::Colour (0xFF3A6A28)); + setup (navEsc, juce::Colour (0xFF3A2A18)); + + navUp.onClick = [this] { menu.up(); }; + navDown.onClick = [this] { menu.down(); }; + navLeft.onClick = [this] { menu.left(); }; + navRight.onClick = [this] { menu.right(); }; + navEnter.onClick = [this] { menu.open(); }; + navEsc.onClick = [this] { if (menu.isOpen()) menu.escape(); else menu.close(); }; +} + +void SM26AudioProcessorEditor::buildRootMenu() +{ + auto builder = [this]() -> MenuScreen + { + MenuScreen s; + s.title = "SM-26 MAIN MENU"; + s.selected = 0; + + auto addPreset = [&](const char* name, int idx) -> MenuItem + { + MenuItem item; + item.label = name; + item.kind = MenuItem::Action; + item.onEnter = [this, idx]() { + proc.setPresetByIndex (idx); + }; + return item; + }; + + { + MenuItem m; + m.label = "PRESETS (" + juce::String (sm26::PresetStore::count()) + ")"; + m.kind = MenuItem::Submenu; + m.onEnter = [this, addPreset]() { + MenuScreen ps; + ps.title = "SELECT PRESET"; + ps.selected = proc.engine.presetIndex(); + for (int i = 0; i < sm26::PresetStore::count(); ++i) + ps.items.push_back (addPreset ( + sm26::PresetStore::get(i)->name, i)); + menu.pushScreen (ps); + }; + s.items.push_back (m); + } + + s.items.push_back (MenuItem().sep()); + + { + MenuItem m; + m.label = "OSCILLATORS"; + m.kind = MenuItem::Submenu; + m.onEnter = [this]() { + MenuScreen os; + os.title = "OSCILLATORS"; + os.selected = 0; + + auto addItem = [&](const char* n, int pid) { + MenuItem mi; + mi.label = n; + mi.kind = MenuItem::Value; + mi.paramId = pid; + mi.valueText = [this, pid]() { return sm26::formatParamReal (pid, proc.engine.getParam (pid)); }; + mi.onAdjust = [this, pid](float d) { + float cur = proc.engine.getParam (pid); + proc.engine.setParam (pid, sm26::clamp01 (cur + d)); + for (int k = 0; k < 4; ++k) + { + auto kv = proc.engine.knobView (k); + if (kv.param == pid) + proc.setKnob (k, sm26::clamp01 ((proc.engine.getParam (pid) - kv.lo) / (kv.hi - kv.lo + 1e-9f))); + } + }; + os.items.push_back (mi); + }; + + addItem ("OSC1 WAVE", sm26::PO1WAVE); + addItem ("OSC2 WAVE", sm26::PO2WAVE); + addItem ("O1 OCTAVE", sm26::PO1OCT); + addItem ("O1 DETUNE", sm26::PO1CT); + addItem ("O2 OCTAVE", sm26::PO2OCT); + addItem ("O2 DETUNE", sm26::PO2CT); + addItem ("MIX", sm26::PMIX); + addItem ("NOISE", sm26::PNOISE); + menu.pushScreen (os); + }; + s.items.push_back (m); + } + + { + MenuItem m; + m.label = "FILTER"; + m.kind = MenuItem::Submenu; + m.onEnter = [this]() { + MenuScreen fs; + fs.title = "FILTER"; + fs.selected = 0; + + auto addItem = [&](const char* n, int pid) { + MenuItem mi; + mi.label = n; + mi.kind = MenuItem::Value; + mi.paramId = pid; + mi.valueText = [this, pid]() { return sm26::formatParamReal (pid, proc.engine.getParam (pid)); }; + mi.onAdjust = [this, pid](float d) { + float cur = proc.engine.getParam (pid); + proc.engine.setParam (pid, sm26::clamp01 (cur + d)); + for (int k = 0; k < 4; ++k) + { + auto kv = proc.engine.knobView (k); + if (kv.param == pid) + proc.setKnob (k, sm26::clamp01 ((proc.engine.getParam (pid) - kv.lo) / (kv.hi - kv.lo + 1e-9f))); + } + }; + fs.items.push_back (mi); + }; + + addItem ("CUTOFF", sm26::PCUTOFF); + addItem ("RES", sm26::PRES); + addItem ("F-ENV", sm26::PFENV); + addItem ("F-TYPE", sm26::PFTYPE); + menu.pushScreen (fs); + }; + s.items.push_back (m); + } + + { + MenuItem m; + m.label = "ENVELOPE"; + m.kind = MenuItem::Submenu; + m.onEnter = [this]() { + MenuScreen es; + es.title = "ENVELOPE"; + es.selected = 0; + + auto addItem = [&](const char* n, int pid) { + MenuItem mi; + mi.label = n; + mi.kind = MenuItem::Value; + mi.paramId = pid; + mi.valueText = [this, pid]() { return sm26::formatParamReal (pid, proc.engine.getParam (pid)); }; + mi.onAdjust = [this, pid](float d) { + float cur = proc.engine.getParam (pid); + proc.engine.setParam (pid, sm26::clamp01 (cur + d)); + for (int k = 0; k < 4; ++k) + { + auto kv = proc.engine.knobView (k); + if (kv.param == pid) + proc.setKnob (k, sm26::clamp01 ((proc.engine.getParam (pid) - kv.lo) / (kv.hi - kv.lo + 1e-9f))); + } + }; + es.items.push_back (mi); + }; + + addItem ("ATTACK", sm26::PATK); + addItem ("DECAY", sm26::PDEC); + addItem ("SUSTAIN", sm26::PSUS); + addItem ("RELEASE", sm26::PREL); + menu.pushScreen (es); + }; + s.items.push_back (m); + } + + { + MenuItem m; + m.label = "DELAY"; + m.kind = MenuItem::Submenu; + m.onEnter = [this]() { + MenuScreen ds; + ds.title = "DELAY"; + ds.selected = 0; + + auto addItem = [&](const char* n, int pid) { + MenuItem mi; + mi.label = n; + mi.kind = MenuItem::Value; + mi.paramId = pid; + mi.valueText = [this, pid]() { return sm26::formatParamReal (pid, proc.engine.getParam (pid)); }; + mi.onAdjust = [this, pid](float d) { + float cur = proc.engine.getParam (pid); + proc.engine.setParam (pid, sm26::clamp01 (cur + d)); + for (int k = 0; k < 4; ++k) + { + auto kv = proc.engine.knobView (k); + if (kv.param == pid) + proc.setKnob (k, sm26::clamp01 ((proc.engine.getParam (pid) - kv.lo) / (kv.hi - kv.lo + 1e-9f))); + } + }; + ds.items.push_back (mi); + }; + + addItem ("TIME/STEPS", sm26::PDTIME); + addItem ("STEPS", sm26::PDSTEPS); + addItem ("SYNC", sm26::PDSYNC); + addItem ("FEEDBACK", sm26::PDFB); + addItem ("MIX", sm26::PDMIX); + menu.pushScreen (ds); + }; + s.items.push_back (m); + } + + { + MenuItem m; + m.label = "SYSTEM"; + m.kind = MenuItem::Submenu; + m.onEnter = [this]() { + MenuScreen ss; + ss.title = "SYSTEM"; + ss.selected = 0; + + auto addItem = [&](const char* n, int pid) { + MenuItem mi; + mi.label = n; + mi.kind = MenuItem::Value; + mi.paramId = pid; + mi.valueText = [this, pid]() { return sm26::formatParamReal (pid, proc.engine.getParam (pid)); }; + mi.onAdjust = [this, pid](float d) { + float cur = proc.engine.getParam (pid); + proc.engine.setParam (pid, sm26::clamp01 (cur + d)); + for (int k = 0; k < 4; ++k) + { + auto kv = proc.engine.knobView (k); + if (kv.param == pid) + proc.setKnob (k, sm26::clamp01 ((proc.engine.getParam (pid) - kv.lo) / (kv.hi - kv.lo + 1e-9f))); + } + }; + ss.items.push_back (mi); + }; + + addItem ("DRIVE", sm26::PDRIVE); + addItem ("GLIDE", sm26::PGLIDE); + addItem ("MASTER", sm26::PMASTER); + + { + MenuItem mi; mi.label = "---"; mi.kind = MenuItem::Separator; + ss.items.push_back (mi); + } + + { + MenuItem mi; + mi.label = "ABOUT"; + mi.kind = MenuItem::Action; + mi.onEnter = [this]() { + MenuScreen as; + as.title = "ABOUT"; + as.selected = 0; + { + MenuItem m2; m2.label = "SM-26 Synthesizer"; m2.kind = MenuItem::Action; + as.items.push_back (m2); + } + { + MenuItem m2; + m2.label = "Version: " + juce::String (SM26_GIT_VERSION); + m2.kind = MenuItem::Action; + as.items.push_back (m2); + } + { + MenuItem m2; + m2.label = "Built: " + juce::String (SM26_BUILD_TIME); + m2.kind = MenuItem::Action; + as.items.push_back (m2); + } + { + MenuItem m2; + m2.label = "Branch: " + juce::String (SM26_GIT_BRANCH); + m2.kind = MenuItem::Action; + as.items.push_back (m2); + } + { + MenuItem m2; m2.label = ""; m2.kind = MenuItem::Separator; + as.items.push_back (m2); + } + { + MenuItem m2; + m2.label = "48 presets | VST3 + AU"; + m2.kind = MenuItem::Action; + as.items.push_back (m2); + } + menu.pushScreen (as); + }; + ss.items.push_back (mi); + } + + menu.pushScreen (ss); + }; + s.items.push_back (m); + } + + return s; + }; + + menu.setRootScreenBuilder (builder); +} + +void SM26AudioProcessorEditor::paint (juce::Graphics& g) +{ + g.fillAll (juce::Colour (0xFF0A0F06)); + + const float totalW = (float) getWidth(); + const float totalH = (float) getHeight(); + + g.setColour (juce::Colour (0xFF1A2E0A)); + g.fillRoundedRectangle (8.0f, 8.0f, totalW - 16.0f, totalH - 16.0f, 6.0f); + + g.setColour (juce::Colour (0xFF2A4A18)); + g.drawRoundedRectangle (8.0f, 8.0f, totalW - 16.0f, totalH - 16.0f, 6.0f, 1.5f); + + if (! menu.isOpen()) + { + g.setColour (juce::Colour (0xFF6AE050)); + g.setFont (juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(), 14.0f, juce::Font::bold })); + g.drawText ("SM-26", 20.0f, 16.0f, 100.0f, 22.0f, juce::Justification::centredLeft); + } + else + { + g.setColour (juce::Colour (0xFFE0E0C0)); + g.setFont (juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(), 14.0f, juce::Font::bold })); + g.drawText (menu.current().title, 20.0f, 16.0f, getWidth() - 40.0f, 22.0f, + juce::Justification::centred); + } + + if (menu.isOpen()) + { + const auto& ms = menu.current(); + float y = 44.0f; + float itemH = 18.0f; + float menuLeft = 20.0f; + float menuW = getWidth() - 40.0f; + + for (int i = 0; i < (int) ms.items.size() && y < (float) getHeight() - 60.0f; ++i) + { + const auto& item = ms.items[i]; + + if (item.kind == MenuItem::Separator) + { + g.setColour (juce::Colour (0xFF2A4A18)); + g.drawHorizontalLine ((int) y, menuLeft, menuLeft + menuW); + y += 4.0f; + continue; + } + + bool selected = (i == ms.selected); + + if (selected) + { + g.setColour (juce::Colour (0xFF3A6A28)); + g.fillRoundedRectangle (menuLeft, y, menuW, itemH, 3.0f); + } + + g.setColour (selected ? juce::Colour (0xFFE0E0C0) : juce::Colour (0xFF709060)); + g.setFont (juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(), 12.0f, juce::Font::plain })); + g.drawText (item.label, menuLeft + 8.0f, y, menuW * 0.6f, itemH, + juce::Justification::centredLeft); + + if (item.kind == MenuItem::Value && item.valueText) + { + g.setColour (juce::Colour (0xFFA0E080)); + g.drawText (item.valueText(), menuLeft + menuW * 0.55f, y, + menuW * 0.4f, itemH, juce::Justification::centredRight); + } + else if (item.kind == MenuItem::Submenu) + { + g.setColour (juce::Colour (0xFF90B080)); + g.drawText (">", menuLeft + menuW - 16.0f, y, 16.0f, itemH, + juce::Justification::centredRight); + } + + y += itemH + 1.0f; + } + } +} + +void SM26AudioProcessorEditor::resized() +{ + const float totalW = (float) getWidth(); + const float totalH = (float) getHeight(); + + float knobSize = 62.0f; + float knobGap = 18.0f; + float knobsW = 4.0f * knobSize + 3.0f * knobGap; + float knobsX = (totalW - knobsW) / 2.0f; + float knobsY = totalH - 135.0f; + + for (int i = 0; i < 4; ++i) + { + float x = knobsX + (float) i * (knobSize + knobGap); + knobSliders[i].setBounds ((int) x, (int) knobsY, (int) knobSize, (int) knobSize); + knobLabels[i].setBounds ((int) x, (int) (knobsY + knobSize + 2), (int) knobSize, 16); + knobValueLabels[i].setBounds ((int) x, (int) (knobsY + knobSize + 18), (int) knobSize, 14); + } + + float navSize = 36.0f; + float navGap = 4.0f; + float navCenterX = totalW / 2.0f; + float navCenterY = totalH - 48.0f; + + navUp.setBounds ((int) (navCenterX - navSize / 2), (int) (navCenterY - navSize - navGap), + (int) navSize, (int) navSize); + navDown.setBounds ((int) (navCenterX - navSize / 2), (int) (navCenterY + navGap), + (int) navSize, (int) navSize); + navLeft.setBounds ((int) (navCenterX - navSize - navGap - navSize / 2), (int) (navCenterY - navSize / 2), + (int) navSize, (int) navSize); + navRight.setBounds ((int) (navCenterX + navGap + navSize / 2), (int) (navCenterY - navSize / 2), + (int) navSize, (int) navSize); + + navEnter.setBounds ((int) (navCenterX - navSize - navGap), (int) (navCenterY + navSize + navGap), + (int) navSize, (int) navSize); + navEsc.setBounds ((int) (navCenterX + navGap), (int) (navCenterY + navSize + navGap), + (int) navSize, (int) navSize); + + lcd.setBounds (14, 36, (int) totalW - 28, (int) totalH - 190); + + footer.setBounds (8, (int) totalH - 22, (int) totalW - 16, 16); +} + +void SM26AudioProcessorEditor::timerCallback() +{ + updateLCD(); + for (int i = 0; i < 4; ++i) + { + auto kv = proc.engine.knobView (i); + float val = proc.engine.getParam (kv.param); + knobSliders[i].setValue (val, juce::dontSendNotification); + knobLabels[i].setText (kv.name, juce::dontSendNotification); + knobValueLabels[i].setText (sm26::formatParamReal (kv.param, val), juce::dontSendNotification); + } + + if (! menu.isOpen() && menuWasOpen) + buildRootMenu(); + menuWasOpen = menu.isOpen(); + + repaint(); +} + +void SM26AudioProcessorEditor::sliderValueChanged (juce::Slider* s) +{ + for (int i = 0; i < 4; ++i) + { + if (s == &knobSliders[i]) + { + float v = (float) s->getValue(); + auto kv = proc.engine.knobView (i); + float paramVal = sm26::clamp01 (kv.lo + v * (kv.hi - kv.lo)); + proc.engine.setParam (kv.param, paramVal); + break; + } + } +} + +void SM26AudioProcessorEditor::updateLCD() +{ + if (! menu.isOpen()) + { + int presetIdx = proc.engine.presetIndex(); + const auto* p = sm26::PresetStore::findByIndex (presetIdx); + + lcd.setLine (0, juce::String (presetIdx + 1) + "/" + juce::String (sm26::PresetStore::count()) + + " " + sm26::PresetStore::fullLabel (*p)); + + lcd.setLine (1, padRight (proc.engine.knobView (0).name, 12) + + padRight (sm26::formatParamReal (proc.engine.knobView(0).param, + proc.engine.getParam(proc.engine.knobView(0).param)), 12) + + " " + + padRight (proc.engine.knobView (2).name, 12) + + sm26::formatParamReal (proc.engine.knobView(2).param, + proc.engine.getParam(proc.engine.knobView(2).param))); + + lcd.setLine (2, padRight (proc.engine.knobView (1).name, 12) + + padRight (sm26::formatParamReal (proc.engine.knobView(1).param, + proc.engine.getParam(proc.engine.knobView(1).param)), 12) + + " " + + padRight (proc.engine.knobView (3).name, 12) + + sm26::formatParamReal (proc.engine.knobView(3).param, + proc.engine.getParam(proc.engine.knobView(3).param))); + + double bpm = proc.engine.lastBpm(); + bool playing = proc.engine.transport(); + juce::String bpmStr = playing ? juce::String (bpm, 1) + " BPM" : "--- BPM"; + int notes = proc.engine.notesPlaying(); + juce::String notesStr = notes > 0 ? "NOTES:" + juce::String (notes) : ""; + + lcd.setLine (3, juce::String ("SM-26") + " " + bpmStr + " " + notesStr); + lcd.showCursor (false); + } + else + { + const auto& cs = menu.current(); + + lcd.setLine (0, "== " + cs.title + " =="); + for (int row = 0; row < 3 && row < (int) cs.items.size(); ++row) + { + int idx = juce::jlimit (0, (int) cs.items.size() - 1, cs.selected + row); + const auto& item = cs.items[idx]; + juce::String line = (idx == cs.selected ? "> " : " ") + item.label; + if (item.kind == MenuItem::Value && item.valueText) + line = padRight (line, 50) + item.valueText(); + else if (item.kind == MenuItem::Submenu) + line = line + " [ENTER]"; + lcd.setLine (1 + row, line); + } + lcd.setLine (3, "UP/DOWN=SEL L/R=VAL ENTER=OK ESC=BACK"); + lcd.showCursor (false); + } +} \ No newline at end of file diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..7726311 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include "PluginProcessor.h" +#include "LcdDisplay.h" +#include "MenuSystem.h" +#include "Presets.h" +#include "SynthParams.h" + +class SM26AudioProcessorEditor : public juce::AudioProcessorEditor, + private juce::Timer, + private juce::Slider::Listener +{ +public: + SM26AudioProcessorEditor (SM26AudioProcessor&); + ~SM26AudioProcessorEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + void timerCallback() override; + +private: + void sliderValueChanged (juce::Slider*) override; + + void setupKnobs(); + void setupNavButtons(); + void buildRootMenu(); + void updateLCD(); + + SM26AudioProcessor& proc; + MenuSystem menu; + + LcdDisplay lcd; + + juce::Slider knobSliders[4]; + juce::Label knobLabels[4]; + juce::Label knobValueLabels[4]; + + juce::TextButton navUp { "UP" }; + juce::TextButton navDown { "DOWN" }; + juce::TextButton navLeft { "LEFT" }; + juce::TextButton navRight { "RIGHT" }; + juce::TextButton navEnter { "ENTER" }; + juce::TextButton navEsc { "ESC" }; + + juce::Label footer; + + bool menuWasOpen = false; + + static juce::String buildGitFooter(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SM26AudioProcessorEditor) +}; \ No newline at end of file diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..f6d6e19 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,163 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" +#include "BuildInfo_generated.h" + +SM26AudioProcessor::SM26AudioProcessor() + : AudioProcessor (BusesProperties().withOutput ("Output", juce::AudioChannelSet::stereo(), true)), + apvts (*this, nullptr, "SM26State", { + std::make_unique ("k1", "Knob 1", juce::NormalisableRange(0.f,1.f,0.001f), 0.5f), + std::make_unique ("k2", "Knob 2", juce::NormalisableRange(0.f,1.f,0.001f), 0.5f), + std::make_unique ("k3", "Knob 3", juce::NormalisableRange(0.f,1.f,0.001f), 0.5f), + std::make_unique ("k4", "Knob 4", juce::NormalisableRange(0.f,1.f,0.001f), 0.5f), + std::make_unique ("preset", "Preset", + 0, sm26::PresetStore::count() - 1, 0) + }) +{ + engine.setPresetIndex (0); + + apvts.addParameterListener ("k1", this); + apvts.addParameterListener ("k2", this); + apvts.addParameterListener ("k3", this); + apvts.addParameterListener ("k4", this); + apvts.addParameterListener ("preset", this); + paramsReady.store (true); +} + +SM26AudioProcessor::~SM26AudioProcessor() +{ + apvts.removeParameterListener ("k1", this); + apvts.removeParameterListener ("k2", this); + apvts.removeParameterListener ("k3", this); + apvts.removeParameterListener ("k4", this); + apvts.removeParameterListener ("preset", this); +} + +void SM26AudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + engine.prepare (sampleRate, samplesPerBlock); +} + +void SM26AudioProcessor::releaseResources() {} + +void SM26AudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + juce::ScopedNoDenormals noDenormals; + + const int n = buffer.getNumSamples(); + + double bpm = 120.0; + bool playing = false; + if (auto* ph = getPlayHead()) + { + auto pos = ph->getPosition(); + if (pos.hasValue()) + { + if (pos->getBpm().hasValue()) + bpm = *pos->getBpm(); + playing = pos->getIsPlaying(); + } + } + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + buffer.clear (ch, 0, n); + + engine.process (buffer, midi, n, bpm, playing); +} + +juce::AudioProcessorEditor* SM26AudioProcessor::createEditor() +{ + return new SM26AudioProcessorEditor (*this); +} + +void SM26AudioProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + auto xmlStr = apvts.copyState().createXml()->toString(); + destData.replaceAll (xmlStr.toRawUTF8(), (size_t) xmlStr.length()); +} + +void SM26AudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + auto xml = juce::XmlDocument::parse (juce::String::fromUTF8 ((const char*) data, sizeInBytes)); + if (xml != nullptr) + { + auto tree = juce::ValueTree::fromXml (*xml); + if (tree.isValid()) + { + paramsReady.store (false); + apvts.replaceState (tree); + paramsReady.store (true); + int idx = (int) *apvts.getRawParameterValue ("preset"); + engine.setPresetIndex (idx); + applyPresetKnobs(); + } + } +} + +void SM26AudioProcessor::setKnob (int knob, float value01) +{ + if (knob < 0 || knob >= 4) return; + auto* p = apvts.getParameter (juce::String ("k") + juce::String (knob + 1)); + if (p != nullptr) + p->setValueNotifyingHost (p->convertTo0to1 (value01)); +} + +void SM26AudioProcessor::adjustParam (int paramId, float delta01) +{ + float cur = engine.getParam (paramId); + engine.setParam (paramId, sm26::clamp01 (cur + delta01)); + + for (int k = 0; k < 4; ++k) + { + auto kv = engine.knobView (k); + if (kv.param == paramId) + { + float pval = engine.getParam (paramId); + float knobVal = sm26::clamp01 ((pval - kv.lo) / (kv.hi - kv.lo + 1e-9f)); + setKnob (k, knobVal); + } + } +} + +void SM26AudioProcessor::setPresetByIndex (int i) +{ + auto* p = apvts.getParameter ("preset"); + if (p != nullptr) + p->setValueNotifyingHost (p->convertTo0to1 ((float) i)); +} + +void SM26AudioProcessor::parameterChanged (const juce::String& parameterID, float newValue) +{ + if (! paramsReady.load()) return; + + if (parameterID == "preset") + { + int idx = (int) std::round (newValue * (float) (sm26::PresetStore::count() - 1)); + idx = juce::jlimit (0, sm26::PresetStore::count() - 1, idx); + engine.setPresetIndex (idx); + applyPresetKnobs(); + } + else if (parameterID == "k1" || parameterID == "k2" || parameterID == "k3" || parameterID == "k4") + { + int k = parameterID.getIntValue() - 1; + auto kv = engine.knobView (k); + + float paramVal = sm26::clamp01 (kv.lo + newValue * (kv.hi - kv.lo)); + engine.setParam (kv.param, paramVal); + } +} + +void SM26AudioProcessor::applyPresetKnobs() +{ + for (int k = 0; k < 4; ++k) + { + auto kv = engine.knobView (k); + float paramVal = engine.getParam (kv.param); + float knobVal = sm26::clamp01 ((paramVal - kv.lo) / (kv.hi - kv.lo + 1e-9f)); + setKnob (k, knobVal); + } +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new SM26AudioProcessor(); +} \ No newline at end of file diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..93111e6 --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include "SynthEngine.h" +#include "Presets.h" + +class SM26AudioProcessor : public juce::AudioProcessor, + public juce::AudioProcessorValueTreeState::Listener +{ +public: + SM26AudioProcessor(); + ~SM26AudioProcessor() override; + + void prepareToPlay (double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override { return true; } + const juce::String getName() const override { return "SM-26"; } + + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return false; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 0.0; } + + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram (int) override {} + const juce::String getProgramName (int) override { return {}; } + void changeProgramName (int, const juce::String&) override {} + + void getStateInformation (juce::MemoryBlock& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + void setKnob (int knob, float value01); + void adjustParam (int paramId, float delta01); + + juce::AudioProcessorValueTreeState apvts; + sm26::SynthEngine engine; + + void setPresetByIndex (int i); + int presetCount() const { return sm26::PresetStore::count(); } + +private: + void parameterChanged (const juce::String& parameterID, float newValue) override; + void applyPresetKnobs(); + + std::atomic paramsReady{false}; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SM26AudioProcessor) +}; \ No newline at end of file diff --git a/Source/Presets.cpp b/Source/Presets.cpp new file mode 100644 index 0000000..0d5c34a --- /dev/null +++ b/Source/Presets.cpp @@ -0,0 +1,400 @@ +#include "Presets.h" + +namespace sm26 { + +namespace { + +struct Catalog { + Preset data[48]; + int n = 0; +}; + +Catalog& catalog() +{ + static Catalog cat; + + if (cat.n == 0) + { + using P = std::pair; + using C = ControlDef; + + auto K = [](const char* n, int p, float lo, float hi) { + C c; c.name = n; c.param = p; c.lo = lo; c.hi = hi; return c; + }; + + auto add = [&](const char* name, const char* category, + const std::initializer_list

& delta, + const ControlDef (&ctl)[4]) + { + Preset& p = cat.data[cat.n++]; + p.name = name; + p.category = category; + p.base = defaultParams(); + for (auto& d : delta) + p.base[d.first] = d.second; + for (int i = 0; i < 4; ++i) + p.ctl[i] = ctl[i]; + }; + + const C cCff[4] = { K("CUTOFF",PCUTOFF,0.05f,1.0f), K("RES",PRES,0.0f,1.0f), K("F-ENV",PFENV,0.0f,1.0f), K("DELAY",PDMIX,0.15f,1.0f) }; + const C cResDcy[4]= { K("CUTOFF",PCUTOFF,0.05f,1.0f), K("RES",PRES,0.0f,1.0f), K("DECAY",PDEC,0.15f,0.98f),K("DELAY",PDMIX,0.15f,1.0f) }; + const C cEnvDcy[4]= { K("CUTOFF",PCUTOFF,0.05f,1.0f), K("F-ENV",PFENV,0.0f,1.0f),K("DECAY",PDEC,0.15f,0.98f),K("GLIDE",PGLIDE,0.0f,1.0f) }; + const C cPluck[4] = { K("CUTOFF",PCUTOFF,0.05f,0.95f), K("DECAY",PDEC,0.1f,0.9f), K("RES",PRES,0.0f,1.0f), K("DELAY",PDMIX,0.0f,0.8f) }; + const C cPads[4] = { K("CUTOFF",PCUTOFF,0.05f,0.95f), K("ATK",PATK,0.0f,1.0f), K("DECAY",PDEC,0.2f,0.95f), K("D-MIX",PDMIX,0.0f,1.0f) }; + const C cDrift[4] = { K("CUTOFF",PCUTOFF,0.05f,1.0f), K("DETUNE",PO1CT,0.0f,1.0f),K("DRIVE",PDRIVE,0.0f,1.0f),K("DELAY",PDMIX,0.0f,1.0f) }; + const C cNoise[4] = { K("CUTOFF",PCUTOFF,0.05f,1.0f), K("NOISE",PNOISE,0.0f,1.0f),K("F-ENV",PFENV,0.0f,1.0f), K("DELAY",PDMIX,0.0f,1.0f) }; + const C cShp[4] = { K("CUTOFF",PCUTOFF,0.05f,1.0f), K("RES",PRES,0.0f,1.0f), K("F-TYPE",PFTYPE,0.0f,1.0f),K("DELAY",PDMIX,0.0f,1.0f) }; + const C cKeys[4] = { K("CUTOFF",PCUTOFF,0.05f,0.95f), K("DECAY",PDEC,0.1f,0.9f), K("DRIVE",PDRIVE,0.0f,1.0f),K("D-MIX",PDMIX,0.0f,1.0f) }; + const C cStab[4] = { K("CUTOFF",PCUTOFF,0.05f,0.95f), K("RES",PRES,0.0f,1.0f), K("F-ENV",PFENV,0.0f,1.0f), K("DRIVE",PDRIVE,0.0f,1.0f) }; + + // BASS ------------------------------------------------------------ + add("Sub Square", "BASS", + { {PO1WAVE,0.75f},{PO2WAVE,0.0f},{PO1OCT,0.25f},{PO2OCT,0.28f},{PMIX,0.9f}, + {PCUTOFF,0.4f},{PRES,0.35f},{PATK,0.015f},{PDEC,0.4f},{PSUS,0.7f},{PREL,0.25f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDFB,0.5f},{PDMIX,0.4f},{PMASTER,0.8f} }, + { K("CUTOFF",PCUTOFF,0.15f,0.8f),K("RES",PRES,0.0f,1.0f),K("DECAY",PDEC,0.15f,0.95f),K("D-MIX",PDMIX,0.0f,1.0f) }); + + add("Deep Saw", "BASS", + { {PO1WAVE,0.5f},{PO2WAVE,0.75f},{PO1OCT,0.317f},{PO2OCT,0.36f},{PMIX,0.5f}, + {PCUTOFF,0.42f},{PRES,0.25f},{PFENV,0.3f},{PATK,0.03f},{PDEC,0.45f},{PSUS,0.65f},{PREL,0.2f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDFB,0.45f},{PDMIX,0.35f},{PMASTER,0.75f} }, + { K("CUTOFF",PCUTOFF,0.1f,0.7f),K("RES",PRES,0.0f,1.0f),K("F-ENV",PFENV,0.0f,1.0f),K("DELAY",PDMIX,0.0f,1.0f) }); + + add("Acid Bass", "BASS", + { {PO1WAVE,0.5f},{PO2WAVE,0.0f},{PO1OCT,0.283f},{PMIX,0.98f},{PCUTOFF,0.2f},{PRES,0.55f}, + {PFENV,0.75f},{PATK,0.008f},{PDEC,0.6f},{PSUS,0.3f},{PREL,0.4f}, + {PDSYNC,0.0f},{PDTIME,0.2f},{PDFB,0.6f},{PDMIX,0.3f},{PMASTER,0.8f} }, + { K("CUTOFF",PCUTOFF,0.04f,0.5f),K("RES",PRES,0.2f,1.0f),K("F-ENV",PFENV,0.0f,1.0f),K("DELAY",PDMIX,0.0f,1.0f) }); + + add("808 Thump", "BASS", + { {PO1WAVE,0.75f},{PO2WAVE,0.0f},{PO1OCT,0.25f},{PO2OCT,0.24f},{PMIX,0.85f}, + {PCUTOFF,0.3f},{PRES,0.7f},{PFENV,0.5f},{PATK,0.004f},{PDEC,0.65f},{PSUS,0.15f},{PREL,0.1f}, + {PDSYNC,0.0f},{PMASTER,0.85f} }, + { K("CUTOFF",PCUTOFF,0.04f,0.6f),K("RES",PRES,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.95f),K("DRIVE",PDRIVE,0.0f,1.0f) }); + + add("DnB Reeses", "BASS", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.525f},{PO2CT,0.475f},{PMIX,0.5f}, + {PCUTOFF,0.28f},{PRES,0.5f},{PFENV,0.6f},{PATK,0.01f},{PDEC,0.5f},{PSUS,0.6f},{PREL,0.15f}, + {PDRIVE,0.5f},{PDSYNC,1.0f},{PDSTEPS,10.0f},{PDFB,0.4f},{PDMIX,0.25f},{PMASTER,0.75f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.7f),K("RES",PRES,0.0f,1.0f),K("DETUNE",PO1CT,0.0f,1.0f),K("DRIVE",PDRIVE,0.0f,1.0f) }); + + add("Round Sub", "BASS", + { {PO1WAVE,0.0f},{PO2WAVE,0.25f},{PO1OCT,0.25f},{PO2OCT,0.25f},{PMIX,0.5f}, + {PCUTOFF,0.5f},{PRES,0.2f},{PATK,0.02f},{PDEC,0.4f},{PSUS,0.7f},{PREL,0.2f}, + {PDSYNC,0.0f},{PMASTER,0.8f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.8f),K("RES",PRES,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.9f),K("D-MIX",PDMIX,0.0f,1.0f) }); + + add("Filter Zam", "BASS", + { {PO1WAVE,0.5f},{PO2WAVE,0.75f},{PO1OCT,0.28f},{PO2OCT,0.35f},{PMIX,0.6f}, + {PCUTOFF,0.18f},{PRES,0.7f},{PFENV,0.8f},{PATK,0.01f},{PDEC,0.35f},{PSUS,0.4f},{PREL,0.3f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDFB,0.55f},{PDMIX,0.3f},{PMASTER,0.8f} }, + { K("CUTOFF",PCUTOFF,0.04f,0.5f),K("RES",PRES,0.0f,1.0f),K("F-ENV",PFENV,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.9f) }); + + add("Dirty Tron", "BASS", + { {PO1WAVE,0.75f},{PO2WAVE,0.5f},{PO1OCT,0.25f},{PO2OCT,0.28f},{PMIX,0.5f}, + {PCUTOFF,0.35f},{PRES,0.4f},{PFENV,0.4f},{PATK,0.02f},{PDEC,0.5f},{PSUS,0.6f},{PREL,0.15f}, + {PDRIVE,0.6f},{PDSYNC,1.0f},{PDSTEPS,6.0f},{PDFB,0.5f},{PDMIX,0.35f},{PMASTER,0.8f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.85f),K("RES",PRES,0.0f,1.0f),K("DRIVE",PDRIVE,0.0f,1.0f),K("DELAY",PDMIX,0.0f,1.0f) }); + + // LEAD ------------------------------------------------------------ + add("Plain Sine", "LEAD", + { {PO1WAVE,0.0f},{PO2WAVE,0.0f},{PMIX,1.0f},{PCUTOFF,0.7f},{PRES,0.15f},{PFENV,0.3f}, + {PATK,0.02f},{PDEC,0.3f},{PSUS,0.65f},{PREL,0.2f},{PGLIDE,0.2f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.3f},{PMASTER,0.75f} }, + cCff); + + add("Square Lead", "LEAD", + { {PO1WAVE,0.75f},{PO2WAVE,0.75f},{PO1CT,0.505f},{PMIX,0.5f},{PCUTOFF,0.55f},{PRES,0.3f}, + {PFENV,0.35f},{PATK,0.01f},{PDEC,0.3f},{PSUS,0.7f},{PREL,0.15f},{PGLIDE,0.25f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDFB,0.5f},{PDMIX,0.3f},{PMASTER,0.75f} }, + cStab); + + add("Saw Lead", "LEAD", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.51f},{PO2CT,0.49f},{PMIX,0.5f},{PCUTOFF,0.5f},{PRES,0.25f}, + {PFENV,0.4f},{PATK,0.01f},{PDEC,0.35f},{PSUS,0.7f},{PREL,0.12f},{PGLIDE,0.3f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.35f},{PMASTER,0.75f} }, + cCff); + + add("Distant Lead", "LEAD", + { {PO1WAVE,0.5f},{PO2WAVE,0.25f},{PO1CT,0.53f},{PO2CT,0.5f},{PMIX,0.5f},{PCUTOFF,0.3f},{PRES,0.4f}, + {PFENV,0.55f},{PATK,0.005f},{PDEC,0.5f},{PSUS,0.5f},{PREL,0.35f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDFB,0.6f},{PDMIX,0.55f},{PMASTER,0.8f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.8f),K("RES",PRES,0.0f,1.0f),K("F-ENV",PFENV,0.0f,1.0f),K("DELAY",PDMIX,0.0f,1.0f) }); + + add("Glass Lead", "LEAD", + { {PO1WAVE,0.75f},{PO2WAVE,0.75f},{PO1OCT,0.62f},{PO2OCT,0.56f},{PO1CT,0.51f},{PO2CT,0.49f}, + {PMIX,0.5f},{PCUTOFF,0.45f},{PRES,0.45f},{PFENV,0.5f},{PATK,0.01f},{PDEC,0.4f},{PSUS,0.6f},{PREL,0.25f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.3f},{PMASTER,0.72f} }, + cCff); + + add("Supersaw Growl", "LEAD", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.45f},{PO2CT,0.55f},{PMIX,0.5f},{PCUTOFF,0.4f},{PRES,0.3f}, + {PFENV,0.45f},{PATK,0.02f},{PDEC,0.35f},{PSUS,0.7f},{PREL,0.15f},{PDRIVE,0.35f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.25f},{PMASTER,0.75f} }, + cDrift); + + add("Night Rider", "LEAD", + { {PO1WAVE,0.5f},{PO2WAVE,0.0f},{PO1OCT,0.4f},{PO2OCT,0.5f},{PMIX,0.6f},{PCUTOFF,0.32f},{PRES,0.5f}, + {PFENV,0.6f},{PATK,0.015f},{PDEC,0.3f},{PSUS,0.7f},{PREL,0.2f},{PDRIVE,0.4f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDMIX,0.4f},{PMASTER,0.78f} }, + cShp); + + add("Sine FM Bell", "LEAD", + { {PO1WAVE,0.0f},{PO2WAVE,0.0f},{PO2OCT,0.83f},{PMIX,0.45f},{PCUTOFF,0.3f},{PRES,0.35f},{PFENV,0.7f}, + {PATK,0.002f},{PDEC,0.4f},{PSUS,0.1f},{PREL,0.4f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDFB,0.6f},{PDMIX,0.5f},{PMASTER,0.75f} }, + { K("RATIO",PO2OCT,0.0f,1.0f),K("LEVEL",PMIX,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.95f),K("DELAY",PDMIX,0.0f,1.0f) }); + + // PAD ------------------------------------------------------------- + add("Analog Pad", "PAD", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.49f},{PO2CT,0.51f},{PMIX,0.45f},{PCUTOFF,0.35f},{PRES,0.3f}, + {PFENV,0.45f},{PATK,0.45f},{PDEC,0.5f},{PSUS,0.7f},{PREL,0.6f}, + {PDSYNC,1.0f},{PDSTEPS,10.0f},{PDFB,0.45f},{PDMIX,0.4f},{PMASTER,0.7f} }, + cPads); + + add("Dream Pad", "PAD", + { {PO1WAVE,0.25f},{PO2WAVE,0.25f},{PO1OCT,0.5f},{PO2OCT,0.5f},{PO1CT,0.52f},{PO2CT,0.48f}, + {PMIX,0.5f},{PCUTOFF,0.38f},{PRES,0.42f},{PFENV,0.5f},{PATK,0.5f},{PDEC,0.45f},{PSUS,0.7f},{PREL,0.7f}, + {PDSYNC,1.0f},{PDSTEPS,12.0f},{PDFB,0.55f},{PDMIX,0.5f},{PMASTER,0.7f} }, + cPads); + + add("Dark Pad", "PAD", + { {PO1WAVE,0.5f},{PO2WAVE,0.75f},{PO1OCT,0.28f},{PO2OCT,0.35f},{PMIX,0.5f},{PCUTOFF,0.22f},{PRES,0.5f}, + {PFENV,0.35f},{PATK,0.6f},{PDEC,0.45f},{PSUS,0.75f},{PREL,0.8f}, + {PDSYNC,1.0f},{PDSTEPS,12.0f},{PDFB,0.5f},{PDMIX,0.45f},{PMASTER,0.72f} }, + cPads); + + add("Brass Pad", "PAD", + { {PO1WAVE,0.75f},{PO2WAVE,0.5f},{PO1OCT,0.4f},{PO2OCT,0.5f},{PMIX,0.5f},{PCUTOFF,0.45f},{PRES,0.4f}, + {PFENV,0.6f},{PATK,0.15f},{PDEC,0.4f},{PSUS,0.7f},{PREL,0.5f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.2f},{PMASTER,0.75f} }, + { K("CUTOFF",PCUTOFF,0.05f,1.0f),K("F-ENV",PFENV,0.0f,1.0f),K("RES",PRES,0.0f,1.0f),K("D-MIX",PDMIX,0.0f,1.0f) }); + + add("String Swell", "PAD", + { {PO1WAVE,0.25f},{PO2WAVE,0.25f},{PO1CT,0.53f},{PO2CT,0.47f},{PMIX,0.45f},{PCUTOFF,0.3f},{PRES,0.35f}, + {PFENV,0.55f},{PATK,0.8f},{PDEC,0.35f},{PSUS,0.75f},{PREL,0.7f}, + {PDSYNC,0.0f},{PMASTER,0.72f} }, + cPads); + + add("Shimmer Pad", "PAD", + { {PO1WAVE,0.25f},{PO2WAVE,0.5f},{PO1OCT,0.62f},{PO2OCT,0.5f},{PO1CT,0.53f},{PO2CT,0.47f}, + {PMIX,0.45f},{PCUTOFF,0.35f},{PRES,0.4f},{PFENV,0.5f},{PATK,0.4f},{PDEC,0.5f},{PSUS,0.6f},{PREL,0.8f}, + {PDSYNC,1.0f},{PDSTEPS,12.0f},{PDFB,0.65f},{PDMIX,0.6f},{PMASTER,0.7f} }, + cPads); + + add("Airy Pad", "PAD", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.47f},{PO2CT,0.52f},{PMIX,0.5f},{PNOISE,0.1f},{PCUTOFF,0.4f}, + {PRES,0.25f},{PFENV,0.4f},{PATK,0.35f},{PDEC,0.5f},{PSUS,0.6f},{PREL,0.6f}, + {PDSYNC,1.0f},{PDSTEPS,10.0f},{PDMIX,0.4f},{PMASTER,0.68f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.9f),K("NOISE",PNOISE,0.0f,1.0f),K("DECAY",PDEC,0.2f,0.95f),K("DELAY",PDMIX,0.0f,1.0f) }); + + add("Vapor Pad", "PAD", + { {PO1WAVE,0.75f},{PO2WAVE,0.25f},{PO1CT,0.55f},{PO2CT,0.48f},{PMIX,0.5f},{PCUTOFF,0.28f},{PRES,0.55f}, + {PFENV,0.6f},{PATK,0.5f},{PDEC,0.5f},{PSUS,0.55f},{PREL,0.85f}, + {PDSYNC,1.0f},{PDSTEPS,12.0f},{PDFB,0.6f},{PDMIX,0.55f},{PMASTER,0.7f} }, + cPads); + + // KEYS ------------------------------------------------------------ + add("DX Bell", "KEYS", + { {PO1WAVE,0.0f},{PO2WAVE,0.0f},{PO2OCT,0.83f},{PMIX,0.4f},{PCUTOFF,0.5f},{PRES,0.3f},{PFENV,0.3f}, + {PATK,0.002f},{PDEC,0.35f},{PSUS,0.1f},{PREL,0.5f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.3f},{PMASTER,0.75f} }, + cKeys); + + add("Wurly", "KEYS", + { {PO1WAVE,0.75f},{PO2WAVE,0.75f},{PO1OCT,0.5f},{PO2OCT,0.62f},{PMIX,0.5f},{PCUTOFF,0.32f},{PRES,0.45f}, + {PFENV,0.5f},{PATK,0.004f},{PDEC,0.5f},{PSUS,0.2f},{PREL,0.3f},{PDRIVE,0.5f}, + {PDSYNC,0.0f},{PMASTER,0.75f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.8f),K("DRIVE",PDRIVE,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.9f),K("D-MIX",PDMIX,0.0f,0.8f) }); + + add("Rhodes EP", "KEYS", + { {PO1WAVE,0.0f},{PO2WAVE,0.0f},{PO2OCT,0.5f},{PO1CT,0.51f},{PO2CT,0.49f},{PMIX,0.45f}, + {PCUTOFF,0.42f},{PRES,0.35f},{PFENV,0.35f},{PATK,0.003f},{PDEC,0.45f},{PSUS,0.15f},{PREL,0.35f}, + {PDRIVE,0.3f},{PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.3f},{PMASTER,0.75f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.85f),K("DRIVE",PDRIVE,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.9f),K("D-MIX",PDMIX,0.0f,0.8f) }); + + add("Clay EP", "KEYS", + { {PO1WAVE,0.25f},{PO2WAVE,0.0f},{PO1OCT,0.5f},{PO2OCT,0.62f},{PMIX,0.6f},{PCUTOFF,0.38f},{PRES,0.4f}, + {PFENV,0.45f},{PATK,0.006f},{PDEC,0.4f},{PSUS,0.15f},{PREL,0.3f},{PDRIVE,0.35f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDMIX,0.25f},{PMASTER,0.72f} }, + cKeys); + + add("Harpsi", "KEYS", + { {PO1WAVE,0.5f},{PO2WAVE,0.75f},{PO1OCT,0.5f},{PO2OCT,0.75f},{PMIX,0.5f},{PCUTOFF,0.45f},{PRES,0.4f}, + {PFENV,0.4f},{PATK,0.001f},{PDEC,0.5f},{PSUS,0.1f},{PREL,0.2f},{PDRIVE,0.3f}, + {PDSYNC,0.0f},{PMASTER,0.75f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.9f),K("RES",PRES,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.9f),K("D-MIX",PDMIX,0.0f,0.8f) }); + + add("Toy Piano", "KEYS", + { {PO1WAVE,0.75f},{PO2WAVE,0.0f},{PO1OCT,0.5f},{PO2OCT,0.75f},{PMIX,0.5f},{PCUTOFF,0.3f},{PRES,0.5f}, + {PFENV,0.5f},{PATK,0.001f},{PDEC,0.4f},{PSUS,0.2f},{PREL,0.15f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDMIX,0.25f},{PMASTER,0.72f} }, + cKeys); + + add("Warm EP", "KEYS", + { {PO1WAVE,0.0f},{PO2WAVE,0.0f},{PO1CT,0.52f},{PO2CT,0.48f},{PMIX,0.5f},{PCUTOFF,0.45f},{PRES,0.3f}, + {PFENV,0.4f},{PATK,0.004f},{PDEC,0.45f},{PSUS,0.3f},{PREL,0.3f},{PDRIVE,0.2f}, + {PDSYNC,1.0f},{PDSTEPS,10.0f},{PDMIX,0.35f},{PMASTER,0.75f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.9f),K("DRIVE",PDRIVE,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.9f),K("DELAY",PDMIX,0.0f,1.0f) }); + + // PLUCK ----------------------------------------------------------- + add("Wood Pluck", "PLUCK", + { {PO1WAVE,0.25f},{PO2WAVE,0.0f},{PO1OCT,0.5f},{PO2OCT,0.8f},{PMIX,0.6f},{PCUTOFF,0.32f},{PRES,0.55f}, + {PFENV,0.7f},{PATK,0.001f},{PDEC,0.25f},{PSUS,0.0f},{PREL,0.2f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.25f},{PMASTER,0.75f} }, + cPluck); + + add("Perc Pluck", "PLUCK", + { {PO1WAVE,0.75f},{PO2WAVE,0.75f},{PO1OCT,0.5f},{PO2OCT,0.5f},{PO1CT,0.5f},{PO2CT,0.58f},{PMIX,0.5f}, + {PCUTOFF,0.25f},{PRES,0.6f},{PFENV,0.6f},{PATK,0.001f},{PDEC,0.2f},{PSUS,0.0f},{PREL,0.2f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.3f},{PMASTER,0.78f} }, + cPluck); + + add("Guitar Pluck", "PLUCK", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.5f},{PO2CT,0.54f},{PMIX,0.5f},{PCUTOFF,0.4f},{PRES,0.45f}, + {PFENV,0.6f},{PATK,0.002f},{PDEC,0.35f},{PSUS,0.0f},{PREL,0.25f}, + {PDSYNC,0.0f},{PMASTER,0.72f} }, + cPluck); + + add("Bubble", "PLUCK", + { {PO1WAVE,0.0f},{PO2WAVE,0.25f},{PO2OCT,0.83f},{PMIX,0.4f},{PCUTOFF,0.3f},{PRES,0.6f},{PFENV,0.7f}, + {PATK,0.003f},{PDEC,0.45f},{PSUS,0.0f},{PREL,0.4f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDFB,0.5f},{PDMIX,0.4f},{PMASTER,0.75f} }, + { K("CUTOFF",PCUTOFF,0.05f,0.9f),K("DECAY",PDEC,0.1f,0.95f),K("RES",PRES,0.0f,1.0f),K("DELAY",PDMIX,0.0f,1.0f) }); + + add("Kalimba", "PLUCK", + { {PO1WAVE,0.0f},{PO2WAVE,0.0f},{PO2OCT,0.83f},{PMIX,0.35f},{PCUTOFF,0.5f},{PRES,0.35f},{PFENV,0.4f}, + {PATK,0.001f},{PDEC,0.4f},{PSUS,0.0f},{PREL,0.35f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.3f},{PMASTER,0.75f} }, + { K("RATIO",PO2OCT,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.95f),K("RES",PRES,0.0f,1.0f),K("DELAY",PDMIX,0.0f,1.0f) }); + + add("Karpling", "PLUCK", + { {PO1WAVE,0.75f},{PO2WAVE,0.75f},{PO1OCT,0.5f},{PO2OCT,0.5f},{PO1CT,0.49f},{PO2CT,0.51f},{PMIX,0.5f}, + {PCUTOFF,0.35f},{PRES,0.5f},{PFENV,0.5f},{PATK,0.001f},{PDEC,0.3f},{PSUS,0.0f},{PREL,0.2f}, + {PDRIVE,0.2f},{PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.25f},{PMASTER,0.75f} }, + cPluck); + + // FX -------------------------------------------------------------- + add("Wobble FX", "FX", + { {PO1WAVE,0.5f},{PO2WAVE,0.0f},{PO1CT,0.58f},{PMIX,0.5f},{PCUTOFF,0.12f},{PRES,0.7f},{PFENV,0.9f}, + {PATK,0.01f},{PDEC,0.5f},{PSUS,0.3f},{PREL,0.4f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDFB,0.7f},{PDMIX,0.5f},{PMASTER,0.8f} }, + cShp); + + add("Riser", "FX", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1OCT,0.3f},{PO2OCT,0.4f},{PMIX,0.5f},{PNOISE,0.5f},{PCUTOFF,0.2f}, + {PRES,0.5f},{PFENV,0.7f},{PATK,0.9f},{PDEC,0.1f},{PSUS,0.8f},{PREL,0.2f}, + {PDSYNC,0.0f},{PMASTER,0.7f} }, + { K("RISE",PATK,0.2f,1.0f),K("CUTOFF",PCUTOFF,0.05f,1.0f),K("NOISE",PNOISE,0.0f,1.0f),K("D-MIX",PDMIX,0.0f,1.0f) }); + + add("Laser", "FX", + { {PO1WAVE,0.75f},{PO2WAVE,0.0f},{PMIX,1.0f},{PCUTOFF,0.08f},{PRES,0.8f},{PFENV,0.8f}, + {PATK,0.002f},{PDEC,0.4f},{PSUS,0.0f},{PREL,0.3f}, + {PDSYNC,1.0f},{PDSTEPS,4.0f},{PDFB,0.4f},{PDMIX,0.4f},{PMASTER,0.8f} }, + cShp); + + add("Sweep", "FX", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1OCT,0.5f},{PO2OCT,0.75f},{PMIX,0.5f},{PNOISE,0.2f},{PCUTOFF,0.35f}, + {PRES,0.45f},{PFENV,0.6f},{PATK,0.2f},{PDEC,0.5f},{PSUS,0.5f},{PREL,0.6f}, + {PDSYNC,1.0f},{PDSTEPS,12.0f},{PDFB,0.6f},{PDMIX,0.5f},{PMASTER,0.72f} }, + { K("CUTOFF",PCUTOFF,0.05f,1.0f),K("F-ENV",PFENV,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.95f),K("DELAY",PDMIX,0.0f,1.0f) }); + + add("Drone FX", "FX", + { {PO1WAVE,0.75f},{PO2WAVE,0.5f},{PO1OCT,0.25f},{PO2OCT,0.28f},{PO1CT,0.54f},{PO2CT,0.55f},{PMIX,0.5f}, + {PNOISE,0.12f},{PCUTOFF,0.25f},{PRES,0.6f},{PFENV,0.2f},{PATK,0.8f},{PDEC,0.3f},{PSUS,0.8f},{PREL,0.8f}, + {PDSYNC,1.0f},{PDSTEPS,16.0f},{PDFB,0.7f},{PDMIX,0.6f},{PMASTER,0.72f} }, + cNoise); + + add("Tuned Noise", "FX", + { {PO1WAVE,0.8f},{PO2WAVE,0.8f},{PMIX,0.5f},{PNOISE,0.6f},{PCUTOFF,0.32f},{PRES,0.5f},{PFENV,0.5f}, + {PATK,0.05f},{PDEC,0.4f},{PSUS,0.4f},{PREL,0.5f}, + {PDSYNC,0.0f},{PMASTER,0.68f} }, + cNoise); + + // STAB / ARP ------------------------------------------------------ + add("Detroit Stab", "STAB", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1OCT,0.5f},{PO2OCT,0.5f},{PO1CT,0.52f},{PO2CT,0.48f},{PMIX,0.5f}, + {PCUTOFF,0.4f},{PRES,0.45f},{PFENV,0.6f},{PATK,0.004f},{PDEC,0.35f},{PSUS,0.3f},{PREL,0.25f},{PDRIVE,0.3f}, + {PDSYNC,0.0f},{PMASTER,0.8f} }, + cStab); + + add("Juno Stab", "STAB", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.5f},{PO2CT,0.5f},{PMIX,0.5f},{PCUTOFF,0.48f},{PRES,0.35f}, + {PFENV,0.5f},{PATK,0.003f},{PDEC,0.4f},{PSUS,0.2f},{PREL,0.2f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.3f},{PMASTER,0.78f} }, + cStab); + + add("Chord Stab", "STAB", + { {PO1WAVE,0.75f},{PO2WAVE,0.75f},{PO1CT,0.5f},{PO2CT,0.5f},{PMIX,0.5f},{PCUTOFF,0.35f},{PRES,0.5f}, + {PFENV,0.65f},{PATK,0.004f},{PDEC,0.3f},{PSUS,0.4f},{PREL,0.3f},{PDRIVE,0.4f}, + {PDSYNC,0.0f},{PMASTER,0.78f} }, + cStab); + + add("House Stab", "STAB", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.48f},{PO2CT,0.52f},{PMIX,0.5f},{PCUTOFF,0.45f},{PRES,0.3f}, + {PFENV,0.5f},{PATK,0.003f},{PDEC,0.3f},{PSUS,0.3f},{PREL,0.18f}, + {PDSYNC,1.0f},{PDSTEPS,8.0f},{PDMIX,0.25f},{PMASTER,0.8f} }, + cStab); + + add("Arp Energy", "ARP", + { {PO1WAVE,0.5f},{PO2WAVE,0.5f},{PO1CT,0.52f},{PO2CT,0.5f},{PMIX,0.5f},{PCUTOFF,0.45f},{PRES,0.4f}, + {PFENV,0.5f},{PATK,0.008f},{PDEC,0.3f},{PSUS,0.5f},{PREL,0.15f}, + {PDSYNC,1.0f},{PDSTEPS,4.0f},{PDFB,0.5f},{PDMIX,0.45f},{PMASTER,0.78f} }, + cCff); + + add("Trance Gate", "ARP", + { {PO1WAVE,0.75f},{PO2WAVE,0.75f},{PO1CT,0.51f},{PO2CT,0.49f},{PMIX,0.5f},{PCUTOFF,0.4f},{PRES,0.3f}, + {PFENV,0.55f},{PATK,0.01f},{PDEC,0.5f},{PSUS,0.4f},{PREL,0.12f},{PDRIVE,0.25f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDFB,0.6f},{PDMIX,0.5f},{PMASTER,0.78f} }, + { K("CUTOFF",PCUTOFF,0.05f,1.0f),K("RES",PRES,0.0f,1.0f),K("DECAY",PDEC,0.1f,0.9f),K("DELAY",PDMIX,0.0f,1.0f) }); + + add("Neon Arp", "ARP", + { {PO1WAVE,0.5f},{PO2WAVE,0.25f},{PO1OCT,0.62f},{PO2OCT,0.5f},{PO1CT,0.55f},{PO2CT,0.5f},{PMIX,0.5f}, + {PCUTOFF,0.35f},{PRES,0.55f},{PFENV,0.7f},{PATK,0.003f},{PDEC,0.4f},{PSUS,0.2f},{PREL,0.3f}, + {PDSYNC,1.0f},{PDSTEPS,4.0f},{PDFB,0.6f},{PDMIX,0.55f},{PMASTER,0.78f} }, + cCff); + + add("Retro Arp", "ARP", + { {PO1WAVE,0.75f},{PO2WAVE,0.75f},{PO1CT,0.5f},{PO2CT,0.5f},{PMIX,0.5f},{PCUTOFF,0.42f},{PRES,0.35f}, + {PFENV,0.5f},{PATK,0.004f},{PDEC,0.5f},{PSUS,0.5f},{PREL,0.1f}, + {PDSYNC,1.0f},{PDSTEPS,4.0f},{PDFB,0.55f},{PDMIX,0.4f},{PMASTER,0.78f} }, + cCff); + + add("Dark Arp", "ARP", + { {PO1WAVE,0.25f},{PO2WAVE,0.25f},{PO1CT,0.5f},{PO2CT,0.53f},{PMIX,0.5f},{PCUTOFF,0.25f},{PRES,0.6f}, + {PFENV,0.8f},{PATK,0.002f},{PDEC,0.3f},{PSUS,0.1f},{PREL,0.3f}, + {PDSYNC,1.0f},{PDSTEPS,6.0f},{PDFB,0.65f},{PDMIX,0.5f},{PMASTER,0.75f} }, + cShp); + } + + return cat; +} + +} // namespace + +const Preset* PresetStore::all() { return catalog().data; } +int PresetStore::count() { return catalog().n; } + +int PresetStore::indexOf(const char* name) +{ + const auto& cat = catalog(); + for (int i = 0; i < cat.n; ++i) + if (juce::String (cat.data[i].name) == name) + return i; + return 0; +} + +const Preset* PresetStore::get(int index) +{ + const auto& cat = catalog(); + return cat.data + juce::jlimit (0, cat.n - 1, index); +} + +String PresetStore::fullLabel(const Preset& p) +{ + return String ("[") + p.category + "] " + p.name; +} + +const Preset* PresetStore::findByIndex(int clampedIndex) { return get (clampedIndex); } + +} // namespace sm26 \ No newline at end of file diff --git a/Source/Presets.h b/Source/Presets.h new file mode 100644 index 0000000..f0c5d14 --- /dev/null +++ b/Source/Presets.h @@ -0,0 +1,23 @@ +#pragma once + +#include "SynthParams.h" + +namespace sm26 { + +class PresetStore +{ +public: + static const Preset* all(); + static int count(); + static const Preset* get(int index); + + static int indexOf(const char* name); + + static const Preset* findByIndex(int clampedIndex); + static String fullLabel(const Preset& p); + +private: + static Preset* table(); +}; + +} // namespace sm26 \ No newline at end of file diff --git a/Source/SynthEngine.cpp b/Source/SynthEngine.cpp new file mode 100644 index 0000000..91f83ee --- /dev/null +++ b/Source/SynthEngine.cpp @@ -0,0 +1,343 @@ +#include "SynthEngine.h" +#include + +namespace sm26 { + +SynthEngine::SynthEngine() +{ + auto def = defaultParams(); + for (int i = 0; i < kNumParams; ++i) + params[i] = def[i]; + + knobParams[0] = PCUTOFF; + knobParams[1] = PRES; + knobParams[2] = PFENV; + knobParams[3] = PDMIX; + + knobNames[0] = "CUTOFF"; + knobNames[1] = "RES"; + knobNames[2] = "F-ENV"; + knobNames[3] = "DELAY"; + + for (int i = 0; i < kNumKnobs; ++i) + { knobLows[i] = 0.0f; knobHighs[i] = 1.0f; } + + std::srand (12345); +} + +void SynthEngine::prepare (double sr, int mb) +{ + sampleRate = sr; + maxBlock = mb; + delay.prepare (sr); + allNotesOff(); + reset(); +} + +void SynthEngine::reset() +{ + allNotesOff(); + delay.clear(); +} + +void SynthEngine::loadPreset (const Preset& p) +{ + for (int i = 0; i < kNumParams; ++i) + params[i] = p.base[i]; + for (int i = 0; i < kNumKnobs; ++i) + { + knobParams[i] = p.ctl[i].param; + knobNames[i] = p.ctl[i].name; + knobLows[i] = p.ctl[i].lo; + knobHighs[i] = p.ctl[i].hi; + } + allNotesOff(); +} + +void SynthEngine::setPresetIndex (int i) +{ + currentPresetIndex = i; + loadPreset (*PresetStore::findByIndex (i)); +} + +void SynthEngine::setParam (int id, float n01) +{ + if (id < 0 || id >= kNumParams) return; + params[id] = juce::jlimit (0.0f, 1.0f, n01); +} + +float SynthEngine::getParam (int id) const +{ + if (id < 0 || id >= kNumParams) return 0.0f; + return params[id]; +} + +SynthEngine::KnobView SynthEngine::knobView (int knob) const +{ + if (knob < 0 || knob >= kNumKnobs) return {}; + KnobView k; + k.name = knobNames[knob]; + k.param = knobParams[knob]; + k.value01 = getParam (k.param); + return k; +} + +float SynthEngine::knobValue (int knob) const +{ + if (knob < 0 || knob >= kNumKnobs) return 0.0f; + return getParam (knobParams[knob]); +} + +const char* SynthEngine::waveNameFor (int index) const +{ + return sm26::waveName (juce::jlimit (0, 4, index)); +} + +void SynthEngine::noteOn (int note, float vel) +{ + Voice* v = findFreeOrSteal(); + resetVoice (*v, note, vel); +} + +void SynthEngine::noteOff (int note) +{ + for (auto& v : voices) + if (v.active && v.note == note) + v.envState = 3; +} + +void SynthEngine::allNotesOff() +{ + for (auto& v : voices) { v.active = false; v.env = 0.0; v.envState = 0; } + activeNotes = 0; +} + +SynthEngine::Voice* SynthEngine::findFreeOrSteal() +{ + int freeIdx = -1; + int oldest = -1; + int oldestAge = -1; + for (int i = 0; i < kNumVoices; ++i) + { + if (! voices[i].active) { freeIdx = i; break; } + if (voices[i].age > oldestAge) { oldest = i; oldestAge = voices[i].age; } + } + if (freeIdx >= 0) { activeNotes++; return &voices[freeIdx]; } + voices[oldest].envState = 3; + voices[oldest].env = 0.0f; + return &voices[oldest]; +} + +void SynthEngine::resetVoice (Voice& v, int note, float vel) +{ + v.active = true; + v.note = note; + v.velocity = vel; + v.envState = 0; + v.env = 0.0f; + v.age = 0; + v.ic1eq = 0.0; + v.ic2eq = 0.0; + + double freq = 440.0 * std::pow (2.0, (note - 69) / 12.0); + double inc = freq / sampleRate; + + double oct1 = paramReal (PO1OCT, params[PO1OCT]); + double oct2 = paramReal (PO2OCT, params[PO2OCT]); + double dt1 = paramReal (PO1CT, params[PO1CT]) / 1200.0; + double dt2 = paramReal (PO2CT, params[PO2CT]) / 1200.0; + + double f1 = freq * std::pow (2.0, oct1 + dt1); + double f2 = freq * std::pow (2.0, oct2 + dt2); + + v.inc1 = f1 / sampleRate; + v.inc2 = f2 / sampleRate; + v.glidedInc1 = v.inc1; + v.ph1 = 0.0; + v.ph2 = 0.0; +} + +float SynthEngine::oscWave (int wave, double phase) +{ + double ph = phase - std::floor(phase); + switch (wave) + { + case 0: return (float) std::sin (ph * 6.283185307179586); + case 1: return (float) (1.0 - 4.0 * std::fabs (ph - 0.5)); + case 2: return (float) (2.0 * ph - 1.0); + case 3: return (float) (ph < 0.5 ? 1.0 : -1.0); + case 4: return (float) (((double) std::rand() / (double) RAND_MAX) * 2.0 - 1.0); + default: return 0.0f; + } +} + +static float softClip (float x) +{ + return std::tanh (x * 1.5f) / std::tanh(1.5f); +} + +double SynthEngine::cutoffFor (const Voice& v, double baseCutoff, double envAmt, double envLevel) +{ + double baseHz = paramReal (PCUTOFF, (float) baseCutoff); + baseHz = std::clamp (baseHz, 20.0, 20000.0); + double mod = envAmt * envLevel * 5.0; + double hz = baseHz * std::pow (2.0, mod); + hz = std::clamp (hz, 20.0, std::min (sampleRate * 0.45, 20000.0)); + return (float) hz; +} + +void SynthEngine::process (AudioBuffer& buffer, MidiBuffer& midi, + int n, double bpm, bool transportPlaying) +{ + wasPlaying = transportPlaying; + lastBpmValue = (bpm > 10.0 && bpm < 300.0) ? bpm : 120.0; + + const float cutN01 = params[PCUTOFF]; + const float resN01 = params[PRES]; + const float fenvN01 = params[PFENV]; + const float ftypeN01= params[PFTYPE]; + const float atkN01 = params[PATK]; + const float decN01 = params[PDEC]; + const float susN01 = params[PSUS]; + const float relN01 = params[PREL]; + const float driveN01= params[PDRIVE]; + const float glideN01= params[PGLIDE]; + const float masterN01= params[PMASTER]; + const float mixN01 = params[PMIX]; + const float noiseN01= params[PNOISE]; + + const double atkSec = paramReal (PATK, atkN01); + const double decSec = paramReal (PDEC, decN01); + const double susLevel = susN01; + const double relSec = paramReal (PREL, relN01); + + const double atkCoef = 1.0 - std::exp (-1.0 / (atkSec * sampleRate + 1.0)); + const double decCoef = 1.0 - std::exp (-1.0 / (decSec * sampleRate + 1.0)); + const double relCoef = 1.0 - std::exp (-1.0 / (relSec * sampleRate + 1.0)); + + const double drive = 0.1 + driveN01 * 2.4; + const double glideCoef = glideN01 < 0.001 ? 1.0 : 1.0 - std::exp (-1.0 / (glideN01 * sampleRate + 1.0)); + const double master = masterN01; + + const double fenvScale = fenvN01 * 5.0; + (void) fenvScale; + + const float gMix = mixN01; + const float g1 = 1.0f - gMix; + const float g2 = gMix; + const float g1p = (float) std::sqrt (g1); + const float g2p = (float) std::sqrt (g2); + + const int filterType = paramFilterType (ftypeN01); + + bool delaySync = params[PDSYNC] > 0.5f; + int stepIdx = juce::jlimit (0, 15, (int) juce::roundToInt (params[PDSTEPS] * 15.0f)); + float delayMsParam = paramReal (PDTIME, params[PDTIME]); + float delaySec; + if (delaySync) + delaySec = (float) (stepsBeats()[stepIdx] * 60.0 / lastBpmValue); + else + delaySec = delayMsParam * 0.001f; + delaySec = std::clamp (delaySec, 0.001f, 17.0f); + lastDelaySamples = delaySec * sampleRate; + + const float delayFB = params[PDFB]; + const float delayMix= params[PDMIX]; + const float dryMix = 1.0f - delayMix; + const float wetMix = delayMix; + + int delayWrite = (int) delay.writePos[0]; + int delayRead = (delayWrite - (int) lastDelaySamples + delay.maxLen * 2) % delay.maxLen; + (void) delayRead; + + for (const auto& metadata : midi) + { + MidiMessage msg = metadata.getMessage(); + int sm = metadata.samplePosition; + if (sm < n && msg.isNoteOn()) + noteOn (msg.getNoteNumber(), msg.getFloatVelocity()); + else if (sm < n && msg.isNoteOff()) + noteOff (msg.getNoteNumber()); + } + + float* outL = buffer.getWritePointer (0); + float* outR = buffer.getNumChannels() > 1 ? buffer.getWritePointer (1) : nullptr; + + for (int s = 0; s < n; ++s) + { + float sumL = 0.0f, sumR = 0.0f; + + for (auto& v : voices) + { + if (! v.active) continue; + + v.age++; + v.ph1 += v.inc1; + v.ph2 += v.inc2; + if (v.ph1 > 1.0) v.ph1 -= 1.0; + if (v.ph2 > 1.0) v.ph2 -= 1.0; + + v.glidedInc1 = (float) ((1.0 - glideCoef) * v.glidedInc1 + glideCoef * v.inc1); + + switch (v.envState) + { + case 0: v.env += (1.0 - v.env) * atkCoef; if (v.env > 0.999) v.envState = 1; break; + case 1: v.env += (susLevel - v.env) * decCoef; break; + case 2: break; + case 3: v.env += (0.0 - v.env) * relCoef; if (v.env < 0.001) { v.active = false; v.env = 0.0; activeNotes--; } break; + } + + float o1 = oscWave (paramWaveIndex (params[PO1WAVE]), v.ph1); + float o2 = oscWave (paramWaveIndex (params[PO2WAVE]), v.ph2); + + float raw = g1p * o1 + g2p * o2 + noiseN01 * (float) (((double) std::rand() / (double) RAND_MAX) * 2.0 - 1.0) * 0.5f; + raw *= 0.7f; + + float cutoffHz = cutoffFor (v, cutN01, fenvN01, v.env); + float Q = 0.5f + resN01 * 14.0f; + float k = 1.0f / Q; + double f = cutoffHz / (float) sampleRate; + if (f > 0.49) f = 0.49; + double g = std::tan (juce::MathConstants::pi * f); + double a1 = 1.0 / (1.0 + g * (g + k)); + double a2 = g * a1; + double a3 = g * a2; + + double vin = raw; + double v3 = vin - v.ic2eq; + double v1 = a1 * v.ic1eq + a2 * v3; + double v2 = v.ic2eq + a2 * v.ic1eq + a3 * v3; + v.ic1eq = 2.0 * v1 - v.ic1eq; + v.ic2eq = 2.0 * v2 - v.ic2eq; + + float filtered; + switch (filterType) + { + case 0: filtered = (float) v2; break; + case 1: filtered = (float) v1; break; + case 2: filtered = (float) (vin - k * v1 - v2); break; + default: filtered = (float) v2; break; + } + + float sig = softClip (filtered * (float) drive); + sig *= (float) (v.env * v.velocity * master); + sumL += sig; + sumR += sig; + } + + float dry = sumL; + int w = delay.writePos[0]; + float delayed = delay.buf[0][w]; + float fb = dry + delayed * (float) delayFB; + fb = softClip (fb * 0.7f); + delay.buf[0][w] = fb; + delay.buf[1][w] = fb; + delay.writePos[0] = (w + 1) % delay.maxLen; + delay.writePos[1] = (w + 1) % delay.maxLen; + + outL[s] = dryMix * dry + wetMix * delayed; + if (outR) outR[s] = outL[s]; + } +} + +} // namespace sm26 \ No newline at end of file diff --git a/Source/SynthEngine.h b/Source/SynthEngine.h new file mode 100644 index 0000000..3db1c08 --- /dev/null +++ b/Source/SynthEngine.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include "SynthParams.h" +#include "Presets.h" + +namespace sm26 { + +class SynthEngine +{ +public: + static constexpr int kNumVoices = 12; + + SynthEngine(); + + void prepare (double sampleRate, int maxBlockSize); + void reset(); + + void loadPreset (const Preset& p); + int presetIndex() const { return currentPresetIndex; } + void setPresetIndex (int i); + + void setParam (int id, float n01); + float getParam (int id) const; + + struct KnobView { + const char* name; + int param; + float value01; + float lo; + float hi; + }; + KnobView knobView (int knob) const; + + float knobValue (int knob) const; + + void process (AudioBuffer& buffer, MidiBuffer& midi, int n, + double bpm, bool transportPlaying); + + int notesPlaying() const { return activeNotes; } + float lastBpm() const { return lastBpmValue; } + bool transport() const { return wasPlaying; } + int delayTimeSamples() const { return lastDelaySamples; } + + const char* waveNameFor (int index) const; + +private: + struct Voice { + bool active = false; + int note = 60; + double velocity = 0.8; + double ph1 = 0.0, ph2 = 0.0; + double inc1 = 0.0, inc2 = 0.0, glidedInc1 = 0.0; + double env = 0.0; + int envState = 0; + int age = 0; + double ic1eq = 0.0, ic2eq = 0.0; + }; + + void noteOn (int note, float vel); + void noteOff (int note); + void allNotesOff(); + Voice* findFreeOrSteal(); + void resetVoice (Voice& v, int note, float vel); + float oscWave (int wave, double phase); + float computeSingleSample (Voice& v, float& outL, float& outR, + double fad, double frel, double fsus, double fcut, + double fq, double drive, double glideCoef, double bpm); + double cutoffFor (const Voice& v, double baseCutoff, double envAmt, double env); + + double sampleRate = 48000.0; + int maxBlock = 512; + + float params[kNumParams]; + int knobParams[kNumKnobs]; + const char* knobNames[kNumKnobs]; + float knobLows[kNumKnobs]; + float knobHighs[kNumKnobs]; + + Voice voices[kNumVoices]; + int activeNotes = 0; + + struct DelayLine + { + std::vector> buf; + std::vector writePos; + int maxLen = 1; + void prepare (double sr) + { + maxLen = (int) (sr * 18.0) + 4; + buf.assign (2, std::vector (maxLen, 0.0f)); + writePos.assign (2, 0); + } + void clear() { for (auto& c : buf) std::fill (c.begin(), c.end(), 0.0f); } + }; + + DelayLine delay; + double lastDelaySamples = 0.0; + double lastBpmValue = 120.0; + bool wasPlaying = false; + + int currentPresetIndex = 0; + mutable juce::SpinLock paramLock; +}; + +} // namespace sm26 \ No newline at end of file diff --git a/Source/SynthParams.cpp b/Source/SynthParams.cpp new file mode 100644 index 0000000..66da20d --- /dev/null +++ b/Source/SynthParams.cpp @@ -0,0 +1,191 @@ +#include "SynthParams.h" + +namespace sm26 { + +static const char* const kStepLabels[] = { + "1/32", "1/16T", "1/16", "1/8T", + "1/8", "1/4T", "1/4", "1/2T", + "1/2", "3/4", "1", "3/2", + "2", "3", "4", "8", +}; + +static const float kStepBeats[] = { + 0.125f, 0.1667f, 0.25f, 0.3333f, + 0.5f, 0.6667f, 1.0f, 1.3333f, + 2.0f, 3.0f, 4.0f, 6.0f, + 8.0f, 12.0f, 16.0f, 32.0f, +}; + +const char* const* stepsLabels() { return kStepLabels; } +const float* stepsBeats() { return kStepBeats; } + +int stepsPerBeatFor(float stepIndex) +{ + const int i = juce::jlimit(0, 15, (int) juce::roundToInt(stepIndex * 15.0f)); + return juce::roundToInt(kStepBeats[i] * 4.0f); +} + +float paramReal(int id, float n01) +{ + n01 = clamp01(n01); + switch (id) + { + case PO1OCT: return -2.0f + n01 * 4.0f; + case PO1CT: return -100.0f + n01 * 200.0f; + case PO2OCT: return -2.0f + n01 * 4.0f; + case PO2CT: return -100.0f + n01 * 200.0f; + case PMIX: return n01; + case PNOISE: return n01; + case PCUTOFF: return (float) std::exp (std::log (20.0) + n01 * (std::log (20000.0) - std::log (20.0))); + case PRES: return 0.5f + n01 * 19.5f; + case PFENV: return n01; + case PATK: return (float) std::exp (std::log (0.001) + n01 * (std::log (8.0) - std::log (0.001))); + case PDEC: return (float) std::exp (std::log (0.002) + n01 * (std::log (8.0) - std::log (0.002))); + case PSUS: return n01; + case PREL: return (float) std::exp (std::log (0.002) + n01 * (std::log (8.0) - std::log (0.002))); + case PDTIME: return (float) std::exp (std::log (10.0) + n01 * (std::log (1500.0) - std::log (10.0))); + case PDFB: return n01; + case PDMIX: return n01; + case PDRIVE: return n01; + case PGLIDE: return (float) std::exp (std::log (0.001) + n01 * (std::log (2.0) - std::log (0.001))); + case PMASTER: return 0.10f + n01 * 0.70f; + default: return n01; + } +} + +float unwrapParameter(int id, float n01) +{ + n01 = clamp01(n01); + switch (id) + { + case PO1OCT: case PO2OCT: + return juce::jmax (0.0f, juce::jmin (1.0f, (n01 + 2.0f) / 4.0f)); + case PO1CT: case PO2CT: + return (n01 + 100.0f) / 200.0f; + case PCUTOFF: + return (float) ((std::log (n01) - std::log (20.0)) / (std::log (20000.0) - std::log (20.0))); + case PATK: + return (float) ((std::log (n01) - std::log (0.001)) / (std::log (8.0) - std::log (0.001))); + case PDEC: + return (float) ((std::log (n01) - std::log (0.002)) / (std::log (8.0) - std::log (0.002))); + case PREL: + return (float) ((std::log (n01) - std::log (0.002)) / (std::log (8.0) - std::log (0.002))); + case PDTIME: + return (float) ((std::log (n01) - std::log (10.0)) / (std::log (1500.0) - std::log (10.0))); + case PGLIDE: + return (float) ((std::log (n01) - std::log (0.001)) / (std::log (2.0) - std::log (0.001))); + case PMASTER: + return (n01 - 0.10f) / 0.70f; + default: + return n01; + } +} + +const char* paramShortName(int id) +{ + static const char* const names[] = { + "OSC1", "OSC2", "O1-OCT", "O1-DET", "O2-OCT", "O2-DET", + "MIX", "NOISE", "CUTOFF", "RES", "F-ENV", "F-TYPE", + "ATK", "DEC", "SUS", "REL", "D-TIME", "D-STEPS", + "SYNC", "D-FB", "D-MIX", "DRIVE","GLIDE", "MASTER", + }; + if (id >= 0 && id < kNumParams) return names[id]; + return "----"; +} + +const char* paramUnit(int id) +{ + switch (id) + { + case PO1OCT: case PO2OCT: return "oct"; + case PO1CT: case PO2CT: return "ct"; + case PCUTOFF: return "Hz"; + case PDTIME: return "ms"; + default: return ""; + } +} + +int paramWaveIndex(float n01) +{ + const int i = (int) juce::roundToInt (n01 * 4.0f); + return juce::jlimit(0, 4, i); +} + +int paramFilterType(float n01) +{ + return juce::jlimit (0, 2, (int) juce::roundToInt (n01 * 2.0f)); +} + +const char* waveName(int w) +{ + switch (w) + { + case 0: return "SINE"; + case 1: return "TRI"; + case 2: return "SAW"; + case 3: return "SQRE"; + case 4: return "NSE"; + default: return "----"; + } +} + +const char* filterTypeName(int t) +{ + switch (t) + { + case 0: return "LP"; + case 1: return "BP"; + case 2: return "HP"; + default: return "LP"; + } +} + +String formatTimeMs(float ms) +{ + if (ms >= 1000.0f) return String (ms / 1000.0, 2) + " s"; + return String ((int) juce::roundToInt (ms)) + " ms"; +} + +String formatFreq(float hz) +{ + if (hz >= 1000.0f) return String (hz / 1000.0, 2) + " kHz"; + return String ((int) juce::roundToInt (hz)) + " Hz"; +} + +String formatPercent(float v) +{ + return String ((int) juce::roundToInt (v * 100.0f)) + "%"; +} + +String formatParamReal(int id, float n01) +{ + n01 = clamp01 (n01); + switch (id) + { + case PO1WAVE: case PO2WAVE: + return waveName (paramWaveIndex (n01)); + case PO1OCT: case PO2OCT: + return String (paramReal (id, n01), 2); + case PO1CT: case PO2CT: + return String ((int) juce::roundToInt (paramReal (id, n01))) + " ct"; + case PMIX: case PNOISE: case PFENV: case PSUS: + case PDFB: case PDMIX: case PDRIVE: case PMASTER: + return formatPercent (n01); + case PCUTOFF: return formatFreq (paramReal (id, n01)); + case PRES: return String (paramReal (id, n01), 1); + case PFTYPE: return filterTypeName (paramFilterType (n01)); + case PATK: case PDEC: case PREL: + return formatTimeMs (paramReal (id, n01) * 1000.0f); + case PDTIME: return formatTimeMs (paramReal (id, n01)); + case PDSTEPS: + return stepsLabels() [juce::jlimit (0, 15, (int) juce::roundToInt (n01 * 15.0f))]; + case PDSYNC: + return n01 > 0.5f ? "SYNC" : "MS"; + case PGLIDE: + return formatTimeMs (paramReal (id, n01) * 1000.0f); + default: + return String ((int) juce::roundToInt (n01 * 100.0f)) + "%"; + } +} + +} // namespace sm26 \ No newline at end of file diff --git a/Source/SynthParams.h b/Source/SynthParams.h new file mode 100644 index 0000000..62f7617 --- /dev/null +++ b/Source/SynthParams.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +namespace sm26 { + +enum ParamId : int { + PO1WAVE = 0, PO2WAVE, PO1OCT, PO1CT, PO2OCT, PO2CT, + PMIX, PNOISE, PCUTOFF, PRES, PFENV, PFTYPE, + PATK, PDEC, PSUS, PREL, + PDTIME, PDSTEPS, PDSYNC, PDFB, PDMIX, + PDRIVE, PGLIDE, PMASTER, + kNumParams +}; + +static constexpr int kNumKnobs = 4; + +struct ControlDef { + const char* name; + int param = 0; + float lo = 0.0f; + float hi = 1.0f; +}; + +struct Preset { + const char* name; + const char* category; + std::array base; + std::array ctl; +}; + +inline std::array defaultParams() +{ + std::array d{}; + d[PO1WAVE] = 0.0f; d[PO2WAVE] = 0.0f; + d[PO1OCT] = 0.50f; d[PO1CT] = 0.50f; + d[PO2OCT] = 0.50f; d[PO2CT] = 0.54f; + d[PMIX] = 0.18f; d[PNOISE] = 0.02f; + d[PCUTOFF] = 0.68f; d[PRES] = 0.22f; + d[PFENV] = 0.38f; d[PFTYPE] = 0.0f; + d[PATK] = 0.12f; d[PDEC] = 0.48f; + d[PSUS] = 0.50f; d[PREL] = 0.55f; + d[PDTIME] = 0.30f; d[PDSTEPS]= 6.0f; + d[PDSYNC] = 1.0f; d[PDFB] = 0.42f; + d[PDMIX] = 0.42f; d[PDRIVE] = 0.16f; + d[PGLIDE] = 0.0f; d[PMASTER]= 0.70f; + return d; +} + +const char* paramShortName(int id); +const char* paramUnit(int id); +float paramReal(int id, float n01); +inline float clamp01(float v) { return v < 0.f ? 0.f : (v > 1.f ? 1.f : v); } + +int paramFilterType(float n01); + +String formatParamReal(int id, float n01); +int paramWaveIndex(float n01); +float unwrapParameter(int id, float n01); + +String formatTimeMs(float ms); +String formatFreq(float hz); +String formatPercent(float v); +const char* waveName(int w); + +const char* const* stepsLabels(); +const float* stepsBeats(); +int stepsPerBeatFor(float stepIndex); + +} // namespace sm26 \ No newline at end of file diff --git a/tools/gen_buildinfo.sh b/tools/gen_buildinfo.sh new file mode 100755 index 0000000..c448682 --- /dev/null +++ b/tools/gen_buildinfo.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -u + +SRC_DIR="$1" +OUT_FILE="$2" + +git_ver="unknown" +git_branch="unknown" + +if command -v git >/dev/null 2>&1 && [ -d "$SRC_DIR/.git" ]; then + ver=$(git -C "$SRC_DIR" describe --always --dirty --tags 2>/dev/null | tr -d '\n\r') + if [ $? -eq 0 ] && [ -n "$ver" ]; then + git_ver="$ver" + fi + + branch=$(git -C "$SRC_DIR" symbolic-ref --short -q HEAD 2>/dev/null | tr -d '\n\r') + if [ -z "$branch" ]; then + branch="detached" + fi + git_branch="$branch" +fi + +build_time=$(date '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || date '+%Y-%m-%d %H:%M:%S') + +mkdir -p "$(dirname "$OUT_FILE")" +TMP="$OUT_FILE.tmp.$$" + +cat > "$TMP" <