commit c72c0216fde40bfee8f454ec732cdc3979a19fe2 Author: Armin Date: Mon Sep 7 21:03:31 2026 +0200 Initial commit: RMX-19 rack sound module (VST3/AU) with dot-matrix LCD UI diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46ce735 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +*.vst3/ +*.component/ +.DS_Store \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..ea06178 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,106 @@ +cmake_minimum_required(VERSION 3.22) + +project(RMX19 VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "arch") + set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0" CACHE STRING "deployment target") +endif() + +# --------------------------------------------------------------------------- +# Git + compile-time build info (shown in the LCD status footer) +# --------------------------------------------------------------------------- +if(NOT DEFINED BUILD_GIT_BRANCH) + execute_process(COMMAND git -C "${CMAKE_CURRENT_SOURCE_DIR}" rev-parse --abbrev-ref HEAD + OUTPUT_VARIABLE _GIT_BRANCH OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE _OK) + if(NOT _OK EQUAL 0) + set(BUILD_GIT_BRANCH "no-vcs") + else() + set(BUILD_GIT_BRANCH "${_GIT_BRANCH}") + endif() +endif() + +if(NOT DEFINED BUILD_GIT_COMMIT) + execute_process(COMMAND git -C "${CMAKE_CURRENT_SOURCE_DIR}" rev-parse --short HEAD + OUTPUT_VARIABLE _GIT_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE _OK) + if(NOT _OK EQUAL 0) + set(BUILD_GIT_COMMIT "0000000") + else() + set(BUILD_GIT_COMMIT "${_GIT_COMMIT}") + endif() +endif() + +if(NOT DEFINED BUILD_GIT_DIRTY) + execute_process(COMMAND git -C "${CMAKE_CURRENT_SOURCE_DIR}" status --porcelain + OUTPUT_VARIABLE _GIT_DIRTY OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE _OK) + if(NOT _OK EQUAL 0) + set(BUILD_GIT_DIRTY "?") + elseif(NOT "${_GIT_DIRTY}" STREQUAL "") + set(BUILD_GIT_DIRTY "*") + else() + set(BUILD_GIT_DIRTY "") + endif() +endif() + +string(TIMESTAMP BUILD_DATE "%Y-%m-%d") +string(TIMESTAMP BUILD_TIME "%H:%M") + +configure_file(Source/BuildInfo.h.in ${CMAKE_CURRENT_BINARY_DIR}/Source/BuildInfo.h @ONLY) +message(STATUS "RMX-19 build: ${BUILD_GIT_BRANCH} ${BUILD_GIT_COMMIT}${BUILD_GIT_DIRTY} @ ${BUILD_DATE} ${BUILD_TIME}") + +# --------------------------------------------------------------------------- +# JUCE +# --------------------------------------------------------------------------- +if(EXISTS "$ENV{HOME}/JUCE/CMakeLists.txt") + set(JUCE_ROOT "$ENV{HOME}/JUCE") +elseif(EXISTS "/opt/JUCE/CMakeLists.txt") + set(JUCE_ROOT "/opt/JUCE") +endif() + +if(NOT DEFINED JUCE_ROOT OR NOT EXISTS "${JUCE_ROOT}/CMakeLists.txt") + message(FATAL_ERROR "JUCE not found. Set -DJUCE_ROOT=") +endif() + +add_subdirectory(${JUCE_ROOT} juce) + +juce_add_plugin(RMX19 + PRODUCT_NAME "RMX-19" + PLUGIN_NAME "RMX-19" + BUNDLE_ID "com.rackmodule.rmx19" + COMPANY_NAME "Rack Module Labs" + COMPANY_WEBSITE "https://localhost/rmx19" + COMPANY_EMAIL "dev@null.local" + VERSION "1.0.0" + DESCRIPTION "19 inch rack digital sound module" + PLUGIN_MANUFACTURER_CODE "Rmlb" + PLUGIN_CODE "Rmx1" + FORMATS AU VST3 + IS_SYNTH TRUE + NEEDS_MIDI_INPUT TRUE + EDITOR_WANTS_KEYBOARD_FOCUS TRUE +) + +set(RMX19_SOURCES + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/LcdPanel.cpp + Source/PresetBank.cpp + Source/SynthEngine.cpp + Source/Params.cpp +) + +target_sources(RMX19 PRIVATE ${RMX19_SOURCES}) +target_include_directories(RMX19 PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/Source" + "${CMAKE_CURRENT_BINARY_DIR}/Source") + +target_link_libraries(RMX19 PRIVATE + juce::juce_audio_utils + juce::juce_gui_basics + juce::juce_dsp) + +target_compile_definitions(RMX19 PRIVATE JUCE_MODAL_LOOPS_PERMITTED=1 + JUCE_VST3_CAN_REPLACE_VST2=0) \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..06b1692 --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +# RMX-19 build & install Makefile (macOS arm64) +# +# make configure + build VST3 and AU (Release, arm64) +# make install build, codesign (ad-hoc), install into ~/Library/Audio/Plug-Ins, +# strip the quarantine attribute from the installed bundles +# make uninstall remove installed bundles +# make clean delete the CMake build tree +# +# Note: after install, restart (or cold-start) the audio host so macOS rescans +# AudioComponents. A fresh AU/AUv3 may first appear after a host relaunch. + +BUILD_DIR ?= build +CONFIG ?= Release +PLUGIN_BASE = RMX-19 + +CMAKE := cmake +BUILD_DIR_ABSPATH := $(abspath $(BUILD_DIR)) +ARTEFACTS := $(BUILD_DIR)/RMX19_artefacts/$(CONFIG) + +VST3_SRC := $(ARTEFACTS)/VST3/$(PLUGIN_BASE).vst3 +AU_SRC := $(ARTEFACTS)/AU/$(PLUGIN_BASE).component + +VST3_DEST := $(HOME)/Library/Audio/Plug-Ins/VST3/$(PLUGIN_BASE).vst3 +AU_DEST := $(HOME)/Library/Audio/Plug-Ins/Components/$(PLUGIN_BASE).component + +USER_PLUGIN_DIRS := $(HOME)/Library/Audio/Plug-Ins/VST3 $(HOME)/Library/Audio/Plug-Ins/Components + +.EXPORT_ALL_VARIABLES: +CMAKE_BUILD_TYPE := $(CONFIG) + +.PHONY: all configure build install uninstall clean info + +all: build + +build: configure + $(CMAKE) --build $(BUILD_DIR) --config $(CONFIG) --parallel + +# Re-run cmake on every invocation: it is cheap when nothing changed and keeps +# the footer's git branch/hash and local build timestamp fresh. +configure: + $(CMAKE) -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(CONFIG) + +install: build + @codesign --force --deep --sign - "$(VST3_SRC)" >/dev/null 2>&1 || true + @codesign --force --deep --sign - "$(AU_SRC)" >/dev/null 2>&1 || true + @mkdir -p $(USER_PLUGIN_DIRS) + @cp -Rf "$(VST3_SRC)" "$(HOME)/Library/Audio/Plug-Ins/VST3/" + @cp -Rf "$(AU_SRC)" "$(HOME)/Library/Audio/Plug-Ins/Components/" + @xattr -dr com.apple.quarantine "$(VST3_DEST)" 2>/dev/null || true + @xattr -dr com.apple.quarantine "$(AU_DEST)" 2>/dev/null || true + @echo "installed:" + @echo " $(VST3_DEST)" + @echo " $(AU_DEST)" + @echo "" + @echo "audiosignature:" + @codesign -dv "$(VST3_DEST)" 2>&1 | head -1 + @echo "" + @echo "Restart your DAW / AudioHost to make the AU rescan." + +uninstall: + @rm -rf "$(VST3_DEST)" "$(AU_DEST)" + @echo "removed RMX-19 from user Audio Plug-Ins." + +clean: + @rm -rf $(BUILD_DIR) + @echo "removed $(BUILD_DIR)." + +info: + @echo "BUILD_DIR : $(BUILD_DIR_ABSPATH)" + @echo "CONFIG : $(CONFIG)" + @echo "VST3 : $(VST3_SRC)" + @echo "AU : $(AU_SRC)" \ No newline at end of file diff --git a/Source/BuildInfo.h.in b/Source/BuildInfo.h.in new file mode 100644 index 0000000..a91b9e6 --- /dev/null +++ b/Source/BuildInfo.h.in @@ -0,0 +1,9 @@ +#pragma once + +#define BUILD_GIT_BRANCH "@BUILD_GIT_BRANCH@" +#define BUILD_GIT_COMMIT "@BUILD_GIT_COMMIT@" +#define BUILD_GIT_DIRTY "@BUILD_GIT_DIRTY@" +#define BUILD_DATE "@BUILD_DATE@" +#define BUILD_TIME "@BUILD_TIME@" + +#define BUILD_VERSION "1.0.0" \ No newline at end of file diff --git a/Source/LcdPanel.cpp b/Source/LcdPanel.cpp new file mode 100644 index 0000000..104959c --- /dev/null +++ b/Source/LcdPanel.cpp @@ -0,0 +1,576 @@ +#include "LcdPanel.h" +#include "BuildInfo.h" + +// --------------------------------------------------------------------------- +// 5x7 dot-matrix font. Each glyph is 5 columns x 7 rows, bit 6 (0x40) of each +// row byte is the MSB (leftmost) dot. +// --------------------------------------------------------------------------- +namespace { + +static constexpr int NW = 5, NH = 7; + +struct Entry { int key; const juce::uint8* rows; }; + +static const juce::uint8 A_[NH] = { 0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001 }; +static const juce::uint8 B_[NH] = { 0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110 }; +static const juce::uint8 C_[NH] = { 0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110 }; +static const juce::uint8 D_[NH] = { 0b11100, 0b10010, 0b10001, 0b10001, 0b10001, 0b10010, 0b11100 }; +static const juce::uint8 E_[NH] = { 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111 }; +static const juce::uint8 F_[NH] = { 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000 }; +static const juce::uint8 G_[NH] = { 0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111 }; +static const juce::uint8 H_[NH] = { 0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001 }; +static const juce::uint8 I_[NH] = { 0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110 }; +static const juce::uint8 J_[NH] = { 0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100 }; +static const juce::uint8 K_[NH] = { 0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001 }; +static const juce::uint8 L_[NH] = { 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111 }; +static const juce::uint8 M_[NH] = { 0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001 }; +static const juce::uint8 N_[NH] = { 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001 }; +static const juce::uint8 O_[NH] = { 0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110 }; +static const juce::uint8 P_[NH] = { 0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000 }; +static const juce::uint8 Q_[NH] = { 0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101 }; +static const juce::uint8 R_[NH] = { 0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001 }; +static const juce::uint8 S_[NH] = { 0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110 }; +static const juce::uint8 T_[NH] = { 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100 }; +static const juce::uint8 U_[NH] = { 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110 }; +static const juce::uint8 V_[NH] = { 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100 }; +static const juce::uint8 W_[NH] = { 0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001 }; +static const juce::uint8 X_[NH] = { 0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001 }; +static const juce::uint8 Y_[NH] = { 0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100 }; +static const juce::uint8 Z_[NH] = { 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111 }; +static const juce::uint8 n0_[NH] = { 0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110 }; +static const juce::uint8 n1_[NH] = { 0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110 }; +static const juce::uint8 n2_[NH] = { 0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111 }; +static const juce::uint8 n3_[NH] = { 0b11110, 0b00001, 0b00001, 0b01110, 0b00001, 0b00001, 0b11110 }; +static const juce::uint8 n4_[NH] = { 0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010 }; +static const juce::uint8 n5_[NH] = { 0b11111, 0b10000, 0b10000, 0b11110, 0b00001, 0b00001, 0b11110 }; +static const juce::uint8 n6_[NH] = { 0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110 }; +static const juce::uint8 n7_[NH] = { 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000 }; +static const juce::uint8 n8_[NH] = { 0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110 }; +static const juce::uint8 n9_[NH] = { 0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100 }; +static const juce::uint8 sp_[NH] = { 0, 0, 0, 0, 0, 0, 0 }; +static const juce::uint8 ex_[NH] = { 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100 }; +static const juce::uint8 dt_[NH] = { 0, 0, 0, 0, 0, 0b00100, 0b00100 }; +static const juce::uint8 co_[NH] = { 0, 0, 0, 0, 0, 0b00110, 0b00110 }; +static const juce::uint8 cl_[NH] = { 0, 0b00100, 0b00100, 0, 0b00100, 0b00100, 0 }; +static const juce::uint8 mi_[NH] = { 0, 0, 0, 0b11111, 0, 0, 0 }; +static const juce::uint8 pl_[NH] = { 0, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0 }; +static const juce::uint8 sl_[NH] = { 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0, 0 }; +static const juce::uint8 pc_[NH] = { 0b10100, 0b10100, 0b01000, 0b10100, 0b00101, 0b00101, 0b00010 }; +static const juce::uint8 ap_[NH] = { 0b00100, 0b00100, 0b00100, 0, 0, 0, 0 }; +static const juce::uint8 lt_[NH] = { 0b00011, 0b00110, 0b01100, 0b11000, 0b01100, 0b00110, 0b00011 }; +static const juce::uint8 gt_[NH] = { 0b11000, 0b01100, 0b00110, 0b00011, 0b00110, 0b01100, 0b11000 }; +static const juce::uint8 eq_[NH] = { 0, 0, 0b11111, 0, 0b11111, 0, 0 }; +static const juce::uint8 hs_[NH] = { 0b01010, 0b01010, 0b11111, 0b01010, 0b11111, 0b01010, 0b01010 }; + +static const Entry table[] = +{ + { 'A', A_ }, { 'B', B_ }, { 'C', C_ }, { 'D', D_ }, { 'E', E_ }, + { 'F', F_ }, { 'G', G_ }, { 'H', H_ }, { 'I', I_ }, { 'J', J_ }, + { 'K', K_ }, { 'L', L_ }, { 'M', M_ }, { 'N', N_ }, { 'O', O_ }, + { 'P', P_ }, { 'Q', Q_ }, { 'R', R_ }, { 'S', S_ }, { 'T', T_ }, + { 'U', U_ }, { 'V', V_ }, { 'W', W_ }, { 'X', X_ }, { 'Y', Y_ }, + { 'Z', Z_ }, { '0', n0_ }, { '1', n1_ }, { '2', n2_ }, { '3', n3_ }, + { '4', n4_ }, { '5', n5_ }, { '6', n6_ }, { '7', n7_ }, { '8', n8_ }, + { '9', n9_ }, { ' ', sp_ }, { '.', dt_ }, { ',', co_ }, { ':', cl_ }, + { '-', mi_ }, { '+', pl_ }, { '/', sl_ }, { '%', pc_ }, { '\'', ap_ }, + { '<', lt_ }, { '>', gt_ }, { '=', eq_ }, { '#', hs_ }, { '!', ex_ }, +}; + +} // namespace + +const juce::uint8* LcdPanel::glyphFor (int key) +{ + if (key >= 'a' && key <= 'z') key = key - 'a' + 'A'; + for (const auto& e : table) + if (e.key == key) return e.rows; + return sp_; +} + +// --------------------------------------------------------------------------- + +LcdPanel::LcdPanel (RMX19AudioProcessor& p) + : proc (p) +{ + dotImage = juce::Image (juce::Image::ARGB, gridW, gridH, true); + splashUntil = juce::Time::getMillisecondCounter() + 1500; + lastMidi = proc.getLastMidiTime(); + startTimerHz (22); +} + +LcdPanel::~LcdPanel() +{ + stopTimer(); +} + +void LcdPanel::timerCallback() +{ + float l, r; proc.getMeters (l, r); + meterL = std::max (l, meterL * 0.5f); + meterR = std::max (r, meterR * 0.5f); + + if (proc.getLastMidiTime() != lastMidi) + { + lastMidi = proc.getLastMidiTime(); + powerGlow = 1.0f; + } + powerGlow = std::max (0.0f, powerGlow - 0.04f); + + if (screen == Screen::Splash && juce::Time::getMillisecondCounter() > splashUntil) + screen = Screen::Home; + + setDirty(); +} + +void LcdPanel::paint (juce::Graphics& g) +{ + const auto b = getLocalBounds(); + + // recessed bezel + g.setColour (juce::Colour (0xff0c0a08)); + g.fillRect (b); + g.setColour (juce::Colour (0xff4a453c)); + g.drawRect (b, 2); + + // inner LCD glass (black) + const float scale = (float) b.getWidth() / (float) gridW; + const int cell = 2; + const int w = gridW * cell; + const int h = gridH * cell; + const int ox = (b.getWidth() - w) / 2; + const int oy = (b.getHeight() - h) / 2 + 2; + g.setColour (juce::Colour (0xff020703)); + g.fillRect (ox, oy, w, h); + + { + juce::Graphics dg (dotImage); + dg.fillAll (juce::Colours::black); + drawContent (dg); + } + + for (int y = 0; y < gridH; ++y) + { + for (int x = 0; x < gridW; ++x) + { + const auto px = dotImage.getPixelAt (x, y); + const float lvl = px.getAlpha() / 255.0f; + if (lvl <= 0.0f) continue; + const juce::Colour c = juce::Colour::fromFloatRGBA (0.04f + 0.02f * powerGlow, + 0.96f * lvl, 0.25f * lvl, 1.0f); + g.setColour (c); + g.fillRect (ox + x * cell, oy + y * cell, cell, cell); + } + } + (void) scale; +} + +void LcdPanel::drawContent (juce::Graphics& g) +{ + switch (screen) + { + case Screen::Splash: drawSplash (g); break; + case Screen::Home: drawHome (g); break; + case Screen::Menu: drawMenu (g); break; + case Screen::Patch: drawPatchList (g); break; + case Screen::Param: drawParamScreen (g); break; + case Screen::System: drawSystem (g); break; + case Screen::Info: drawInfo (g); break; + } + drawFooter (g); +} + +void LcdPanel::px (juce::Graphics& g, int x, int y, float intensity) +{ + if (x < 0 || y < 0 || x >= gridW || y >= gridH) return; + if (intensity <= 0.0f) return; + const int a = (int) (255.0f * juce::jlimit (0.0f, 1.0f, intensity)); + g.setColour (juce::Colour::fromRGBA (255, 255, 255, a)); + g.fillRect (x, y, 1, 1); +} + +void LcdPanel::pxText (juce::Graphics& g, int x, int y, const juce::String& text, float intensity) +{ + int cx = x; + for (int i = 0; i < text.length(); ++i) + { + const int key = text[i]; + const juce::uint8* rows = glyphFor (key); + for (int row = 0; row < NH; ++row) + { + const juce::uint8 bits = rows[row]; + for (int col = 0; col < NW; ++col) + if (bits & (1u << (NW - 1 - col))) + px (g, cx + col, y + row, intensity); + } + cx += NW + 1; + } +} + +void LcdPanel::drawFilled (juce::Graphics& g, int x, int y, int w, int h, float intensity) +{ + for (int yy = 0; yy < h; ++yy) + for (int xx = 0; xx < w; ++xx) + px (g, x + xx, y + yy, intensity); +} + +void LcdPanel::drawHBar (juce::Graphics& g, int x, int y, int w, double level, float intensity) +{ + const int filled = juce::roundToInt (w * juce::jlimit (0.0, 1.0, level)); + if (filled > 0) drawFilled (g, x, y, filled, 1, intensity); +} + +void LcdPanel::drawFooter (juce::Graphics& g) +{ + juce::String branch = juce::String (BUILD_GIT_BRANCH); + if (branch.length() > 6) branch = branch.substring (0, 6); + const juce::String git = branch + "@" + juce::String (BUILD_GIT_COMMIT) + juce::String (BUILD_GIT_DIRTY); + const juce::String dt = juce::String (BUILD_DATE).substring (5) + " " + juce::String (BUILD_TIME); + + drawFilled (g, 0, 61, gridW, 1, 0.4f); + pxText (g, 2, 63, git, 0.75f); + + const auto w = dt.length() * (NW + 1) - 1; + pxText (g, gridW - w - 2, 63, dt, 0.75f); + + (void) powerGlow; +} + +void LcdPanel::drawSplash (juce::Graphics& g) +{ + pxText (g, 46, 6, "* RMX-19 *", 1.0f); + pxText (g, 30, 18, "RACK SYNTHESIZER", 0.9f); + drawFilled (g, 10, 30, gridW - 20, 1, 0.5f); + pxText (g, 44, 34, "INITIALIZING", 0.8f); + pxText (g, 34, 45, "DSP READY", 0.75f); + pxText (g, 56, 56, "REV.B", 0.6f); +} + +void LcdPanel::drawHome (juce::Graphics& g) +{ + const int patch = proc.getPatchIndex(); + const auto& p = proc.getBank().get (patch); + + const juce::String num = juce::String (patch + 1).paddedLeft ('0', 2); + pxText (g, 2, 3, num, 1.0f); + pxText (g, 4, 15, p.name, 1.0f); + pxText (g, 4, 28, p.category, 0.8f); + + drawFilled (g, 4, 42, 88, 1, 0.5f); + drawHBar (g, 4, 43, 88, proc.getActiveVoices() / 16.0, 0.9f); + + pxText (g, 116, 42, "L", 0.6f); + pxText (g, 138, 42, "R", 0.6f); + drawFilled (g, 122, 40, 1, 8, 0.5f); + drawHBar (g, 123, 41, 12, meterL, 1.0f); + drawHBar (g, 123, 44, 12, meterR, 1.0f); + drawFilled (g, 123, 40, 12, 1, 0.4f); + drawFilled (g, 123, 47, 12, 1, 0.4f); +} + +void LcdPanel::drawMenu (juce::Graphics& g) +{ + static const char* items[] = { + "PATCH SELECT", "OSC SELECT", "FILTER SETUP", "AMP ENVELOPE", + "FILT ENVELOPE", "MODULATION", "EFFECTS", "MASTER", "SYSTEM", "ABOUT" + }; + const int rows = (int) juce::numElementsInArray (items); + const int visible = 6; + const int top = juce::jmax (0, juce::jmin (cursor - 3, rows - visible)); + for (int i = 0; i < visible; ++i) + { + const int idx = top + i; + if (idx >= rows) break; + const int y = 2 + i * 10 + 1; + const bool sel = (idx == cursor); + pxText (g, 4, y, items[idx], sel ? 1.0f : 0.6f); + if (sel) + { + drawFilled (g, 1, y, 1, NH, 1.0f); + pxText (g, 142, y, ">", 1.0f); + } + } + drawFilled (g, 151, 8, 1, 48, 0.4f); + const double frac = rows > 1 ? cursor / (double) (rows - 1) : 0.0; + const int thumbY = 8 + (int) (frac * 40.0); + drawFilled (g, 150, thumbY - 2, 3, 4, 0.8f); + pxText (g, 4, 62, "UP/DN MOVE OK OPEN", 0.55f); +} + +void LcdPanel::drawPatchList (juce::Graphics& g) +{ + const int bankSize = proc.getBank().size(); + const int visible = 6; + const int top = juce::jmax (0, juce::jmin (patchCursor - 2, bankSize - visible)); + for (int i = 0; i < visible; ++i) + { + const int idx = top + i; + if (idx >= bankSize) break; + const auto& p = proc.getBank().get (idx); + const int y = 2 + i * 10 + 1; + const bool sel = (idx == patchCursor); + pxText (g, 4, y, juce::String (idx + 1).paddedLeft ('0', 2) + " " + juce::String (p.name), + sel ? 1.0f : 0.6f); + if (sel) drawFilled (g, 1, y, 1, NH, 1.0f); + if (idx == proc.getPatchIndex()) pxText (g, 138, y, "*", 0.9f); + } + drawFilled (g, 140, 8, 1, 48, 0.4f); + const double frac = bankSize > 1 ? patchCursor / (double) (bankSize - 1) : 0.0; + const int thumbY = 8 + (int) (frac * 40.0); + drawFilled (g, 139, thumbY - 2, 3, 4, 0.8f); + pxText (g, 4, 62, "UP/DN BROWSE OK LOAD", 0.55f); +} + +void LcdPanel::drawParamScreen (juce::Graphics& g) +{ + pxText (g, 2, 2, paramScreenTitle (paramScreenIdx), 1.0f); + drawFilled (g, 2, 9, gridW - 4, 1, 0.35f); + + static const prm::Id voice[] = { prm::Id::osc1Wave, prm::Id::osc2Wave, prm::Id::detune, prm::Id::oscMix }; + static const prm::Id filter[] = { prm::Id::fltType, prm::Id::fltCutoff, prm::Id::fltRes, prm::Id::fltEnv, prm::Id::fltKey }; + static const prm::Id ampEnv[] = { prm::Id::ampA, prm::Id::ampD, prm::Id::ampS, prm::Id::ampR }; + static const prm::Id fltEnv[] = { prm::Id::fenvA, prm::Id::fenvD, prm::Id::fenvS, prm::Id::fenvR }; + static const prm::Id mod[] = { prm::Id::lfoRate, prm::Id::lfoShape, prm::Id::lfoDepth, prm::Id::lfoTarget }; + static const prm::Id fx[] = { prm::Id::fxType, prm::Id::fxAmt, prm::Id::fxTime }; + static const prm::Id master[] = { prm::Id::vol, prm::Id::pan, prm::Id::poly, prm::Id::glide, prm::Id::tune }; + + const prm::Id* rows = nullptr; + int count = 0; + switch (paramScreenIdx) + { + case 0: rows = voice; count = (int) juce::numElementsInArray (voice); break; + case 1: rows = filter; count = (int) juce::numElementsInArray (filter); break; + case 2: rows = ampEnv; count = (int) juce::numElementsInArray (ampEnv); break; + case 3: rows = fltEnv; count = (int) juce::numElementsInArray (fltEnv); break; + case 4: rows = mod; count = (int) juce::numElementsInArray (mod); break; + case 5: rows = fx; count = (int) juce::numElementsInArray (fx); break; + case 6: rows = master; count = (int) juce::numElementsInArray (master); break; + default: return; + } + + const int visible = 5; + const int top = juce::jmax (0, juce::jmin (cursor - 2, count - visible)); + for (int i = 0; i < visible; ++i) + { + const int ri = top + i; + if (ri >= count) break; + const prm::Id id = rows[ri]; + const auto& d = prm::desc (id); + const float cur = proc.apvts.getRawParameterValue (d.id)->load(); + const auto* par = proc.apvts.getParameter (d.id); + const float v01 = par->convertTo0to1 (cur); + + const int y = 12 + i * 10; + const bool sel = (ri == cursor); + pxText (g, 3, y, d.shortName, 0.55f); + pxText (g, 22, y, prm::formatParam (id, cur), sel ? 1.0f : 0.7f); + drawFilled (g, 22, y + 6, 130, 1, 0.15f); + drawHBar (g, 22, y + 6, 130, v01, sel ? 1.0f : 0.5f); + if (sel) + { + drawFilled (g, 0, y, 1, NH, 1.0f); + pxText (g, 148, y, "<>", 1.0f); + } + } + pxText (g, 4, 62, "LEFT/RIGHT EDIT OK BACK", 0.55f); +} + +void LcdPanel::drawSystem (juce::Graphics& g) +{ + static const char* items[] = { "FACTORY INIT", "ALL NOTES OFF", "MIDI TEST", "BACK" }; + for (int i = 0; i < 4; ++i) + { + const int y = 2 + i * 10 + 1; + const bool sel = (i == cursor); + pxText (g, 4, y, items[i], sel ? 1.0f : 0.6f); + if (sel) drawFilled (g, 1, y, 1, NH, 1.0f); + } + if (cursor == 2 && powerGlow > 0.5f) + pxText (g, 4, 50, "MIDI RX " + juce::String (lastMidi ? "ACTIVE" : "IDLE"), 0.6f); +} + +void LcdPanel::drawInfo (juce::Graphics& g) +{ + pxText (g, 2, 3, "RMX-19 RACK SYNTH", 0.9f); + pxText (g, 2, 15, "GIT " + juce::String (BUILD_GIT_BRANCH) + " " + BUILD_GIT_COMMIT + BUILD_GIT_DIRTY, 0.85f); + pxText (g, 2, 27, "BUILT " + juce::String (BUILD_DATE) + " " + BUILD_TIME, 0.85f); + pxText (g, 2, 39, "ARM64 JUCE VERSION " + juce::String (JUCE_MAJOR_VERSION) + "." + juce::String (JUCE_MINOR_VERSION), 0.7f); + pxText (g, 2, 51, "MIDI NOTE + CC CONTROL", 0.7f); +} + +// ---- editing helpers ------------------------------------------------------ + +int LcdPanel::rowCountForScreen() const +{ + switch (screen) + { + case Screen::Menu: return 10; + case Screen::System: return 4; + default: return 0; + } +} + +int LcdPanel::paramCountForScreen (int idx) const +{ + switch (idx) + { + case 0: return 4; + case 1: return 5; + case 2: return 4; + case 3: return 4; + case 4: return 4; + case 5: return 3; + case 6: return 5; + default: return 0; + } +} + +const char* LcdPanel::paramScreenTitle (int idx) const +{ + switch (idx) + { + case 0: return "OSCILLATOR"; + case 1: return "FILTER"; + case 2: return "AMP ENVELOPE"; + case 3: return "FILT ENVELOPE"; + case 4: return "MODULATION"; + case 5: return "EFFECTS"; + case 6: return "MASTER"; + default: return ""; + } +} + +void LcdPanel::openParamScreen (int idx) +{ + paramScreenIdx = juce::jlimit (0, 6, idx); + cursor = 0; + enterScreen (Screen::Param); +} + +void LcdPanel::moveCursor (int d) +{ + if (screen == Screen::Patch) + { + patchCursor = juce::jlimit (0, proc.getBank().size() - 1, patchCursor + d); + setDirty(); + } + else if (screen == Screen::Param) + { + cursor = juce::jlimit (0, paramCountForScreen (paramScreenIdx) - 1, cursor + d); + setDirty(); + } + else if (rowCountForScreen() > 0) + { + cursor = juce::jlimit (0, rowCountForScreen() - 1, cursor + d); + setDirty(); + } +} + +void LcdPanel::stepParam (prm::Id id, int dir, bool coarse) +{ + auto* par = dynamic_cast (proc.apvts.getParameter (prm::desc (id).id)); + if (par == nullptr) return; + + const float cur = proc.apvts.getRawParameterValue (prm::desc (id).id)->load(); + const float nv = prm::changeParam (id, cur, dir, coarse); + if (nv != cur) + par->setValueNotifyingHost (par->convertTo0to1 (nv)); + setDirty(); +} + +static prm::Id paramForCursor (int screenIdx, int row) +{ + static const prm::Id voice[] = { prm::Id::osc1Wave, prm::Id::osc2Wave, prm::Id::detune, prm::Id::oscMix }; + static const prm::Id filter[] = { prm::Id::fltType, prm::Id::fltCutoff, prm::Id::fltRes, prm::Id::fltEnv, prm::Id::fltKey }; + static const prm::Id ampEnv[] = { prm::Id::ampA, prm::Id::ampD, prm::Id::ampS, prm::Id::ampR }; + static const prm::Id fltEnv[] = { prm::Id::fenvA, prm::Id::fenvD, prm::Id::fenvS, prm::Id::fenvR }; + static const prm::Id mod[] = { prm::Id::lfoRate, prm::Id::lfoShape, prm::Id::lfoDepth, prm::Id::lfoTarget }; + static const prm::Id fx[] = { prm::Id::fxType, prm::Id::fxAmt, prm::Id::fxTime }; + static const prm::Id master[] = { prm::Id::vol, prm::Id::pan, prm::Id::poly, prm::Id::glide, prm::Id::tune }; + + switch (screenIdx) + { + case 0: return voice[row]; + case 1: return filter[row]; + case 2: return ampEnv[row]; + case 3: return fltEnv[row]; + case 4: return mod[row]; + case 5: return fx[row]; + case 6: return master[row]; + default: return prm::Id::vol; + } +} + +void LcdPanel::adjustCurrent (int dir, bool coarse) +{ + if (screen == Screen::Param) + stepParam (paramForCursor (paramScreenIdx, cursor), dir, coarse); +} + +void LcdPanel::navUp() { moveCursor (-1); } +void LcdPanel::navDown() { moveCursor (1); } +void LcdPanel::navLeft() { adjustCurrent (-1, false); } +void LcdPanel::navRight() { adjustCurrent (1, false); } + +void LcdPanel::navEnter() +{ + switch (screen) + { + case Screen::Splash: enterScreen (Screen::Home); break; + case Screen::Home: enterScreen (Screen::Menu); break; + case Screen::Menu: + switch (cursor) + { + case 0: enterScreen (Screen::Patch); break; + case 1: openParamScreen (0); break; + case 2: openParamScreen (1); break; + case 3: openParamScreen (2); break; + case 4: openParamScreen (3); break; + case 5: openParamScreen (4); break; + case 6: openParamScreen (5); break; + case 7: openParamScreen (6); break; + case 8: enterScreen (Screen::System); break; + case 9: enterScreen (Screen::Info); break; + default: break; + } + break; + case Screen::Patch: + proc.selectPatch (patchCursor); + enterScreen (Screen::Home); + break; + case Screen::System: + switch (cursor) + { + case 0: proc.getBank().writeTo (0, proc.apvts); proc.selectPatch (0); break; + case 1: proc.getEngine().allNotesOff (true); break; + case 2: break; + case 3: enterScreen (Screen::Home); break; + default: break; + } + break; + case Screen::Info: + case Screen::Param: + default: + enterScreen (Screen::Home); + break; + } +} + +void LcdPanel::navExit() +{ + if (screen == Screen::Menu) + enterScreen (Screen::Home); + else if (screen != Screen::Home) + enterScreen (Screen::Menu); +} + +bool LcdPanel::keyTrap (const juce::KeyPress& k) +{ + if (k.isKeyCode (juce::KeyPress::upKey)) { navUp(); return true; } + if (k.isKeyCode (juce::KeyPress::downKey)) { navDown(); return true; } + if (k.isKeyCode (juce::KeyPress::leftKey)) { navLeft(); return true; } + if (k.isKeyCode (juce::KeyPress::rightKey)) { navRight(); return true; } + if (k.isKeyCode (juce::KeyPress::returnKey) || k.getTextCharacter() == ' ') + { navEnter(); return true; } + if (k.isKeyCode (juce::KeyPress::escapeKey)) + { navExit(); return true; } + return false; +} diff --git a/Source/LcdPanel.h b/Source/LcdPanel.h new file mode 100644 index 0000000..fba73c1 --- /dev/null +++ b/Source/LcdPanel.h @@ -0,0 +1,75 @@ +#pragma once +#include +#include "Params.h" +#include "PluginProcessor.h" + +class LcdPanel : public juce::Component, public juce::Timer +{ +public: + explicit LcdPanel (RMX19AudioProcessor& proc); + ~LcdPanel() override; + + void paint (juce::Graphics&) override; + + void navUp(); void navDown(); + void navLeft(); void navRight(); + void navEnter(); void navExit(); + + void resetToHome(); + + static constexpr int gridW = 160; // dot-matrix cell resolution + static constexpr int gridH = 64; + + bool keyTrap (const juce::KeyPress&); + +private: + enum class Screen { Splash, Home, Menu, Patch, Param, System, Info }; + + RMX19AudioProcessor& proc; + + Screen screen = Screen::Splash; + juce::uint32 splashUntil = 0; + + int cursor = 0; // menu / patch / param cursor + int patchCursor = 0; + int paramScreenIdx = 0; // which param screen is open + + float meterL = 0.0f, meterR = 0.0f; + float powerGlow = 0.0f; + juce::uint32 lastMidi = 0; + + juce::Image dotImage; + + static const juce::uint8* glyphFor (int key); + + void drawContent (juce::Graphics&); + void px (juce::Graphics&, int x, int y, float intensity); + void pxText (juce::Graphics&, int x, int y, const juce::String&, float intensity); + void drawFilled (juce::Graphics&, int x, int y, int w, int h, float intensity); + void drawHBar (juce::Graphics&, int x, int y, int w, double level, float intensity); + + void drawSplash (juce::Graphics&); + void drawHome (juce::Graphics&); + void drawMenu (juce::Graphics&); + void drawPatchList (juce::Graphics&); + void drawParamScreen (juce::Graphics&); + void drawSystem (juce::Graphics&); + void drawInfo (juce::Graphics&); + void drawFooter (juce::Graphics&); + + void stepParam (prm::Id id, int dir, bool coarse); + + void setDirty() { dotImage.clear (dotImage.getBounds()); repaint(); } + void enterScreen (Screen s) { screen = s; cursor = 0; setDirty(); } + + void timerCallback() override; + + int rowCountForScreen() const; + void moveCursor (int d); + void adjustCurrent (int dir, bool coarse); + void openParamScreen (int idx); + const char* paramScreenTitle (int idx) const; + int paramCountForScreen (int idx) const; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (LcdPanel) +}; \ No newline at end of file diff --git a/Source/Params.cpp b/Source/Params.cpp new file mode 100644 index 0000000..2296119 --- /dev/null +++ b/Source/Params.cpp @@ -0,0 +1,154 @@ +#include "Params.h" +#include "PresetBank.h" + +namespace prm { + +static const Desc table[num] = { + // id name short min max def disc fine coarse + { "osc1wave", "OSC1 WAVE", "O1WV", 0, 4, 2, true, 1, 1 }, + { "osc2wave", "OSC2 WAVE", "O2WV", 0, 4, 3, true, 1, 1 }, + { "detune", "DETUNE", "DTN", -50, 50, 8, false, 1, 6 }, + { "oscmix", "OSC BAL", "MIX", 0, 100, 52, false, 1, 8 }, + { "flttype", "FILTER TYPE", "FTYP", 0, 3, 1, true, 1, 1 }, + { "fltcut", "CUTOFF", "CUT", 20, 20000, 3400, false, 20, 400 }, + { "fltres", "RESONANCE", "RES", 0, 0.98f, 0.18f, false, 0.01f, 0.08f }, + { "fltenv", "FILT ENV", "FEN", 0, 100, 52, false, 1, 8 }, + { "fltkey", "KEY TRACK", "KEY", 0, 100, 40, false, 1, 8 }, + { "ampA", "AMP ATTACK", "AAT", 0.001f, 6, 0.008f, false, 0.001f, 0.05f }, + { "ampD", "AMP DECAY", "ADE", 0.001f, 6, 0.26f, false, 0.001f, 0.05f }, + { "ampS", "AMP SUSTAIN", "ASUS", 0, 1, 0.70f, false, 0.01f, 0.08f }, + { "ampR", "AMP RELEASE", "AREL", 0.001f, 8, 0.35f, false, 0.001f, 0.05f }, + { "fenvA", "FENV ATTACK", "FAT", 0.001f, 6, 0.02f, false, 0.001f, 0.05f }, + { "fenvD", "FENV DECAY", "FDE", 0.001f, 6, 0.16f, false, 0.001f, 0.05f }, + { "fenvS", "FENV SUSTAIN", "FSUS", 0, 1, 0.26f, false, 0.01f, 0.08f }, + { "fenvR", "FENV RELEASE", "FRE", 0.001f, 8, 0.25f, false, 0.001f, 0.05f }, + { "lforate", "LFO RATE", "LFR", 0.02f, 22, 4.2f, false, 0.02f, 0.5f }, + { "lfoshape", "LFO SHAPE", "LFS", 0, 3, 0, true, 1, 1 }, + { "lfodepth", "LFO DEPTH", "LFD", 0, 1, 0.22f, false, 0.01f, 0.08f }, + { "lfotarget", "LFO TARGET", "LFT", 0, 2, 0, true, 1, 1 }, + { "fxtype", "FX TYPE", "FXT", 0, 3, 1, true, 1, 1 }, + { "fxamt", "FX DEPTH", "FXD", 0, 1, 0.30f, false, 0.01f, 0.08f }, + { "fxtime", "FX TIME", "FXT", 0.05f, 1.2f, 0.28f, false, 0.01f, 0.12f }, + { "vol", "MASTER VOL", "VOL", 0, 1, 0.85f, false, 0.01f, 0.08f }, + { "pan", "STEREO PAN", "PAN", -1, 1, 0, false, 0.02f, 0.2f }, + { "poly", "POLYPHONY", "POLY", 1, 16, 10, true, 1, 1 }, + { "glide", "PORTAMENTO", "GLD", 0, 2, 0, false, 0.02f, 0.2f }, + { "tune", "MASTER TUNE", "TUN", -12, 12, 0, true, 1, 1 }, +}; + +const Desc& desc (Id id) { return table[static_cast(id)]; } +bool validId (int i) { return i >= 0 && i < num; } + +juce::AudioProcessorValueTreeState::ParameterLayout createLayout() +{ + juce::AudioProcessorValueTreeState::ParameterLayout layout; + + PresetBank bank; + const Patch& p0 = bank.get (0); // seed APVTS defaults with the first patch + + for (int i = 0; i < num; ++i) + { + const Desc& d = table[i]; + juce::NormalisableRange r (d.min, d.max); + if (juce::String (d.id) == "fltcut") + r = juce::NormalisableRange (d.min, d.max, 1.0f, 0.22f); + else if (d.discrete) + r = juce::NormalisableRange (d.min, d.max, 1.0f); + + layout.add (std::make_unique ( + d.id, d.name, r, p0.v[i])); + } + + layout.add (std::make_unique ("patchsel", "Patch", 0, 15, 0)); + return layout; +} + +const char* waveName (int w) +{ + switch (w) { case 0: return "SINE"; case 1: return "TRIANG"; case 2: return "SAWTOOTH"; + case 3: return "PULSE"; case 4: return "NOISE"; default: return "-----"; } +} + +const char* fltTypeName (int t) +{ + switch (t) { case 0: return "OFF "; case 1: return "LOWPASS "; case 2: return "BANDPASS "; + case 3: return "HIGH-PASS"; default: return "OFF "; } +} + +const char* lfoShapeName (int s) +{ + switch (s) { case 0: return "SINE"; case 1: return "TRIANG"; case 2: return "SQUARE"; + case 3: return "S+H "; default: return "SINE"; } +} + +const char* lfoTargetName (int t) +{ + switch (t) { case 0: return "FILTER"; case 1: return "PITCH"; case 2: return "AMP "; + default: return "FILTER"; } +} + +const char* fxTypeName (int t) +{ + switch (t) { case 0: return "OFF"; case 1: return "CHORUS"; case 2: return "DELAY "; + case 3: return "SPACE"; default: return "OFF"; } +} + +static juce::String fmtTime (float t) +{ + if (t >= 1.0f) return juce::String (t, 2) + "s"; + if (t >= 0.1f) return juce::String (t, 2) + "s"; + return juce::String ((int) (t * 1000.0f)) + "ms"; +} + +static juce::String fmtPercent (float v) { return juce::String ((int) (v * 100.0f + 0.5f)) + "%"; } + +juce::String formatParam (Id id, float v) +{ + switch (id) + { + case Id::osc1Wave: + case Id::osc2Wave: return waveName ((int) v); + case Id::detune: return juce::String ((int) v > 0 ? "+" : "") + juce::String ((int) v) + "c"; + case Id::oscMix: return juce::String ((int) (100.0f - v)) + "/" + juce::String ((int) v); + case Id::fltType: return fltTypeName ((int) v); + case Id::fltCutoff: + if (v >= 1000.0f) return juce::String (v / 1000.0f, 2) + "kHz"; + return juce::String ((int) v) + "Hz"; + case Id::fltRes: return fmtPercent (v); + case Id::fltEnv: return fmtPercent (v / 100.0f); + case Id::fltKey: return fmtPercent (v / 100.0f); + case Id::ampA: case Id::ampD: case Id::ampR: + case Id::fenvA: case Id::fenvD: case Id::fenvR: return fmtTime (v); + case Id::ampS: case Id::fenvS: return fmtPercent (v); + case Id::lfoRate: return juce::String (v, 2) + "Hz"; + case Id::lfoShape: return lfoShapeName ((int) v); + case Id::lfoDepth: return fmtPercent (v); + case Id::lfoTarget:return lfoTargetName ((int) v); + case Id::fxType: return fxTypeName ((int) v); + case Id::fxAmt: return fmtPercent (v); + case Id::fxTime: return fmtTime (v); + case Id::vol: return fmtPercent (v); + case Id::pan: + if (v < -0.02f) return "L" + juce::String ((int) (v * -100.0f)); + if (v > 0.02f) return "R" + juce::String ((int) (v * 100.0f)); + return "CTR"; + case Id::poly: return juce::String ((int) v) + " VOICE"; + case Id::glide: return v < 0.001f ? juce::String ("0") : fmtTime (v); + case Id::tune: { + const int t = (int) v; + return juce::String (t > 0 ? "+" : "") + juce::String (t) + "ST"; + } + default: return "-----"; + } +} + +float changeParam (Id id, float cur, int dir, bool coarse) +{ + const Desc& d = desc (id); + float step = coarse ? d.coarse : d.fine; + cur = juce::jlimit (d.min, d.max, cur + dir * step); + if (d.discrete) cur = (float) juce::roundToInt (cur); + return cur; +} + +} // namespace prm \ No newline at end of file diff --git a/Source/Params.h b/Source/Params.h new file mode 100644 index 0000000..ff44127 --- /dev/null +++ b/Source/Params.h @@ -0,0 +1,41 @@ +#pragma once +#include + +namespace prm { + +enum class Id : int { + osc1Wave, osc2Wave, detune, oscMix, + fltType, fltCutoff, fltRes, fltEnv, fltKey, + ampA, ampD, ampS, ampR, + fenvA, fenvD, fenvS, fenvR, + lfoRate, lfoShape, lfoDepth, lfoTarget, + fxType, fxAmt, fxTime, + vol, pan, poly, glide, tune +}; + +constexpr int num = 29; + +struct Desc { + const char* id; + const char* name; + const char* shortName; + float min, max, def; + bool discrete; + float fine, coarse; +}; + +const Desc& desc (Id id); +bool validId (int i); + +juce::AudioProcessorValueTreeState::ParameterLayout createLayout(); + +juce::String formatParam (Id id, float v); +float changeParam (Id id, float cur, int dir, bool coarse); + +const char* waveName (int w); +const char* fltTypeName (int t); +const char* lfoShapeName (int s); +const char* lfoTargetName (int t); +const char* fxTypeName (int t); + +} // namespace prm \ No newline at end of file diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..cd98a89 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,196 @@ +#include "PluginEditor.h" +#include "BuildInfo.h" +#include + +RackEditor::RackEditor (RMX19AudioProcessor& p) + : AudioProcessorEditor (p) + , proc (p) + , display (p) +{ + face = juce::Colour (0xff16130f); + + up = new NavButton (-1, "^", "UP"); + down = new NavButton (1, "v", "DOWN"); + left = new NavButton (-1, "<", "LEFT"); + right = new NavButton (1, ">", "RIGHT"); + enterB = new OkButton ("ENTER", true); + backB = new OkButton ("BACK", false); + + for (auto* b : { (juce::Button*) up, (juce::Button*) down, + (juce::Button*) left, (juce::Button*) right, + (juce::Button*) enterB, (juce::Button*) backB }) + addAndMakeVisible (b); + + up->onClick = [this] { display.navUp(); }; + down->onClick = [this] { display.navDown(); }; + left->onClick = [this] { display.navLeft(); }; + right->onClick = [this] { display.navRight(); }; + enterB->onClick = [this] { display.navEnter(); }; + backB->onClick = [this] { display.navExit(); }; + + addAndMakeVisible (display); + + setWantsKeyboardFocus (true); + setSize (656, 248); + startTimerHz (18); +} + +RackEditor::~RackEditor() +{ + stopTimer(); +} + +void RackEditor::paint (juce::Graphics& g) +{ + const auto b = getLocalBounds(); + + // --- metal rack face --- + g.setColour (face); + g.fillRect (b); + + // brushed texture + g.setColour (juce::Colour::fromRGBA (255, 255, 255, 7)); + for (int x = 0; x < b.getWidth(); x += 3) + g.fillRect (x, 0, 1, b.getHeight()); + + // rack rails (top/bottom) + g.setColour (juce::Colour (0xff221e1a)); + g.fillRect (0, 0, b.getWidth(), 4); + g.fillRect (0, b.getHeight() - 4, b.getWidth(), 4); + g.setColour (juce::Colour (0xff5a5347)); + g.fillRect (0, 4, b.getWidth(), 1); + g.fillRect (0, b.getHeight() - 5, b.getWidth(), 1); + + // screw holes + const auto screw = [&] (int x, int y) + { + g.setColour (juce::Colour (0xff2a2620)); + g.fillEllipse (x - 4, y - 4, 8, 8); + g.setColour (juce::Colour (0xff8a7f6e)); + g.drawEllipse (x - 4.5f, y - 4.5f, 9, 9, 1.0f); + g.drawLine (x - 3, y, x + 3, y, 1.0f); + }; + screw (8, 8); screw (b.getWidth() - 9, 8); + screw (8, b.getHeight() - 9); screw (b.getWidth() - 9, b.getHeight() - 9); + + // brand plate + g.setColour (juce::Colour (0xff614f3a)); + g.fillRect (12, 6, 150, 12); + g.setColour (juce::Colour (0xffc9a86f)); + g.setFont (juce::FontOptions ("Arial", 10.0f, juce::Font::bold)); + g.drawFittedText ("RMX-19 RACK SYNTH", 12, 6, 150, 12, juce::Justification::centred, 1); + + // power LED (right of LCD status) + const int ledX = getWidth() - 20, ledY = 18; + g.setColour (juce::Colour (0xff3a0d0d)); + g.fillEllipse (ledX - 3, ledY - 3, 8, 8); + g.setColour (juce::Colour::fromFloatRGBA (0.95f, 0.25f + 0.1f * powerPulse, 0.1f, 1.0f)); + g.fillEllipse (ledX - 2, ledY - 2, 6, 6); + g.setColour (juce::Colour (0xff8a7f6e)); + g.setFont (juce::FontOptions ("Arial", 8.0f, juce::Font::plain)); + g.drawFittedText ("PWR", ledX - 26, ledY - 5, 20, 10, juce::Justification::centredRight, 1); + + // rear engraving text + g.setColour (juce::Colour (0xff6e6657)); + g.setFont (juce::FontOptions ("Courier New", 8.0f, juce::Font::plain)); + const juce::String ser = "S/N 0001-1943 MIDI OUT (R) MIDI IN (R) AUDIO 24BIT/96K"; + g.drawFittedText (ser, 12, getHeight() - 16, getWidth() - 24, 11, juce::Justification::centred, 1); +} + +void RackEditor::resized() +{ + const auto b = getLocalBounds(); + + // LCD recessed module + const juce::Rectangle lcdArea (14, 24, 356, 168); + display.setBounds (lcdArea); + + // nav diamond + enter/back to the right of the LCD + const juce::Rectangle navArea (lcdArea.getRight() + 16, 26, b.getWidth() - lcdArea.getRight() - 30, 164); + + const int bw = 58, bh = 20; + const int cx = navArea.getX() + (navArea.getWidth() - bw) / 2; + const int rowY = navArea.getY(); + + up->setBounds (cx, rowY, bw, bh); + left->setBounds (navArea.getX() + 2, rowY + bh + 6, bw, bh); + down->setBounds (cx, rowY + bh + 6, bw, bh); + right->setBounds (navArea.getRight() - bw - 2, rowY + bh + 6, bw, bh); + + enterB->setBounds (navArea.getX() + 2, rowY + 2 * (bh + 6) + 4, bw, bh); + backB->setBounds (navArea.getRight() - bw - 2, rowY + 2 * (bh + 6) + 4, bw, bh); +} + +void RackEditor::timerCallback() +{ + powerPulse = 0.5f + 0.5f * std::sin (juce::Time::getMillisecondCounter() * 0.0022f); + repaint (getLocalBounds()); +} + +bool RackEditor::keyPressed (const juce::KeyPress& k) +{ + if (display.keyTrap (k)) + { + repaint(); + return true; + } + return false; +} + +// ---- buttons ------------------------------------------------------------- + +void RackEditor::NavButton::paintButton (juce::Graphics& g, bool, bool down) +{ + const auto b = getLocalBounds(); + const juce::Colour base = juce::Colour (0xff221d18); + const juce::Colour edge = juce::Colour (0xff6b5f4e); + const juce::Colour text = juce::Colour (0xfffff3d8); + + if (down) + { + g.setColour (edge.darker (0.4f)); + g.fillRect (b); + g.setColour (base.brighter (0.1f)); + g.fillRect (b.reduced (2, 2)); + } + else + { + g.setColour (base); + g.fillRect (b); + g.setColour (edge); + g.drawRect (b, 1); + } + + // glyph + g.setColour (text); + g.setFont (juce::FontOptions ("Arial", 12.0f, juce::Font::bold)); + g.drawFittedText (mark, b.withTrimmedBottom (9), juce::Justification::centred, 1); + g.setFont (juce::FontOptions ("Arial", 7.0f, juce::Font::plain)); + g.drawFittedText (getName(), b.withTrimmedTop (b.getHeight() - 8), juce::Justification::centred, 1); +} + +void RackEditor::OkButton::paintButton (juce::Graphics& g, bool over, bool down) +{ + const auto b = getLocalBounds(); + const juce::Colour base = enter ? juce::Colour (0xff263a28) : juce::Colour (0xff332a20); + const juce::Colour edge = juce::Colour (0xff8a9b6a); + + if (down) + { + g.setColour (edge.darker (0.35f)); + g.fillRect (b); + g.setColour (base.brighter (0.12f)); + g.fillRect (b.reduced (2, 2)); + } + else + { + g.setColour (base); + g.fillRect (b); + g.setColour (edge); + g.drawRect (b, 1); + } + + g.setColour (over ? juce::Colour (0xffffd977) : juce::Colour (0xffd8d2c6)); + g.setFont (juce::FontOptions ("Arial", 9.0f, juce::Font::bold)); + g.drawFittedText (getName(), b, juce::Justification::centred, 1); +} \ No newline at end of file diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..9a6628b --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,53 @@ +#pragma once +#include +#include "LcdPanel.h" +#include "PluginProcessor.h" + +class RackEditor : public juce::AudioProcessorEditor, private juce::Timer +{ +public: + explicit RackEditor (RMX19AudioProcessor&); + ~RackEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + + bool keyPressed (const juce::KeyPress&) override; + +private: + RMX19AudioProcessor& proc; + + class NavButton : public juce::Button + { + public: + NavButton (int d, const juce::String& glyph, const juce::String& label) + : juce::Button (label), dir (d), mark (glyph) + { setWantsKeyboardFocus (true); setRepeatSpeed (320, 80); } + void paintButton (juce::Graphics&, bool, bool) override; + int dir; + juce::String mark; + }; + + class OkButton : public juce::Button + { + public: + OkButton (const juce::String& label, bool isEnter) + : juce::Button (label), enter (isEnter) + { setWantsKeyboardFocus (true); setRepeatSpeed (320, 80); } + void paintButton (juce::Graphics&, bool, bool) override; + bool enter; + }; + + NavButton* up = nullptr; NavButton* down = nullptr; + NavButton* left = nullptr; NavButton* right = nullptr; + OkButton* enterB = nullptr; OkButton* backB = nullptr; + + LcdPanel display; + + juce::Colour face; + float powerPulse = 0.0f; + + void timerCallback() override; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (RackEditor) +}; \ No newline at end of file diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..8202d92 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,264 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +// --------------------------------------------------------------------------- + +class RMX19AudioProcessor::FxEngine +{ +public: + void prepare (double sampleRate, int maxSamples) + { + sr = sampleRate; + chorusL.prepare ({ sampleRate, 1u, (juce::uint32) maxSamples }); + chorusR.prepare ({ sampleRate, 1u, (juce::uint32) maxSamples }); + delL.prepare ({ sampleRate, 1u, (juce::uint32) maxSamples }); + delR.prepare ({ sampleRate, 1u, (juce::uint32) maxSamples }); + + const int maxDelay = (int) (sampleRate * 1.5); + chorusL.setMaximumDelayInSamples (maxDelay); + chorusR.setMaximumDelayInSamples (maxDelay); + delL.setMaximumDelayInSamples (maxDelay); + delR.setMaximumDelayInSamples (maxDelay); + } + + void reset() + { + chorusL.reset(); chorusR.reset(); + delL.reset(); delR.reset(); + chPh = 0.0; + } + + void process (const VParams& p, float* L, float* R, int num) + { + const int type = (int) p.fxType; + if (type == 0) return; + + const float wet = p.fxAmt; + const float dry = 1.0f - wet * 0.5f; + const float chBase = 0.008f * (float) sr; // 8 ms chorus base + const float chDepth = 0.0035f * (float) sr; // +-3.5 ms wobble + const float delSamples = (float) (p.fxTime * sr); + const float feedback = 0.40f; + + for (int s = 0; s < num; ++s) + { + const float inL = L[s], inR = R[s]; + float outL = inL * dry, outR = inR * dry; + + if (type == 1 || type == 3) + { + const double rate = type == 3 ? 0.55 : 0.38; + chPh += rate / sr; + if (chPh >= 1.0) chPh -= 1.0; + const float m = 0.5f + 0.5f * (float) std::sin (chPh * 2.0 * juce::MathConstants::pi); + + chorusL.pushSample (0, inL); + chorusR.pushSample (0, inR); + const float mL = chorusL.popSample (0, chBase + chDepth * m); + const float mR = chorusR.popSample (0, chBase + chDepth * (1.0f - m)); + outL += mL * wet * 0.7f; + outR += mR * wet * 0.7f; + } + + if (type == 2 || type == 3) + { + const float rL = delL.popSample (0, delSamples); + const float rR = delR.popSample (0, delSamples); + delL.pushSample (0, inL + rR * feedback); + delR.pushSample (0, inR + rL * feedback); + outL += rL * wet; + outR += rR * wet; + } + + L[s] = outL; + R[s] = outR; + } + } + +private: + juce::dsp::DelayLine chorusL, chorusR, delL, delR; + double sr = 48000.0; + double chPh = 0.0; +}; + +// --------------------------------------------------------------------------- + +RMX19AudioProcessor::RMX19AudioProcessor() + : AudioProcessor (BusesProperties().withOutput ("Output", juce::AudioChannelSet::stereo(), true)) + , apvts (*this, nullptr, "Params", createParams()) +{ +} + +RMX19AudioProcessor::~RMX19AudioProcessor() {} + +juce::AudioProcessorValueTreeState::ParameterLayout RMX19AudioProcessor::createParams() +{ + return prm::createLayout(); +} + +const juce::String RMX19AudioProcessor::getName() const { return "RMX-19"; } + +void RMX19AudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + engine.setSampleRate (sampleRate); + engine.allNotesOff (true); + fx = std::make_unique(); + fx->prepare (sampleRate, samplesPerBlock); +} + +void RMX19AudioProcessor::releaseResources() +{ + engine.allNotesOff (true); + if (fx) fx->reset(); +} + +void RMX19AudioProcessor::processMidi (juce::MidiBuffer& midi, const VParams& p) +{ + (void) p; + constexpr double bendSemis = 2.0; + + for (const auto meta : midi) + { + const auto msg = meta.getMessage(); + lastMidi = juce::Time::getMillisecondCounter(); + + if (msg.isNoteOn()) + { + engine.noteOn (msg.getNoteNumber(), msg.getFloatVelocity()); + } + else if (msg.isNoteOff()) + { + engine.noteOff (msg.getNoteNumber()); + } + else if (msg.isPitchWheel()) + { + engine.pitchBend ((float) ((msg.getPitchWheelValue() - 8192) / 8192.0 * bendSemis * 100.0)); + } + else if (msg.isController()) + { + const int cc = msg.getControllerNumber(); + const float v = msg.getControllerValue() / 127.0f; + switch (cc) + { + case 64: engine.sustainPedal (msg.getControllerValue() >= 64); break; + case 1: engine.modWheel (v); break; + case 7: if (auto* par = apvts.getParameter ("vol")) par->setValueNotifyingHost (v); break; + case 10: if (auto* par = apvts.getParameter ("pan")) par->setValueNotifyingHost (v * 2.0f - 1.0f); break; + case 71: if (auto* par = apvts.getParameter ("fltres")) par->setValueNotifyingHost (v); break; + case 72: if (auto* par = apvts.getParameter ("ampR")) par->setValueNotifyingHost (v); break; + case 73: if (auto* par = apvts.getParameter ("ampA")) par->setValueNotifyingHost (v); break; + case 74: if (auto* par = apvts.getParameter ("fltcut")) par->setValueNotifyingHost (v); break; + case 76: if (auto* par = apvts.getParameter ("lforate")) par->setValueNotifyingHost (v); break; + case 77: if (auto* par = apvts.getParameter ("lfodepth"))par->setValueNotifyingHost (v); break; + case 91: if (auto* par = apvts.getParameter ("fxamt")) par->setValueNotifyingHost (v); break; + default: break; + } + } + } +} + +void RMX19AudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + juce::ScopedNoDenormals noDenormals; + + const juce::uint32 start = juce::Time::getMillisecondCounter(); + const int numSamples = buffer.getNumSamples(); + const int numChannels = buffer.getNumChannels(); + if (numSamples == 0) return; + + VParams p; + p.update (apvts); + + // patch follow + const int sel = (int) apvts.getRawParameterValue ("patchsel")->load(); + if (sel != lastPatchParam && sel >= 0 && sel < bank.size()) + { + lastPatchParam = sel; + patchIndex = sel; + bank.writeTo (sel, apvts); + } + + processMidi (midi, p); + + const int chL = 0, chR = 1; + float* L = buffer.getNumChannels() > 0 ? buffer.getWritePointer (juce::jmin (chL, numChannels - 1)) : nullptr; + float* R = buffer.getNumChannels() > 1 ? buffer.getWritePointer (juce::jmin (chR, numChannels - 1)) : nullptr; + + for (int c = 0; c < numChannels; ++c) + buffer.clear (c, 0, numSamples); + + if (L != nullptr && R != nullptr) + { + engine.render (p, L, R, numSamples); + if (fx) fx->process (p, L, R, numSamples); + if (p.vol != 1.0f) buffer.applyGain (p.vol); + } + else if (L != nullptr) + { + juce::AudioBuffer dummy (1, numSamples); + dummy.clear(); + float* dL = dummy.getWritePointer (0); + float* dR = dummy.getWritePointer (0); + engine.render (p, dL, dR, numSamples); + for (int c = 0; c < numChannels; ++c) + buffer.copyFrom (c, 0, dummy, 0, 0, numSamples); + buffer.applyGain (p.vol); + } + + cpu = 0.7f * cpu + 0.3f * (float) (juce::Time::getMillisecondCounter() - start); +} + +void RMX19AudioProcessor::selectPatch (int i) +{ + i = juce::jlimit (0, bank.size() - 1, i); + patchIndex = i; + lastPatchParam = i; + if (auto* par = apvts.getParameter ("patchsel")) + par->setValueNotifyingHost (par->convertTo0to1 ((float) i)); + bank.writeTo (i, apvts); +} + +int RMX19AudioProcessor::getNumPrograms() { return bank.size(); } +int RMX19AudioProcessor::getCurrentProgram() { return patchIndex; } + +void RMX19AudioProcessor::setCurrentProgram (int index) { selectPatch (index); } + +const juce::String RMX19AudioProcessor::getProgramName (int index) +{ + if (index >= 0 && index < bank.size()) return bank.get (index).name; + return {}; +} + +void RMX19AudioProcessor::changeProgramName (int, const juce::String&) {} + +void RMX19AudioProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + auto state = apvts.copyState(); + std::unique_ptr xml (state.createXml()); + copyXmlToBinary (*xml, destData); +} + +void RMX19AudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xml (getXmlFromBinary (data, sizeInBytes)); + if (xml == nullptr) return; + + apvts.replaceState (juce::ValueTree::fromXml (*xml)); + + const int sel = juce::jlimit (0, bank.size() - 1, + (int) apvts.getRawParameterValue ("patchsel")->load()); + lastPatchParam = sel; + patchIndex = sel; +} + +juce::AudioProcessorEditor* RMX19AudioProcessor::createEditor() +{ + return new RackEditor (*this); +} + +bool RMX19AudioProcessor::hasEditor() const { return true; } + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new RMX19AudioProcessor(); +} \ No newline at end of file diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..be67ce1 --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,64 @@ +#pragma once +#include +#include "Params.h" +#include "PresetBank.h" +#include "SynthEngine.h" +#include + +class RMX19AudioProcessor : public juce::AudioProcessor +{ +public: + RMX19AudioProcessor(); + ~RMX19AudioProcessor() 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; + + const juce::String getName() const override; + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return false; } + double getTailLengthSeconds() const override { return 3.0; } + + int getNumPrograms() override; + int getCurrentProgram() override; + void setCurrentProgram (int) override; + const juce::String getProgramName (int) override; + void changeProgramName (int, const juce::String&) override; + + void getStateInformation (juce::MemoryBlock&) override; + void setStateInformation (const void*, int) override; + + juce::AudioProcessorValueTreeState apvts; + + PresetBank& getBank() { return bank; } + SynthEngine& getEngine() { return engine; } + int getPatchIndex() const noexcept { return patchIndex; } + void selectPatch (int i); + + void getMeters (float& l, float& r) const noexcept { engine.getMeters (l, r); } + int getActiveVoices() const noexcept { return engine.activeVoices(); } + juce::uint32 getLastMidiTime() const noexcept { return lastMidi; } + float getCpu() const noexcept { return cpu; } + +private: + void processMidi (juce::MidiBuffer&, const VParams&); + + juce::AudioProcessorValueTreeState::ParameterLayout createParams(); + + class FxEngine; + std::unique_ptr fx; + + PresetBank bank; + SynthEngine engine; + int patchIndex = 0; + int lastPatchParam = 0; + juce::uint32 lastMidi = 0; + float cpu = 0.0f; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (RMX19AudioProcessor) +}; \ No newline at end of file diff --git a/Source/PresetBank.cpp b/Source/PresetBank.cpp new file mode 100644 index 0000000..9391726 --- /dev/null +++ b/Source/PresetBank.cpp @@ -0,0 +1,151 @@ +#include "PresetBank.h" + +// Oscillator wave indices (see prm::waveName) +enum { O_SIN = 0, O_TRI, O_SAW, O_PUL, O_NOI }; + +// Parameter order matches the prm::desc table: +// 0 osc1Wave 1 osc2Wave 2 detune 3 oscMix +// 4 fltType 5 fltCutoff 6 fltRes 7 fltEnv 8 fltKey +// 9 ampA 10 ampD 11 ampS 12 ampR +// 13 fenvA 14 fenvD 15 fenvS 16 fenvR +// 17 lfoRate 18 lfoShape 19 lfoDepth 20 lfoTarget +// 21 fxType 22 fxAmt 23 fxTime +// 24 vol 25 pan 26 poly 27 glide 28 tune + +static const Patch defs[] = { + // 1 Warm Pad --------------------------------------------------------- + { "Warm Pad", "POLY PAD", { O_SAW, O_SAW, 8, 55, 1, 2600, 0.20f, 60, 35, + 0.60f, 1.6f, 0.80f, 1.20f, + 0.40f, 0.9f, 0.35f, 0.5f, + 4.0f, 0, 0.18f, 0, + 1, 0.32f, 0.30f, + 0.82f, 0.0f, 12, 0.10f, 0 } }, + // 2 Glass Pad -------------------------------------------------------- + { "Glass Pad", "POLY PAD", { O_TRI, O_TRI, 12, 60, 2, 1400, 0.55f, 75, 30, + 0.80f, 1.8f, 0.90f, 1.60f, + 0.50f, 1.2f, 0.40f, 0.8f, + 5.2f, 0, 0.16f, 2, + 3, 0.38f, 0.40f, + 0.80f, 0.0f, 12, 0.10f, 0 } }, + // 3 Dream Pad -------------------------------------------------------- + { "Dream Pad", "POLY PAD", { O_SAW, O_PUL, -7, 50, 1, 3000, 0.25f, 70, 40, + 1.10f, 2.4f, 0.85f, 1.80f, + 0.60f, 1.4f, 0.30f, 0.9f, + 0.35f, 1, 0.20f, 0, + 1, 0.40f, 0.50f, + 0.80f, 0.0f, 12, 0.15f, 0 } }, + // 4 Movie Strings ---------------------------------------------------- + { "Movie Strings", "POLY PAD", { O_SAW, O_SAW, 6, 45, 1, 2200, 0.15f, 65, 55, + 0.50f, 1.2f, 0.80f, 0.70f, + 0.40f, 0.8f, 0.35f, 0.5f, + 3.1f, 0, 0.10f, 0, + 3, 0.28f, 0.45f, + 0.82f, 0.0f, 12, 0.08f, 0 } }, + // 5 Electric Piano --------------------------------------------------- + { "Electric Piano", "KEY", { O_PUL, O_TRI, 5, 62, 1, 2800, 0.25f, 45, 40, + 0.004f, 1.4f, 0.30f, 0.40f, + 0.004f, 0.2f, 0.10f, 0.3f, + 6.0f, 0, 0.05f, 2, + 1, 0.35f, 0.30f, + 0.80f, 0.0f, 12, 0.0f, 0 } }, + // 6 Acoustic Piano --------------------------------------------------- + { "Acoustic Piano", "KEY", { O_SAW, O_TRI, 3, 55, 1, 5200, 0.18f, 30, 60, + 0.003f, 2.2f, 0.25f, 0.50f, + 0.002f, 0.25f, 0.05f, 0.35f, + 4.0f, 0, 0.03f, 2, + 1, 0.22f, 0.30f, + 0.80f, 0.0f, 12, 0.0f, 0 } }, + // 7 Toy Piano -------------------------------------------------------- + { "Toy Piano", "KEY", { O_PUL, O_PUL, 0, 70, 2, 2100, 0.50f, 30, 45, + 0.002f, 0.35f, 0.0f, 0.18f, + 0.002f, 0.05f, 0.02f, 0.08f, + 5.0f, 0, 0.03f, 2, + 2, 0.18f, 0.17f, + 0.78f, 0.0f, 12, 0.0f, 0 } }, + // 8 Clavinet 70 ------------------------------------------------------ + { "Clavinet 70", "KEY", { O_PUL, O_PUL, 0, 40, 2, 1500, 0.40f, 60, 30, + 0.002f, 0.50f, 0.45f, 0.12f, + 0.001f, 0.05f, 0.05f, 0.10f, + 4.0f, 0, 0.05f, 0, + 1, 0.30f, 0.28f, + 0.84f, 0.0f, 12, 0.0f, 0 } }, + // 9 Organ Drawbar ---------------------------------------------------- + { "Organ Drawbar", "KEY", { O_SAW, O_SAW, 0, 50, 1, 10000, 0.10f, 20, 80, + 0.010f, 0.10f, 0.98f, 0.10f, + 0.010f, 0.10f, 0.50f, 0.10f, + 8.0f, 3, 0.03f, 1, + 1, 0.20f, 0.28f, + 0.82f, 0.0f, 8, 0.0f, 0 } }, + // 10 FM E.Piano ------------------------------------------------------ + { "FM E.Piano", "KEY", { O_SIN, O_TRI, 0, 30, 1, 4000, 0.12f, 50, 50, + 0.002f, 1.0f, 0.50f, 0.30f, + 0.002f, 0.20f, 0.10f, 0.25f, + 4.5f, 0, 0.04f, 2, + 1, 0.40f, 0.30f, + 0.78f, 0.0f, 12, 0.0f, 0 } }, + // 11 Analog Bass ----------------------------------------------------- + { "Analog Bass", "BASS", { O_PUL, O_SIN, 0, 80, 1, 700, 0.35f, 35, 10, + 0.002f, 0.30f, 0.82f, 0.12f, + 0.002f, 0.10f, 0.15f, 0.10f, + 3.0f, 0, 0.05f, 0, + 0, 0.0f, 0.30f, + 0.90f, 0.0f, 1, 0.04f, 0 } }, + // 12 Sub Rez Bass ---------------------------------------------------- + { "Sub Rez Bass", "BASS", { O_SAW, O_PUL, -4, 45, 1, 520, 0.70f, 55, 15, + 0.004f, 0.40f, 0.70f, 0.10f, + 0.004f, 0.15f, 0.25f, 0.10f, + 2.4f, 3, 0.30f, 0, + 0, 0.0f, 0.30f, + 0.90f, 0.0f, 2, 0.03f, 0 } }, + // 13 Saw Lead -------------------------------------------------------- + { "Saw Lead", "LEAD", { O_SAW, O_SAW, 14, 50, 1, 3800, 0.25f, 30, 60, + 0.005f, 0.30f, 0.80f, 0.20f, + 0.005f, 0.15f, 0.30f, 0.15f, + 5.4f, 0, 0.14f, 1, + 0, 0.0f, 0.28f, + 0.86f, 0.0f, 4, 0.06f, 0 } }, + // 14 Mr. Square ------------------------------------------------------ + { "Mr. Square", "LEAD", { O_PUL, O_PUL, 7, 60, 1, 2400, 0.22f, 20, 70, + 0.003f, 0.25f, 0.72f, 0.18f, + 0.003f, 0.12f, 0.25f, 0.12f, + 6.6f, 0, 0.10f, 1, + 0, 0.0f, 0.25f, + 0.84f, 0.0f, 4, 0.05f, 0 } }, + // 15 Choir Layered --------------------------------------------------- + { "Choir Layered", "POLY PAD",{ O_SAW, O_PUL, 9, 48, 1, 2600, 0.14f, 62, 45, + 0.35f, 0.80f, 0.85f, 1.10f, + 0.30f, 0.60f, 0.35f, 0.60f, + 3.4f, 1, 0.12f, 0, + 1, 0.38f, 0.42f, + 0.82f, 0.0f, 12, 0.08f, 0 } }, + // 16 8-bit Chiptune -------------------------------------------------- + { "8bit Chiptune", "LEAD", { O_PUL, O_PUL, 0, 30, 1, 9000, 0.10f, 10, 70, + 0.003f, 0.12f, 0.90f, 0.12f, + 0.003f, 0.06f, 0.10f, 0.08f, + 9.0f, 0, 0.05f, 0, + 0, 0.0f, 0.20f, + 0.80f, 0.0f, 6, 0.0f, 0 } }, +}; + +PresetBank::PresetBank() +{ + patches.assign (defs, defs + juce::numElementsInArray (defs)); +} + +int PresetBank::findByName (const juce::String& name) const +{ + for (int i = 0; i < patches.size(); ++i) + if (juce::String (patches[i].name) == name) + return i; + return -1; +} + +void PresetBank::writeTo (int i, juce::AudioProcessorValueTreeState& apvts) const +{ + const int n = (int) patches.size(); + i = juce::jlimit (0, n - 1, i); + const Patch& p = patches[i]; + for (int k = 0; k < prm::num; ++k) + if (auto* par = apvts.getParameter (prm::desc (static_cast (k)).id)) + par->setValueNotifyingHost (par->convertTo0to1 (p.v[k])); +} \ No newline at end of file diff --git a/Source/PresetBank.h b/Source/PresetBank.h new file mode 100644 index 0000000..84cc295 --- /dev/null +++ b/Source/PresetBank.h @@ -0,0 +1,26 @@ +#pragma once +#include "Params.h" +#include +#include + +struct Patch +{ + const char* name; + const char* category; + float v[prm::num]; +}; + +class PresetBank +{ +public: + PresetBank(); + + int size() const noexcept { return (int) patches.size(); } + const Patch& get (int i) const { return patches[(size_t) i]; } + int findByName (const juce::String&) const; + + void writeTo (int i, juce::AudioProcessorValueTreeState&) const; + +private: + std::vector patches; +}; \ No newline at end of file diff --git a/Source/SynthEngine.cpp b/Source/SynthEngine.cpp new file mode 100644 index 0000000..9982d76 --- /dev/null +++ b/Source/SynthEngine.cpp @@ -0,0 +1,235 @@ +#include "SynthEngine.h" + +SynthEngine::SynthEngine() { rng.setSeedRandomly(); } +SynthEngine::~SynthEngine() {} + +void SynthEngine::setSampleRate (double s) +{ + sr = s; +} + +float SynthEngine::osc (int wave, double ph) +{ + switch (wave) + { + case 0: return (float) std::sin (ph * 2.0 * juce::MathConstants::pi); + case 1: return (float) (ph < 0.5 ? 4.0 * ph - 1.0 : 3.0 - 4.0 * ph); + case 2: return (float) (1.0 - 2.0 * ph); + case 3: return (float) (ph < 0.5 ? -1.0 : 1.0); + case 4: return rng.nextFloat() * 2.0f - 1.0f; + default: return 0.0f; + } +} + +void SynthEngine::allNotesOff (bool immediate) +{ + for (auto& v : voices) + { + if (v.active) + { + if (immediate) v.active = false; + else v.amp.noteOff(); + } + } + sustainedNotes.clear(); + if (immediate) active = 0; +} + +void SynthEngine::sustainPedal (bool down) +{ + pedal = down; + if (! down) + { + for (auto n : sustainedNotes) + for (auto& v : voices) + if (v.active && v.note == n) + v.amp.noteOff(); + sustainedNotes.clear(); + } +} + +void SynthEngine::pitchBend (float cents) { bendCents = cents; } +void SynthEngine::modWheel (float v) { mod = juce::jlimit (0.0f, 1.0f, v); } + +SynthEngine::Voice* SynthEngine::acquireVoice() +{ + const int lim = juce::jlimit (1, maxVoice, polyLimit); + for (int i = 0; i < lim; ++i) + if (! voices[i].active) + return &voices[i]; + + stealIdx = (stealIdx + 1) % lim; + return &voices[stealIdx]; +} + +void SynthEngine::noteOn (int midiNote, float velocity) +{ + const float noteFreq = (float) (440.0 * std::pow (2.0, (midiNote - 69.0) / 12.0)); + Voice* v = acquireVoice(); + v->active = true; + v->note = midiNote; + v->vel = 1.0f - std::pow (1.0f - velocity, 1.35f); + v->curFreq = (lastGlide > 0.001f && lastNoteFreq > 0.0f) ? lastNoteFreq : noteFreq; + v->targetFreq = noteFreq; + v->ph1 = rng.nextDouble() * 0.5; + v->ph2 = rng.nextDouble() * 0.5; + v->lfoPh = rng.nextDouble(); + v->lfoIdx = 0; + v->lfoSH = rng.nextFloat() * 2.0f - 1.0f; + v->lp = v->bp = 0.0f; + v->outSmooth = 0.0f; + + const float p = juce::jlimit (-1.0f, 1.0f, lastPan + (rng.nextFloat() - 0.5f) * 0.1f); + const float ang = (p + 1.0f) * juce::MathConstants::pi * 0.25f; + const float mag = 0.7071f + 0.25f * rng.nextFloat(); + v->panL = std::cos (ang) * mag; + v->panR = std::sin (ang) * mag; + + v->amp.reset(); v->flt.reset(); + v->amp.noteOn(); v->flt.noteOn(); + lastNoteFreq = noteFreq; + ++active; +} + +void SynthEngine::noteOff (int midiNote) +{ + if (pedal) + { + if (! sustainedNotes.contains (midiNote)) + sustainedNotes.add (midiNote); + return; + } + for (auto& v : voices) + if (v.active && v.note == midiNote) + v.amp.noteOff(); +} + +void SynthEngine::render (const VParams& p, float* left, float* right, int num) +{ + float peakL = metL, peakR = metR; + metL = metR = 0.0f; + + for (auto& v : voices) + { + v.amp.setRates (p.ampA, p.ampD, p.ampS, p.ampR, sr); + v.flt.setRates (p.fA, p.fD, p.fS, p.fR, sr); + } + lastGlide = p.glide; + lastPan = p.pan; + polyLimit = juce::jlimit (1, maxVoice, (int) p.poly); + + const float glideRate = p.glide <= 0.001f ? 1.0f + : 1.0f - std::exp (-1.0f / (p.glide * (float) sr)); + const float detuneRatio = std::pow (2.0f, p.detune / 1200.0f); + const float tuneRatio = std::pow (2.0f, p.tune / 12.0f); + const float maxFc = 18000.0f, minFc = 30.0f; + + for (int s = 0; s < num; ++s) + { + float mixL = 0.0f, mixR = 0.0f; + + for (auto& v : voices) + { + if (! v.active) + continue; + + if (p.glide > 0.001f) + v.curFreq += (v.targetFreq - v.curFreq) * glideRate; + else + v.curFreq = v.targetFreq; + + // LFO + v.lfoPh += (double) p.lfoRate / sr; + while (v.lfoPh >= 1.0) + { + v.lfoPh -= 1.0; + v.lfoIdx++; + v.lfoSH = rng.nextFloat() * 2.0f - 1.0f; + } + + float lfo = 0.0f; + switch ((int) p.lfoShape) + { + case 0: lfo = (float) std::sin (v.lfoPh * 2.0 * juce::MathConstants::pi); break; + case 1: lfo = (float) (v.lfoPh < 0.5 ? 4.0 * v.lfoPh - 1.0 : 3.0 - 4.0 * v.lfoPh); break; + case 2: lfo = v.lfoPh < 0.5 ? -1.0f : 1.0f; break; + case 3: lfo = v.lfoSH; break; + default: lfo = 0.0f; break; + } + + // frequency, vibrato, bend, tune + float freq = v.curFreq * tuneRatio; + if ((int) p.lfoTarget == 1) + freq *= std::pow (2.0f, lfo * p.lfoDepth * 1.6f / 12.0f); + if (bendSmooth != bendCents) + bendSmooth += (bendCents - bendSmooth) * 0.08f; + freq *= std::pow (2.0f, bendSmooth / 1200.0f); + + const float d1 = (float) (freq / sr); + const float d2 = d1 * detuneRatio; + + v.ph1 += d1; if (v.ph1 >= 1.0) v.ph1 -= 1.0; + v.ph2 += d2; if (v.ph2 >= 1.0) v.ph2 -= 1.0; + + const float o1 = osc ((int) p.osc1w, v.ph1); + const float o2 = osc ((int) p.osc2w, v.ph2); + const float mixb = p.oscMix * 0.01f; + float sig = o1 * (1.0f - mixb) + o2 * mixb; + + const float fe = v.flt.next(); + + // cutoff with keytrack + filter envelope + LFO + mod wheel + float fc = p.fltCut; + fc *= std::pow (freq / 261.6256f, p.fltKey * 0.01f); + fc *= std::pow (maxFc / fc, p.fltEnv * 0.01f * fe); + if ((int) p.lfoTarget == 0) + fc *= std::pow (2.0f, lfo * p.lfoDepth * 10.0f / 12.0f); + fc *= std::pow (2.0f, mod); + fc = std::min (maxFc, std::max (minFc, fc)); + + // SVF + const float f1 = 2.0f * std::sin (juce::MathConstants::pi * fc / (float) sr); + const float q1 = 1.0f / (0.5f + p.fltRes * 3.5f); + v.f1c = f1; + const float hp = sig - v.lp - q1 * v.bp; + v.bp += f1 * hp; + v.lp += f1 * v.bp; + + float filtered = sig; + switch ((int) p.fltType) + { + case 1: filtered = v.lp; break; + case 2: filtered = v.bp; break; + case 3: filtered = hp; break; + default: break; + } + + // amplitude envelope (+ tremolo if LFO targets amp) + const float ae = v.amp.next(); + float ampMod = 1.0f; + if ((int) p.lfoTarget == 2) + ampMod = 1.0f - p.lfoDepth * 0.5f * (1.0f + lfo); + + v.outSmooth += (filtered - v.outSmooth) * 0.12f; + const float o = v.outSmooth * (ae * ampMod) * v.vel; + + mixL += o * v.panL; + mixR += o * v.panR; + + if (v.amp.getState() == Env::Off) + { + v.active = false; + if (active > 0) --active; + } + } + + peakL = std::max (peakL, std::abs (mixL)); + peakR = std::max (peakR, std::abs (mixR)); + + left[s] += mixL; + right[s] += mixR; + } + + metL = peakL; + metR = peakR; +} \ No newline at end of file diff --git a/Source/SynthEngine.h b/Source/SynthEngine.h new file mode 100644 index 0000000..01651ca --- /dev/null +++ b/Source/SynthEngine.h @@ -0,0 +1,144 @@ +#pragma once +#include +#include "Params.h" + +struct VParams +{ + float osc1w = 2, osc2w = 3, detune = 8, oscMix = 52.f; + float fltType = 1, fltCut = 3400.f, fltRes = 0.18f, fltEnv = 52.f, fltKey = 40.f; + float ampA = 0.008f, ampD = 0.26f, ampS = 0.70f, ampR = 0.35f; + float fA = 0.02f, fD = 0.16f, fS = 0.26f, fR = 0.25f; + float lfoRate = 4.2f, lfoShape = 0, lfoDepth = 0.22f, lfoTarget = 0; + float fxType = 1, fxAmt = 0.30f, fxTime = 0.28f; + float vol = 0.85f, pan = 0.0f, poly = 10.f, glide = 0.f, tune = 0.f; + + void update (const juce::AudioProcessorValueTreeState& apvts) + { + auto get = [&] (const char* id) { return apvts.getRawParameterValue (id) -> load(); }; + osc1w = get ("osc1wave"); osc2w = get ("osc2wave"); detune = get ("detune"); + oscMix = get ("oscmix"); + fltType = get ("flttype"); fltCut = get ("fltcut"); fltRes = get ("fltres"); + fltEnv = get ("fltenv"); fltKey = get ("fltkey"); + ampA = get ("ampA"); ampD = get ("ampD"); ampS = get ("ampS"); + ampR = get ("ampR"); + fA = get ("fenvA"); fD = get ("fenvD"); fS = get ("fenvS"); + fR = get ("fenvR"); + lfoRate = get ("lforate"); lfoShape = get ("lfoshape"); lfoDepth = get ("lfodepth"); + lfoTarget= get ("lfotarget"); + fxType = get ("fxtype"); fxAmt = get ("fxamt"); fxTime = get ("fxtime"); + vol = get ("vol"); pan = get ("pan"); poly = get ("poly"); + glide = get ("glide"); tune = get ("tune"); + } +}; + +// --------------------------------------------------------------------------- + +class Env +{ +public: + enum State { Off, Atk, Dec, Sus, Rel }; + + void setRates (float a, float d, float s, float r, double sr) + { + srInv = 1.0 / sr; + attTau = 1.0f / (0.001f + 0.25f * a); + decTau = 1.0f / (0.001f + 0.35f * d); + relTau = 1.0f / (0.001f + 0.30f * r); + susL = s; + } + + void noteOn () { st = Atk; } + + void noteOff () + { + if (st != Off && st != Rel) st = Rel; + } + + void reset (float v = 0.0f) { cur = v; st = Off; } + + float next() + { + switch (st) + { + case Atk: + cur += (1.0f - cur) * std::min (1.0f, (float) (attTau * srInv) * 8.0f); + if (cur >= 1.0f) { cur = 1.0f; st = Dec; } + break; + case Dec: + cur = susL + (cur - susL) * std::exp (-decTau * (float) srInv); + if (std::abs (cur - susL) < 0.001f) { cur = susL; st = Sus; } + break; + case Sus: break; + case Rel: + cur *= std::exp (-relTau * (float) srInv); + if (cur < 0.0002f) { cur = 0.0f; st = Off; } + break; + case Off: break; + } + return cur; + } + + bool running() const { return st != Off; } + float getCur() const { return cur; } + State getState() const { return st; } + +private: + float cur = 0.0f, susL = 0.7f; + float attTau = 1.0f, decTau = 1.0f, relTau = 1.0f; + double srInv = 1.0 / 48000.0; + State st = Off; +}; + +// --------------------------------------------------------------------------- + +class SynthEngine +{ +public: + SynthEngine(); + ~SynthEngine(); + + void setSampleRate (double sr); + void noteOn (int midiNote, float velocity); + void noteOff (int midiNote); + void allNotesOff (bool immediate); + void sustainPedal (bool down); + void pitchBend (float cents); + void modWheel (float v); + + void render (const VParams& p, float* left, float* right, int numSamples); + + int activeVoices() const noexcept { return active; } + void getMeters (float& l, float& r) const noexcept { l = metL; r = metR; } + +private: + struct Voice + { + bool active = false; + int note = 0; + float vel = 1.0f; + float panL = 0.71f, panR = 0.71f; + float curFreq = 0.0f, targetFreq = 0.0f; + double ph1 = 0.0, ph2 = 0.0, lfoPh = 0.0; + float lfoSH = 0.0f; + int lfoIdx = 0; + Env amp, flt; + float lp = 0.0f, bp = 0.0f, f1c = 0.0f; + float outSmooth = 0.0f; + }; + + static constexpr int maxVoice = 16; + Voice voices[maxVoice]; + double sr = 48000.0; + int active = 0; + int polyLimit = 16; + float mod = 0.0f, bendCents = 0.0f, bendSmooth = 0.0f; + bool pedal = false; + float lastNoteFreq = 440.0f, lastGlide = 0.0f, lastPan = 0.0f; + int stealIdx = 0; + float metL = 0.0f, metR = 0.0f; + juce::Random rng; + juce::Array sustainedNotes; + + float osc (int wave, double ph); + Voice* acquireVoice(); +}; \ No newline at end of file