diff --git a/.gitignore b/.gitignore index 46ce735..bbbb742 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ build/ *.vst3/ *.component/ -.DS_Store \ No newline at end of file +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt index ea06178..54e1fc0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,10 +54,12 @@ message(STATUS "RMX-19 build: ${BUILD_GIT_BRANCH} ${BUILD_GIT_COMMIT}${BUILD_GIT # --------------------------------------------------------------------------- # 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") +if(NOT DEFINED JUCE_ROOT) + 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() endif() if(NOT DEFINED JUCE_ROOT OR NOT EXISTS "${JUCE_ROOT}/CMakeLists.txt") @@ -92,15 +94,42 @@ set(RMX19_SOURCES Source/Params.cpp ) +juce_add_binary_data(RMX19_Assets + NAMESPACE BinaryData + SOURCES + b2.png +) + 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 + RMX19_Assets 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 + JUCE_VST3_CAN_REPLACE_VST2=0) + +# --------------------------------------------------------------------------- +# Offline probe (dev only): runs the processor with scripted MIDI, no GUI. +# --------------------------------------------------------------------------- +add_executable(rmx_probe Source/Probe.cpp + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/LcdPanel.cpp + Source/PresetBank.cpp + Source/SynthEngine.cpp + Source/Params.cpp) +target_include_directories(rmx_probe PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/Source" + "${CMAKE_CURRENT_BINARY_DIR}/Source" + "${CMAKE_CURRENT_BINARY_DIR}/juce_binarydata_RMX19_Assets/JuceLibraryCode") +target_link_libraries(rmx_probe PRIVATE + RMX19_Assets + juce::juce_audio_utils + juce::juce_gui_basics + juce::juce_dsp) \ No newline at end of file diff --git a/Makefile b/Makefile index 06b1692..c065d8f 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,10 @@ CMAKE := cmake BUILD_DIR_ABSPATH := $(abspath $(BUILD_DIR)) ARTEFACTS := $(BUILD_DIR)/RMX19_artefacts/$(CONFIG) +JUCE_DIR := $(BUILD_DIR)/JUCE +JUCE_REPO := https://github.com/juce-framework/JUCE.git +JUCE_TAG := 8.0.4 + VST3_SRC := $(ARTEFACTS)/VST3/$(PLUGIN_BASE).vst3 AU_SRC := $(ARTEFACTS)/AU/$(PLUGIN_BASE).component @@ -28,17 +32,25 @@ USER_PLUGIN_DIRS := $(HOME)/Library/Audio/Plug-Ins/VST3 $(HOME)/Library/Audio/Pl .EXPORT_ALL_VARIABLES: CMAKE_BUILD_TYPE := $(CONFIG) -.PHONY: all configure build install uninstall clean info +.PHONY: all configure build install uninstall clean info juce all: build build: configure $(CMAKE) --build $(BUILD_DIR) --config $(CONFIG) --parallel +juce: + @if [ ! -d "$(JUCE_DIR)" ]; then \ + echo "Fetching JUCE $(JUCE_TAG)..."; \ + git clone --branch $(JUCE_TAG) --depth 1 $(JUCE_REPO) $(JUCE_DIR); \ + else \ + echo "JUCE already present at $(JUCE_DIR)"; \ + fi + # 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) +configure: juce + $(CMAKE) -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(CONFIG) -DJUCE_ROOT=$(JUCE_DIR) install: build @codesign --force --deep --sign - "$(VST3_SRC)" >/dev/null 2>&1 || true diff --git a/README.md b/README.md index e69de29..5c2c842 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,269 @@ +# RMX-19 — 19" Rack Digital Sound Module (VST3 / AU) + +RMX-19 is a virtual analog synthesizer implemented as an audio plugin. It is +styled as a piece of 19-inch studio rack hardware from the late 1980s / early +1990s, complete with a working dot-matrix LCD, a metal faceplate, nav-diamond +buttons, and a green-on-black display — but underneath it is a fully modern +JUCE-based software instrument that runs inside any DAW that hosts AU or VST3 +plugins on macOS (Apple Silicon). + +The plugin is a self-contained subtractive synth: two oscillators, a +state-variable filter, dual envelopes, an LFO, a built-in FX unit (chorus / +delay / space), and a factory patch bank — all edited and browsed through the +on-screen LCD instead of a conventional panel of knobs. + +> Credit where it's due: RMX-19 is an homage to the hardware-style UI of +> instruments like the Novation **Bass Station Rack** — hence the "RACK SYNTH" +> branding and the LCD-plus-soft-button interaction model. + +--- + +## What it is at a glance + +| | | +|---|---| +| **Format** | VST3 and Audio Unit (AU) plugin | +| **Transport** | Host JUCE-based AU / VST3 on macOS arm64 | +| **Architecture** | Subtractive / virtual-analog synthesizer | +| **UI model** | Simulated 19" rack module with a 160×64 dot-matrix LCD | +| **Voice count** | 16-voice polyphonic engine (1–16 selectable) | +| **Audio** | 24-bit/96 kHz capable; optional 2× HQ oversampling | +| **MIDI** | Note on/off, aftertouch-style CCs, pitch wheel, sustain pedal | + +--- + +## Feature overview + +### Sound engine + +- **Two oscillators** per voice, each with five waveforms: + sine, triangle, sawtooth, pulse, and noise. +- **Oscillator mix** (blend between OSC1 and OSC2) and **detune** (in cents) + for rich, thick unison-style stacking. +- **State-variable filter** in the Chamberlin style with three modes: + low-pass, band-pass, and high-pass, plus **cutoff**, **resonance**, + **filter envelope amount**, and **key tracking**. +- **Two ADSR envelopes**: one for the amplifier and one for the filter, + each with independent attack / decay / sustain / release. +- **One LFO** with rate, depth, and four selectable shapes + (sine, triangle, square, sample-and-hold), routable to three targets: + filter cutoff, pitch, or amplitude (tremolo). +- **Portamento / glide** per patch, **master tune** (±12 semitones). +- **Voice panning**: user pan plus a small per-voice random offset to spread + the stereo image. + +### Effects + +The FX unit is stacked **chorus** and **delay**, selectable via **FX TYPE**: + +- **OFF** — bypass. +- **CHORUS** — modulated short delay (uses the FX time as a base). +- **DELAY** — ping-pong-style feedback delay with a cross-feedback network. +- **SPACE** — a combination of chorus + delay (widened stereo ambience). + +**FX DEPTH** sets the wet/dry mix; **FX TIME** sets the delay time in seconds +when running free. Crucially, the delay can also be **quantized to the host +tempo** (see below). + +#### Delay tempo sync + +The delay can be locked to your DAW's transport tempo. On the **FX** screen +there is a fourth row labeled **SYNC** that chooses from these rhythmic +divisions (values are multiplied against `60 / BPM` to derive the delay in +seconds): + +| SYNC value | Music-notation equivalent | +|------------|---------------------------| +| `OFF` | free (use FX TIME in seconds) | +| `1/16` | sixteenth note | +| `1/8` | eighth note | +| `1/8T` | eighth-note triplet | +| `1/8D` | dotted eighth | +| `1/4` | quarter note | +| `1/4T` | quarter-note triplet | +| `1/4D` | dotted quarter | +| `1/2` | half note | +| `3/4` | dotted half | +| `1/1` | whole note | + +When a SYNC value other than `OFF` is selected, the FX TIME knob is ignored +for the delay and the delay time follows the host tempo in real time. + +### Factory patches + +RMX-19 ships with a hand-tuned bank of factory presets organized by category +(POLY PAD, KEY, BASS, LEAD, and more). Patches are stored as parameter +settings and can be recalled either from the LCD patch browser or from the +DAW's own program list. + +--- + +## The rack front panel (UI) + +The plugin looks like a 19" rack unit sitting in your DAW. The main elements: + +- **LCD display** — the green-on-black dot-matrix screen where all editing + happens. +- **Nav diamond** (`▲ ▼ ◀ ▶`) — move the cursor / change values. +- **ENTER** and **BACK** — confirm a selection or return to the previous + screen. +- **UI scale** dropdown (bottom-right) — resize the whole rack anywhere from + 50% to 300%. +- A **power LED** and cosmetic backplate graphics round out the hardware + illusion. + +You can also drive the whole interface with the keyboard: **arrow keys** for +navigation, **Return / Space** for ENTER, and **Escape** for BACK. + +### Using the LCD + +The interface is a small nested menu system: + +1. **SPLASH** — the boot-up screen; press ENTER to continue. +2. **HOME** — shows the current patch number, name, category, and a live + voice-activity bar. Use ◀/▶ (or ▲/▼) to step through patches. +3. **MENU** — eight destinations: + `PATCH`, `VOICE`, `FILTER`, `ENVELOPE`, `LFO`, `FX`, `MASTER`, `ABOUT`. +4. **PATCH** — scroll through the factory bank; press ENTER to load the + highlighted patch. +5. **VOICE / FILTER / ENVELOPE / LFO / FX / MASTER** — parameter screens. + Highlight a row and use ◀/▶ to adjust it (defaults to fine steps). + +#### The parameter screens + +| Screen | Parameters | +|--------|------------| +| **VOICE** | OSC1 WAVE, OSC2 WAVE, DETUNE, OSC BAL | +| **FILTER** | FILTER TYPE, CUTOFF, RESONANCE, FILT ENV, KEY TRACK | +| **ENVELOPE** | AMP ATTACK, AMP DECAY, AMP SUSTAIN, AMP RELEASE, FENV ATTACK, FENV DECAY, FENV SUSTAIN, FENV RELEASE | +| **LFO** | LFO RATE, LFO SHAPE, LFO DEPTH, LFO TARGET | +| **FX** | FX TYPE, FX DEPTH, FX TIME, **SYNC** (delay tempo sync) | +| **MASTER** | MASTER VOL, STEREO PAN, POLYPHONY, PORTAMENTO, MASTER TUNE, HQ MODE | + +> **Finding the delay sync.** On the **FX** screen the rows are FX TYPE, +> FX DEPTH, FX TIME, and then a fourth row labeled **SYNC**. Press **DOWN** +> three times from the top of the FX screen to reach it, then use **◀/▶** to +> pick a rhythmic division (see the table above). + +### About screen + +Shows the RMX-19 build: git branch / SHA / dirty marker, the build date/time, +the JUCE version, and the target platform. The LCD **footer** also shows the +git branch@commit and build timestamp on every screen. + +--- + +## MIDI + +RMX-19 accepts MIDI input and maps the following messages: + +| MIDI | Action | +|------|--------| +| Note on / off | Play / release voices | +| Pitch wheel | ±2 semitones, smoothed | +| CC 64 | Sustain pedal (sustains held notes) | +| CC 1 / 74 | Filter cutoff | +| CC 2 / 71 | Filter resonance | +| CC 3 / 76 | LFO rate | +| CC 4 / 91 | FX depth | +| CC 7 | Master volume | +| CC 10 | Stereo pan | +| CC 72 | Amp release | +| CC 73 | Amp attack | +| CC 77 | LFO depth | + +The plugin also exposes each patch as a DAW program, so preset changes from +the host map onto the factory bank. + +--- + +## DSP notes + +- **Voice engine** — each voice is a classic two-osc → mix → SVF filter → + amp-envelope chain. The oscillator mix is a crossfade between OSC1 and + OSC2, and detune offsets OSC2's frequency (OSC1 × `2^(detune/1200)`). +- **Filter** — a stable Chamberlin state-variable filter; cutoff is modulated + by the filter envelope, key tracking, the LFO, and the mod wheel, with a + smoothed control signal to suppress zipper noise. +- **Envelopes** — exponential ADSRs (attack/decay/release are time-domain + tau-based approximations, sustain is a level). +- **HQ mode** — a MASTER-screen toggle that renders the engine at **2× the + host sample rate** internally, then downsamples, reducing alias artifacts + at high cutoff / bright oscillators. +- **Output stage** — per-sample `tanh` soft-clipping plus a DC-blocking + filter, and a master volume gain. +- **FX** — a modulated short delay (chorus) and a cross-feedback delay line + (delay). The feedback delay supports host-tempo sync via the SYNC setting. + Tail length is sized to fit the longest possible sync delay. + +--- + +## Building from source + +### Prerequisites + +- macOS on Apple Silicon (arm64), macOS 12.0 or later +- CMake ≥ 3.22 +- A C++17 compiler (Apple Clang via Xcode / Command Line Tools) +- `git` (to fetch the JUCE framework) + +The build fetches **JUCE 8.0.4** automatically into `build/JUCE` on first +configure — no manual JUCE setup is required. + +### Commands + +```sh +make # configure + build VST3 and AU (Release, arm64) +make install # build, ad-hoc codesign, and install into ~/Library/Audio/Plug-Ins +make uninstall # remove installed bundles +make clean # delete the build tree (and the fetched JUCE) +make info # print build paths +``` + +`make` (configure) is designed to be cheap when nothing changed and refreshes +the git branch/hash and timestamp embedded in the LCD footer on every +configure, so the About screen always reflects the current source state. + +### Notes + +- After `make install`, **restart (or cold-start) your DAW** so macOS rescans + AudioComponents. A freshly-installed AU may not appear until the host is + relaunched. +- Installed bundles are ad-hoc code-signed; if your DAW refuses to load the + AU, run `codesign --force --deep --sign - ` again or re-install. + +--- + +## Project layout + +``` +CMakeLists.txt Build configuration (JUCE plugin target, git build info) +Makefile Building / installing convenience wrapper +b2.png Rack faceplate background image (embedded as binary data) +Source/ + PluginProcessor.cpp/.h Audio processor: MIDI, FX engine, patch I/O, state save/load + PluginEditor.cpp/.h Rack front panel: buttons, scale dropdown, rendering + LcdPanel.cpp/.h Dot-matrix LCD: all screens, navigation, glyph rendering + SynthEngine.cpp/.h The voice / oscillator / filter / envelope DSP core + Params.cpp/.h Parameter table, formatting, UI value stepping, delay sync names + PresetBank.cpp/.h Factory patch definitions and bank lookup + Probe.cpp Offline dev probe (scripted MIDI, no GUI/DAW) + BuildInfo.h.in Compile-time git/timestamp template +``` + +### Development probe + +`Source/Probe.cpp` is an offline executable (`rmx_probe`) used during +development to drive the processor with scripted MIDI events without a DAW or +GUI. It is defined in `CMakeLists.txt` for convenience and is not part of the +shipped plugin. + +--- + +## License / attribution + +RMX-19 is a personal / hobby project. It is built on the +[JUCE](https://juce.com) audio framework under its own license. The rack +hardware aesthetic pays tribute to classic 90s synthesizer rack gear; the +plugin is an original implementation and is not affiliated with or endorsed +by any hardware manufacturer. diff --git a/Source/LcdPanel.cpp b/Source/LcdPanel.cpp index 104959c..10dd472 100644 --- a/Source/LcdPanel.cpp +++ b/Source/LcdPanel.cpp @@ -1,5 +1,6 @@ #include "LcdPanel.h" #include "BuildInfo.h" +#include // --------------------------------------------------------------------------- // 5x7 dot-matrix font. Each glyph is 5 columns x 7 rows, bit 6 (0x40) of each @@ -78,6 +79,94 @@ static const Entry table[] = } // namespace +// --------------------------------------------------------------------------- +// 3x5 compact font (denser layout, reads like a small real-world LCD). Each +// glyph is 3 columns x 5 rows, bits 0..2 (bit 2 is the leftmost dot). +// --------------------------------------------------------------------------- +namespace { + +static constexpr int MW = 3, MH = 5; + +struct MEntry { int key; const juce::uint8* rows; }; + +static const juce::uint8 mA_[MH] = { 0b010, 0b101, 0b111, 0b101, 0b101 }; +static const juce::uint8 mB_[MH] = { 0b110, 0b101, 0b110, 0b101, 0b110 }; +static const juce::uint8 mC_[MH] = { 0b011, 0b100, 0b100, 0b100, 0b011 }; +static const juce::uint8 mD_[MH] = { 0b110, 0b101, 0b101, 0b101, 0b110 }; +static const juce::uint8 mE_[MH] = { 0b111, 0b100, 0b110, 0b100, 0b111 }; +static const juce::uint8 mF_[MH] = { 0b111, 0b100, 0b110, 0b100, 0b100 }; +static const juce::uint8 mG_[MH] = { 0b011, 0b100, 0b101, 0b101, 0b011 }; +static const juce::uint8 mH_[MH] = { 0b101, 0b101, 0b111, 0b101, 0b101 }; +static const juce::uint8 mI_[MH] = { 0b111, 0b010, 0b010, 0b010, 0b111 }; +static const juce::uint8 mJ_[MH] = { 0b111, 0b001, 0b001, 0b101, 0b010 }; +static const juce::uint8 mK_[MH] = { 0b101, 0b110, 0b100, 0b110, 0b101 }; +static const juce::uint8 mL_[MH] = { 0b100, 0b100, 0b100, 0b100, 0b111 }; +static const juce::uint8 mM_[MH] = { 0b101, 0b111, 0b111, 0b101, 0b101 }; +static const juce::uint8 mN_[MH] = { 0b110, 0b101, 0b101, 0b101, 0b101 }; +static const juce::uint8 mO_[MH] = { 0b010, 0b101, 0b101, 0b101, 0b010 }; +static const juce::uint8 mP_[MH] = { 0b110, 0b101, 0b110, 0b100, 0b100 }; +static const juce::uint8 mQ_[MH] = { 0b010, 0b101, 0b101, 0b110, 0b011 }; +static const juce::uint8 mR_[MH] = { 0b110, 0b101, 0b110, 0b110, 0b101 }; +static const juce::uint8 mS_[MH] = { 0b011, 0b100, 0b010, 0b001, 0b110 }; +static const juce::uint8 mT_[MH] = { 0b111, 0b010, 0b010, 0b010, 0b010 }; +static const juce::uint8 mU_[MH] = { 0b101, 0b101, 0b101, 0b101, 0b111 }; +static const juce::uint8 mV_[MH] = { 0b101, 0b101, 0b101, 0b101, 0b010 }; +static const juce::uint8 mW_[MH] = { 0b101, 0b111, 0b101, 0b111, 0b101 }; +static const juce::uint8 mX_[MH] = { 0b101, 0b101, 0b010, 0b101, 0b101 }; +static const juce::uint8 mY_[MH] = { 0b101, 0b101, 0b010, 0b010, 0b010 }; +static const juce::uint8 mZ_[MH] = { 0b111, 0b001, 0b010, 0b100, 0b111 }; +static const juce::uint8 mn0_[MH] = { 0b010, 0b101, 0b101, 0b101, 0b010 }; +static const juce::uint8 mn1_[MH] = { 0b011, 0b001, 0b001, 0b001, 0b011 }; +static const juce::uint8 mn2_[MH] = { 0b110, 0b001, 0b010, 0b100, 0b111 }; +static const juce::uint8 mn3_[MH] = { 0b110, 0b001, 0b110, 0b001, 0b110 }; +static const juce::uint8 mn4_[MH] = { 0b101, 0b101, 0b111, 0b001, 0b001 }; +static const juce::uint8 mn5_[MH] = { 0b111, 0b100, 0b110, 0b001, 0b110 }; +static const juce::uint8 mn6_[MH] = { 0b010, 0b100, 0b110, 0b101, 0b010 }; +static const juce::uint8 mn7_[MH] = { 0b111, 0b001, 0b001, 0b010, 0b010 }; +static const juce::uint8 mn8_[MH] = { 0b010, 0b101, 0b010, 0b101, 0b010 }; +static const juce::uint8 mn9_[MH] = { 0b010, 0b101, 0b011, 0b001, 0b010 }; +static const juce::uint8 msp_[MH] = { 0, 0, 0, 0, 0 }; +static const juce::uint8 mdt_[MH] = { 0, 0, 0, 0b011, 0b011 }; +static const juce::uint8 mco_[MH] = { 0, 0, 0, 0b110, 0b100 }; +static const juce::uint8 mcl_[MH] = { 0, 0b010, 0, 0b010, 0 }; +static const juce::uint8 mmi_[MH] = { 0, 0, 0b111, 0, 0 }; +static const juce::uint8 mpl_[MH] = { 0, 0b010, 0b111, 0b010, 0 }; +static const juce::uint8 msl_[MH] = { 0b001, 0b001, 0b010, 0b100, 0b100 }; +static const juce::uint8 mpc_[MH] = { 0b101, 0b001, 0b010, 0b100, 0b101 }; +static const juce::uint8 map_[MH] = { 0b010, 0b010, 0, 0, 0 }; +static const juce::uint8 mlt_[MH] = { 0b100, 0b010, 0b001, 0b010, 0b100 }; +static const juce::uint8 mgt_[MH] = { 0b001, 0b010, 0b100, 0b010, 0b001 }; +static const juce::uint8 meq_[MH] = { 0, 0b111, 0, 0b111, 0 }; +static const juce::uint8 mhs_[MH] = { 0b010, 0b111, 0b111, 0b111, 0b010 }; +static const juce::uint8 mst_[MH] = { 0, 0, 0, 0, 0b111 }; +static const juce::uint8 mex_[MH] = { 0b010, 0b010, 0b010, 0, 0b010 }; +static const juce::uint8 mas_[MH] = { 0, 0b101, 0b010, 0b101, 0 }; + +static const MEntry mtable[] = +{ + { 'A', mA_ }, { 'B', mB_ }, { 'C', mC_ }, { 'D', mD_ }, { 'E', mE_ }, + { 'F', mF_ }, { 'G', mG_ }, { 'H', mH_ }, { 'I', mI_ }, { 'J', mJ_ }, + { 'K', mK_ }, { 'L', mL_ }, { 'M', mM_ }, { 'N', mN_ }, { 'O', mO_ }, + { 'P', mP_ }, { 'Q', mQ_ }, { 'R', mR_ }, { 'S', mS_ }, { 'T', mT_ }, + { 'U', mU_ }, { 'V', mV_ }, { 'W', mW_ }, { 'X', mX_ }, { 'Y', mY_ }, + { 'Z', mZ_ }, { '0', mn0_ }, { '1', mn1_ }, { '2', mn2_ }, { '3', mn3_ }, + { '4', mn4_ }, { '5', mn5_ }, { '6', mn6_ }, { '7', mn7_ }, { '8', mn8_ }, + { '9', mn9_ }, { ' ', msp_ }, { '.', mdt_ }, { ',', mco_ }, { ':', mcl_ }, + { '-', mmi_ }, { '+', mpl_ }, { '/', msl_ }, { '%', mpc_ }, { '\'', map_ }, + { '<', mlt_ }, { '>', mgt_ }, { '=', meq_ }, { '#', mhs_ }, { '_', mst_ }, + { '!', mex_ }, { '*', mas_ }, +}; + +} // namespace + +const juce::uint8* LcdPanel::glyphM (int key) +{ + if (key >= 'a' && key <= 'z') key = key - 'a' + 'A'; + for (const auto& e : mtable) + if (e.key == key) return e.rows; + return msp_; +} + const juce::uint8* LcdPanel::glyphFor (int key) { if (key >= 'a' && key <= 'z') key = key - 'a' + 'A'; @@ -91,7 +180,10 @@ const juce::uint8* LcdPanel::glyphFor (int key) LcdPanel::LcdPanel (RMX19AudioProcessor& p) : proc (p) { - dotImage = juce::Image (juce::Image::ARGB, gridW, gridH, true); + dotImage = juce::Image (juce::Image::ARGB, gridW, gridH, false); + displayImage = juce::Image (juce::Image::ARGB, gridW * 2, gridH * 2, false); + backdrop = juce::Image (juce::Image::ARGB, gridW * 2, gridH * 2, false); + buildBackdrop(); splashUntil = juce::Time::getMillisecondCounter() + 1500; lastMidi = proc.getLastMidiTime(); startTimerHz (22); @@ -104,21 +196,36 @@ LcdPanel::~LcdPanel() void LcdPanel::timerCallback() { - float l, r; proc.getMeters (l, r); - meterL = std::max (l, meterL * 0.5f); - meterR = std::max (r, meterR * 0.5f); + bool needsRedraw = (screen == Screen::Splash); if (proc.getLastMidiTime() != lastMidi) { lastMidi = proc.getLastMidiTime(); powerGlow = 1.0f; + needsRedraw = true; } - powerGlow = std::max (0.0f, powerGlow - 0.04f); + const float oldGlow = powerGlow; + powerGlow = std::max (0.0f, powerGlow - 0.05f); + if (powerGlow >= 0.05f || oldGlow >= 0.05f) needsRedraw = true; if (screen == Screen::Splash && juce::Time::getMillisecondCounter() > splashUntil) + { screen = Screen::Home; + needsRedraw = true; + } - setDirty(); + if (screen == Screen::Home) + { + const int v = proc.getActiveVoices(); + if (v != lastVoices) + { + lastVoices = v; + needsRedraw = true; + } + } + + if (needsRedraw) + setDirty(); } void LcdPanel::paint (juce::Graphics& g) @@ -132,7 +239,6 @@ void LcdPanel::paint (juce::Graphics& g) 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; @@ -141,26 +247,150 @@ void LcdPanel::paint (juce::Graphics& g) g.setColour (juce::Colour (0xff020703)); g.fillRect (ox, oy, w, h); + if (contentDirty) + { + rebuild(); + contentDirty = false; + } + + g.drawImageAt (displayImage, ox, oy); +} + +void LcdPanel::buildBackdrop() +{ + const int W = displayImage.getWidth(); + const int H = displayImage.getHeight(); + juce::Graphics dg (backdrop); + + // dark glass base + dg.fillAll (juce::Colour (0xff020703)); + + // even, faint backlight (brightest toward the middle of the glass) + { + const float cx = W * 0.5f, cy = H * 0.5f; + juce::ColourGradient backlight (juce::Colour::fromRGBA (0x28, 0x5a, 0x3c, 22), cx, cy, + juce::Colour::fromRGBA (0x18, 0x2e, 0x20, 0), W * 0.38f, cy, false); + dg.setGradientFill (backlight); + dg.fillRect (backdrop.getBounds()); + } + + // glass reflection sheen across the top + { + juce::ColourGradient sheen (juce::Colour::fromRGBA (0xff, 0xff, 0xff, 16), 0.0f, 2.0f, + juce::Colour::fromRGBA (0xff, 0xff, 0xff, 0), 0.0f, 42.0f, false); + dg.setGradientFill (sheen); + dg.fillRect (0, 0, W, 42); + } + + // subtle vignette so the glass reads as a luminous surface + { + juce::ColourGradient eT (juce::Colour::fromRGBA (0x00, 0x08, 0x04, 60), 0, 0, + juce::Colour::fromRGBA (0x00, 0x08, 0x04, 0), 0, 10, false); + dg.setGradientFill (eT); dg.fillRect (0, 0, W, 10); + + juce::ColourGradient eB (juce::Colour::fromRGBA (0x00, 0x08, 0x04, 60), 0, (float) H, + juce::Colour::fromRGBA (0x00, 0x08, 0x04, 0), 0, (float) H - 10, false); + dg.setGradientFill (eB); dg.fillRect (0, H - 10, W, 10); + + juce::ColourGradient eL (juce::Colour::fromRGBA (0x00, 0x08, 0x04, 36), 0, 0, + juce::Colour::fromRGBA (0x00, 0x08, 0x04, 0), 10, 0, false); + dg.setGradientFill (eL); dg.fillRect (0, 0, 10, H); + + juce::ColourGradient eR (juce::Colour::fromRGBA (0x00, 0x08, 0x04, 36), (float) W, 0, + juce::Colour::fromRGBA (0x00, 0x08, 0x04, 0), (float) W - 10, 0, false); + dg.setGradientFill (eR); dg.fillRect (W - 10, 0, 10, H); + } + + // faint "ghost" dot for every (unlit) segment, exactly like a passive matrix LCD + for (int y = 0; y < gridH; ++y) + for (int x = 0; x < gridW; ++x) + { + const float cx = x * 2.0f + 1.0f; + const float cy = y * 2.0f + 1.0f; + dg.setColour (juce::Colour::fromRGBA (0x00, 0x88, 0x55, 6)); + dg.fillEllipse (cx - 0.55f, cy - 0.55f, 1.1f, 1.1f); + } +} + +void LcdPanel::rebuild() +{ + // Image::clear() hard-zeros pixels; Graphics::fillAll(transparent black) only blends + // and leaves stale content behind. + dotImage.clear (dotImage.getBounds()); { juce::Graphics dg (dotImage); - dg.fillAll (juce::Colours::black); drawContent (dg); } - for (int y = 0; y < gridH; ++y) + const int W = displayImage.getWidth(); + const int H = displayImage.getHeight(); + + // start from the pre-rendered base layer (backlight, sheen, vignette, ghost dots) { - for (int x = 0; x < gridW; ++x) + juce::Image::BitmapData src (backdrop, juce::Image::BitmapData::readOnly); + juce::Image::BitmapData dst (displayImage, juce::Image::BitmapData::writeOnly); + for (int y = 0; y < H; ++y) + std::memcpy (dst.getLinePointer (y), src.getLinePointer (y), (size_t) W * 4u); + } + + // blend the lit segments in as crisp round dots with just a hint of bloom. + // The core is near-solid so glyphs stay sharp; the glow is faint and tight + // so it can't wash the dark gaps between rows/columns. + constexpr float coreR2 = 0.36f; // bright core radius ~0.60 cell + constexpr float glowR2 = 1.10f; // faint bloom radius ~1.05 cells + { + juce::Image::BitmapData cells (dotImage, juce::Image::BitmapData::readOnly); + juce::Image::BitmapData out (displayImage, juce::Image::BitmapData::readWrite); + + for (int cy = 0; cy < gridH; ++cy) { - 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); + for (int cx = 0; cx < gridW; ++cx) + { + const float lvl = cells.getPixelColour (cx, cy).getAlpha() / 255.0f; + if (lvl <= 0.02f) continue; + + const float ccx = cx * 2.0f + 1.0f; + const float ccy = cy * 2.0f + 1.0f; + const int x0 = cx * 2 - 1, x1 = cx * 2 + 3; + const int y0 = cy * 2 - 1, y1 = cy * 2 + 3; + + for (int py = y0; py <= y1; ++py) + { + if (py < 0 || py >= H) continue; + for (int px = x0; px <= x1; ++px) + { + if (px < 0 || px >= W) continue; + + const float dx = (px - ccx) * 0.5f; + const float dy = (py - ccy) * 0.5f; + const float d2 = dx * dx + dy * dy; + if (d2 > glowR2) continue; + + float a; + juce::uint8 r, g, b; + if (d2 <= coreR2) + { + a = (0.85f + 0.15f * (1.0f - d2 / coreR2)) * lvl; + r = 0xdf; g = 0xff; b = 0xe6; + } + else + { + a = (1.0f - (d2 - coreR2) / (glowR2 - coreR2)) * 0.16f * lvl; + r = 0x90; g = 0xff; b = 0xa8; + } + if (a <= 0.002f) continue; + + const juce::Colour base = out.getPixelColour (px, py); + const float ia = 1.0f - a; + out.setPixelColour (px, py, juce::Colour ( + (juce::uint8) juce::jmin (255.0f, (float) base.getRed() * ia + r * a), + (juce::uint8) juce::jmin (255.0f, (float) base.getGreen() * ia + g * a), + (juce::uint8) juce::jmin (255.0f, (float) base.getBlue() * ia + b * a))); + } + } + } } } - (void) scale; } void LcdPanel::drawContent (juce::Graphics& g) @@ -175,7 +405,6 @@ void LcdPanel::drawContent (juce::Graphics& g) 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) @@ -205,6 +434,24 @@ void LcdPanel::pxText (juce::Graphics& g, int x, int y, const juce::String& text } } +void LcdPanel::pxTextM (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 = glyphM (key); + for (int row = 0; row < MH; ++row) + { + const juce::uint8 bits = rows[row]; + for (int col = 0; col < MW; ++col) + if (bits & (1u << (MW - 1 - col))) + px (g, cx + col, y + row, intensity); + } + cx += MW + 1; + } +} + void LcdPanel::drawFilled (juce::Graphics& g, int x, int y, int w, int h, float intensity) { for (int yy = 0; yy < h; ++yy) @@ -212,6 +459,12 @@ void LcdPanel::drawFilled (juce::Graphics& g, int x, int y, int w, int h, float px (g, x + xx, y + yy, intensity); } +// full-width selection bar: lit row behind a dimmed-to-transparent glyph +void LcdPanel::drawSelBar (juce::Graphics& g, int y) +{ + drawFilled (g, 2, y, gridW - 4, 6, 0.55f); +} + 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)); @@ -222,26 +475,23 @@ 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); + const juce::String git = branch + "@" + juce::String (BUILD_GIT_COMMIT) + juce::String (BUILD_GIT_DIRTY); - drawFilled (g, 0, 61, gridW, 1, 0.4f); - pxText (g, 2, 63, git, 0.75f); + drawFilled (g, 2, 57, gridW - 4, 1, 0.35f); + pxTextM (g, 4, 58, git, 0.75f); - const auto w = dt.length() * (NW + 1) - 1; - pxText (g, gridW - w - 2, 63, dt, 0.75f); - - (void) powerGlow; + const juce::String dt = juce::String (BUILD_DATE) + " " + juce::String (BUILD_TIME); + const auto w = dt.length() * (MW + 1) - 1; + pxTextM (g, gridW - w - 4, 58, dt, 0.75f); } 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); + pxTextM (g, 58, 8, "* RMX-19 *", 1.0f); + pxTextM (g, 28, 20, "RACK SYNTHESIZER", 0.9f); + drawFilled (g, 10, 31, gridW - 20, 1, 0.5f); + pxTextM (g, 44, 36, "INITIALIZING", 0.8f); + pxTextM (g, 34, 46, "DSP READY", 0.75f); } void LcdPanel::drawHome (juce::Graphics& g) @@ -251,86 +501,59 @@ void LcdPanel::drawHome (juce::Graphics& g) 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); + pxText (g, 14, 3, p.name, 1.0f); + pxTextM (g, 4, 17, p.category, 0.9f); - 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); + drawFilled (g, 4, 28, 88, 1, 0.5f); + drawHBar (g, 4, 29, 88, proc.getActiveVoices() / 16.0, 0.9f); } 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" + "PATCH", "VOICE", "FILTER", "ENVELOPE", "LFO", "FX", "MASTER", "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) + for (int i = 0; i < rows; ++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); - } + const int y = 2 + i * 7; + const bool sel = (i == cursor); + if (sel) drawSelBar (g, y - 1); + pxTextM (g, 6, y, items[i], sel ? 1.0f : 0.72f); } - 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)); + const int visible = 8; + const int top = juce::jmax (0, juce::jmin (patchCursor - 3, 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 int y = 2 + i * 7; 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); + if (sel) drawSelBar (g, y - 1); + pxTextM (g, 6, y, juce::String (idx + 1).paddedLeft ('0', 2) + " " + juce::String (p.name), + sel ? 1.0f : 0.72f); + if (idx == proc.getPatchIndex()) + pxTextM (g, gridW - 7, y, "*", sel ? 1.0f : 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); + pxTextM (g, 3, 1, paramScreenTitle (paramScreenIdx), 1.0f); + drawFilled (g, 3, 6, gridW - 6, 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 envMt[] = { prm::Id::ampA, prm::Id::ampD, prm::Id::ampS, prm::Id::ampR, prm::Id::fenvA, prm::Id::fenvD, prm::Id::fenvS, prm::Id::fenvR }; + static const prm::Id lfo[] = { 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 }; + static const prm::Id master[] = { prm::Id::vol, prm::Id::pan, prm::Id::poly, prm::Id::glide, prm::Id::tune, prm::Id::hq }; const prm::Id* rows = nullptr; int count = 0; @@ -338,39 +561,47 @@ void LcdPanel::drawParamScreen (juce::Graphics& g) { 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; + case 2: rows = envMt; count = (int) juce::numElementsInArray (envMt); break; + case 3: rows = lfo; count = (int) juce::numElementsInArray (lfo); break; + case 4: rows = fx; count = (int) juce::numElementsInArray (fx); break; + case 5: 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)); + const int visible = 7; + const int top = juce::jmax (0, juce::jmin (cursor - 3, count - visible)); for (int i = 0; i < visible; ++i) { const int ri = top + i; if (ri >= count) break; + + const int y = 8 + i * 7; + const bool sel = (ri == cursor); + + // FX screen has an extra "SYNC" (delay tempo sync) row after the 3 fx params + if (paramScreenIdx == 4 && ri >= (int) juce::numElementsInArray (fx)) + { + const int syncIdx = (int) proc.apvts.getRawParameterValue ("delaysync")->load(); + if (sel) drawSelBar (g, y - 1); + pxTextM (g, 6, y, "SYNC", sel ? 1.0f : 0.75f); + const juce::String val = prm::delaySyncName (syncIdx); + const auto w = val.length() * (MW + 1) - 1; + pxTextM (g, gridW - w - 5, y, val, sel ? 1.0f : 0.85f); + continue; + } + 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); - } + if (sel) drawSelBar (g, y - 1); + + pxTextM (g, 6, y, d.name, sel ? 1.0f : 0.75f); + + const juce::String val = prm::formatParam (id, cur); + const auto w = val.length() * (MW + 1) - 1; + pxTextM (g, gridW - w - 5, y, val, sel ? 1.0f : 0.85f); } - pxText (g, 4, 62, "LEFT/RIGHT EDIT OK BACK", 0.55f); } void LcdPanel::drawSystem (juce::Graphics& g) @@ -378,22 +609,22 @@ 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 int y = 2 + i * 7; 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 (sel) drawSelBar (g, y - 1); + pxTextM (g, 6, y, items[i], sel ? 1.0f : 0.72f); } if (cursor == 2 && powerGlow > 0.5f) - pxText (g, 4, 50, "MIDI RX " + juce::String (lastMidi ? "ACTIVE" : "IDLE"), 0.6f); + pxTextM (g, 6, 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); + pxTextM (g, 3, 3, "RMX-19 RACK SYNTH BY A. JENEWEIN", 0.95f); + pxTextM (g, 3, 13, "GIT " + juce::String (BUILD_GIT_BRANCH) + " " + juce::String (BUILD_GIT_COMMIT) + BUILD_GIT_DIRTY, 0.85f); + pxTextM (g, 3, 23, "BUILT " + juce::String (BUILD_DATE) + " " + BUILD_TIME, 0.85f); + pxTextM (g, 3, 33, "ARM64 JUCE " + juce::String (JUCE_MAJOR_VERSION) + "." + juce::String (JUCE_MINOR_VERSION), 0.7f); + pxTextM (g, 3, 43, "MIDI NOTE + CC CONTROL", 0.7f); } // ---- editing helpers ------------------------------------------------------ @@ -402,7 +633,7 @@ int LcdPanel::rowCountForScreen() const { switch (screen) { - case Screen::Menu: return 10; + case Screen::Menu: return 8; case Screen::System: return 4; default: return 0; } @@ -414,11 +645,10 @@ int LcdPanel::paramCountForScreen (int idx) const { case 0: return 4; case 1: return 5; - case 2: return 4; + case 2: return 8; case 3: return 4; case 4: return 4; - case 5: return 3; - case 6: return 5; + case 5: return 5; default: return 0; } } @@ -427,20 +657,19 @@ const char* LcdPanel::paramScreenTitle (int idx) const { switch (idx) { - case 0: return "OSCILLATOR"; + case 0: return "VOICE"; 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"; + case 2: return "ENVELOPE"; + case 3: return "LFO"; + case 4: return "FX"; + case 5: return "MASTER"; default: return ""; } } void LcdPanel::openParamScreen (int idx) { - paramScreenIdx = juce::jlimit (0, 6, idx); + paramScreenIdx = juce::jlimit (0, 5, idx); cursor = 0; enterScreen (Screen::Param); } @@ -464,6 +693,17 @@ void LcdPanel::moveCursor (int d) } } +void LcdPanel::stepDelaySync (int dir) +{ + auto* par = dynamic_cast (proc.apvts.getParameter ("delaysync")); + if (par == nullptr) return; + const int cur = (int) proc.apvts.getRawParameterValue ("delaysync")->load(); + const int nv = juce::jlimit (0, prm::delaySyncCount - 1, cur + dir); + if (nv != cur) + par->setValueNotifyingHost (juce::NormalisableRange (0.0, (double) (prm::delaySyncCount - 1)).convertTo0to1 ((double) nv)); + setDirty(); +} + void LcdPanel::stepParam (prm::Id id, int dir, bool coarse) { auto* par = dynamic_cast (proc.apvts.getParameter (prm::desc (id).id)); @@ -480,33 +720,56 @@ 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 envMt[] = { prm::Id::ampA, prm::Id::ampD, prm::Id::ampS, prm::Id::ampR, prm::Id::fenvA, prm::Id::fenvD, prm::Id::fenvS, prm::Id::fenvR }; + static const prm::Id lfo[] = { 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 }; + static const prm::Id master[] = { prm::Id::vol, prm::Id::pan, prm::Id::poly, prm::Id::glide, prm::Id::tune, prm::Id::hq }; 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]; + case 2: return envMt[row]; + case 3: return lfo[row]; + case 4: return fx[row]; + case 5: 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); + if (screen != Screen::Param) return; + // FX screen: row 3 is the delay-sync selector (not a prm::Id) + if (paramScreenIdx == 4 && cursor >= 3) + { + stepDelaySync (dir); + return; + } + stepParam (paramForCursor (paramScreenIdx, cursor), dir, coarse); } -void LcdPanel::navUp() { moveCursor (-1); } -void LcdPanel::navDown() { moveCursor (1); } +void LcdPanel::selectPatchCyclic (int dir) +{ + const int n = proc.getBank().size(); + if (n <= 0) return; + const int next = (proc.getPatchIndex() + dir + n) % n; + if (next != proc.getPatchIndex()) + proc.selectPatch (next); + setDirty(); +} + +void LcdPanel::navUp() { if (screen == Screen::Home) { selectPatchCyclic (-1); return; } moveCursor (-1); applyPatchOnBrowserMove(); } +void LcdPanel::navDown() { if (screen == Screen::Home) { selectPatchCyclic (1); return; } moveCursor (1); applyPatchOnBrowserMove(); } + +void LcdPanel::applyPatchOnBrowserMove() +{ + if (screen == Screen::Patch) + { + proc.selectPatch (patchCursor); + setDirty(); + } +} void LcdPanel::navLeft() { adjustCurrent (-1, false); } void LcdPanel::navRight() { adjustCurrent (1, false); } @@ -526,15 +789,13 @@ void LcdPanel::navEnter() 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; + case 7: enterScreen (Screen::Info); break; default: break; } break; case Screen::Patch: proc.selectPatch (patchCursor); - enterScreen (Screen::Home); + enterScreen (Screen::Menu); break; case Screen::System: switch (cursor) @@ -542,14 +803,17 @@ void LcdPanel::navEnter() 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; + case 3: enterScreen (Screen::Menu); break; default: break; } break; - case Screen::Info: case Screen::Param: + break; // stay in the submenu; left/right edits the highlighted param + case Screen::Info: + enterScreen (Screen::Menu); + break; default: - enterScreen (Screen::Home); + enterScreen (Screen::Menu); break; } } diff --git a/Source/LcdPanel.h b/Source/LcdPanel.h index fba73c1..862c90c 100644 --- a/Source/LcdPanel.h +++ b/Source/LcdPanel.h @@ -14,6 +14,7 @@ public: void navUp(); void navDown(); void navLeft(); void navRight(); void navEnter(); void navExit(); + void applyPatchOnBrowserMove(); void resetToHome(); @@ -34,19 +35,28 @@ private: int patchCursor = 0; int paramScreenIdx = 0; // which param screen is open - float meterL = 0.0f, meterR = 0.0f; float powerGlow = 0.0f; + int lastVoices = -1; juce::uint32 lastMidi = 0; - juce::Image dotImage; + juce::Image dotImage; // 160x64 content scratch buffer + juce::Image displayImage; // 320x128 cached green-on-black result + juce::Image backdrop; // 320x128 static base (backlight + ghost dots) + bool contentDirty = true; static const juce::uint8* glyphFor (int key); + static const juce::uint8* glyphM (int key); + + void rebuild(); + void buildBackdrop(); 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 pxText (juce::Graphics&, int x, int y, const juce::String&, float intensity); + void pxTextM (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 drawSelBar (juce::Graphics&, int y); void drawSplash (juce::Graphics&); void drawHome (juce::Graphics&); @@ -57,9 +67,12 @@ private: void drawInfo (juce::Graphics&); void drawFooter (juce::Graphics&); - void stepParam (prm::Id id, int dir, bool coarse); + void selectPatchCyclic (int dir); - void setDirty() { dotImage.clear (dotImage.getBounds()); repaint(); } + void stepParam (prm::Id id, int dir, bool coarse); + void stepDelaySync (int dir); + + void setDirty() { contentDirty = true; repaint(); } void enterScreen (Screen s) { screen = s; cursor = 0; setDirty(); } void timerCallback() override; diff --git a/Source/Params.cpp b/Source/Params.cpp index 2296119..3a43ea5 100644 --- a/Source/Params.cpp +++ b/Source/Params.cpp @@ -14,15 +14,15 @@ static const Desc table[num] = { { "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 }, + { "ampA", "AMP ATTACK", "AAT", 0.001f, 6, 0.008f, false, 0.01f, 0.1f }, + { "ampD", "AMP DECAY", "ADE", 0.001f, 6, 0.26f, false, 0.01f, 0.1f }, { "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 }, + { "ampR", "AMP RELEASE", "AREL", 0.001f, 8, 0.35f, false, 0.01f, 0.1f }, + { "fenvA", "FENV ATTACK", "FAT", 0.001f, 6, 0.02f, false, 0.01f, 0.1f }, + { "fenvD", "FENV DECAY", "FDE", 0.001f, 6, 0.16f, false, 0.01f, 0.1f }, { "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 }, + { "fenvR", "FENV RELEASE", "FRE", 0.001f, 8, 0.25f, false, 0.01f, 0.1f }, + { "lforate", "LFO RATE", "LFR", 0.02f, 22, 4.2f, false, 0.1f, 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 }, @@ -34,6 +34,7 @@ static const Desc table[num] = { { "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 }, + { "hq", "HQ MODE", "HQ", 0, 1, 0, true, 1, 1 }, }; const Desc& desc (Id id) { return table[static_cast(id)]; } @@ -59,7 +60,8 @@ juce::AudioProcessorValueTreeState::ParameterLayout createLayout() d.id, d.name, r, p0.v[i])); } - layout.add (std::make_unique ("patchsel", "Patch", 0, 15, 0)); + layout.add (std::make_unique ("patchsel", "Patch", 0, bank.size() - 1, 0)); + layout.add (std::make_unique ("delaysync", "Delay Sync", 0, 10, 0)); return layout; } @@ -93,6 +95,49 @@ const char* fxTypeName (int t) case 3: return "SPACE"; default: return "OFF"; } } +const char* delaySyncName (int s) +{ + switch (s) + { + case 0: return "OFF "; + case 1: return "1/16"; + case 2: return "1/8 "; + case 3: return "1/8T"; + case 4: return "1/8D"; + case 5: return "1/4 "; + case 6: return "1/4T"; + case 7: return "1/4D"; + case 8: return "1/2 "; + case 9: return "3/4 "; + case 10: return "1/1 "; + default: return "--"; + } +} + +double delaySyncBeats (int s) +{ + switch (s) + { + case 0: return 0.0; // disabled + case 1: return 0.25; // 1/16 note + case 2: return 0.5; // 1/8 + case 3: return 1.0 / 3.0; // 1/8 triplet + case 4: return 0.75; // 1/8 dotted + case 5: return 1.0; // 1/4 + case 6: return 2.0 / 3.0; // 1/4 triplet + case 7: return 1.5; // 1/4 dotted + case 8: return 2.0; // 1/2 + case 9: return 3.0; // 3/4 + case 10: return 4.0; // whole + default: return 0.0; + } +} + +float delaySyncMaxTime() +{ + return 5.0f; // allow a whole note at slow tempos (e.g. 48 BPM) +} + static juce::String fmtTime (float t) { if (t >= 1.0f) return juce::String (t, 2) + "s"; @@ -138,6 +183,7 @@ juce::String formatParam (Id id, float v) const int t = (int) v; return juce::String (t > 0 ? "+" : "") + juce::String (t) + "ST"; } + case Id::hq: return v >= 0.5f ? "HQ" : "ECO"; default: return "-----"; } } diff --git a/Source/Params.h b/Source/Params.h index ff44127..3d616f3 100644 --- a/Source/Params.h +++ b/Source/Params.h @@ -10,10 +10,11 @@ enum class Id : int { fenvA, fenvD, fenvS, fenvR, lfoRate, lfoShape, lfoDepth, lfoTarget, fxType, fxAmt, fxTime, - vol, pan, poly, glide, tune + vol, pan, poly, glide, tune, + hq }; -constexpr int num = 29; +constexpr int num = 30; struct Desc { const char* id; @@ -38,4 +39,9 @@ const char* lfoShapeName (int s); const char* lfoTargetName (int t); const char* fxTypeName (int t); +constexpr int delaySyncCount = 11; +const char* delaySyncName (int s); // "OFF", "1/16", ... +double delaySyncBeats (int s); // beat multiple for sync delay +float delaySyncMaxTime(); // max delay seconds needed for slow sync + } // namespace prm \ No newline at end of file diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index cd98a89..33a5fdc 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -9,6 +9,9 @@ RackEditor::RackEditor (RMX19AudioProcessor& p) { face = juce::Colour (0xff16130f); + backgroundImage = juce::ImageFileFormat::loadFrom (BinaryData::b2_png, + BinaryData::b2_pngSize); + up = new NavButton (-1, "^", "UP"); down = new NavButton (1, "v", "DOWN"); left = new NavButton (-1, "<", "LEFT"); @@ -30,8 +33,43 @@ RackEditor::RackEditor (RMX19AudioProcessor& p) addAndMakeVisible (display); + scaleBox.addItem ("50%", 1); + scaleBox.addItem ("75%", 2); + scaleBox.addItem ("100%", 3); + scaleBox.addItem ("125%", 4); + scaleBox.addItem ("150%", 5); + scaleBox.addItem ("200%", 6); + scaleBox.addItem ("250%", 7); + scaleBox.addItem ("300%", 8); + scaleBox.setSelectedId (5, juce::dontSendNotification); + scaleBox.setJustificationType (juce::Justification::centred); + scaleBox.setTextWhenNothingSelected ("UI scale"); + scaleBox.setColour (juce::ComboBox::backgroundColourId, juce::Colour (0xff221d18)); + scaleBox.setColour (juce::ComboBox::outlineColourId, juce::Colour (0xff6b5f4e)); + scaleBox.setColour (juce::ComboBox::textColourId, juce::Colour (0xffd8cdb9)); + scaleBox.setColour (juce::ComboBox::arrowColourId, juce::Colour (0xffd8cdb9)); + scaleBox.setColour (juce::PopupMenu::backgroundColourId, juce::Colour (0xff1c1813)); + scaleBox.setColour (juce::PopupMenu::textColourId, juce::Colour (0xffd8cdb9)); + scaleBox.setColour (juce::PopupMenu::highlightedBackgroundColourId, juce::Colour (0xff3a332b)); + scaleBox.onChange = [this] + { + switch (scaleBox.getSelectedId()) + { + case 1: setScale (0.5f); break; + case 2: setScale (0.75f); break; + case 3: setScale (1.0f); break; + case 4: setScale (1.25f); break; + case 5: setScale (1.5f); break; + case 6: setScale (2.0f); break; + case 7: setScale (2.5f); break; + case 8: setScale (3.0f); break; + default: break; + } + }; + addAndMakeVisible (scaleBox); + setWantsKeyboardFocus (true); - setSize (656, 248); + setScale (1.5f); startTimerHz (18); } @@ -42,11 +80,41 @@ RackEditor::~RackEditor() void RackEditor::paint (juce::Graphics& g) { - const auto b = getLocalBounds(); + // full-area backdrop so scaled edges stay covered + g.fillAll (juce::Colour (0xff0d0b09)); - // --- metal rack face --- - g.setColour (face); - g.fillRect (b); + const auto b = juce::Rectangle (0, 0, baseW, baseH); + + // --- metal rack face (b2.png background, cover-scaled) --- + if (backgroundImage.isValid()) + { + const float imageAspect = (float) backgroundImage.getWidth() / (float) backgroundImage.getHeight(); + const float boxAspect = (float) b.getWidth() / (float) b.getHeight(); + + int sx = 0, sy = 0, sw = backgroundImage.getWidth(), sh = backgroundImage.getHeight(); + if (imageAspect > boxAspect) + { + sw = (int) (backgroundImage.getHeight() * boxAspect); + sx = (backgroundImage.getWidth() - sw) / 2; + } + else + { + sh = (int) (backgroundImage.getWidth() / boxAspect); + sy = (backgroundImage.getHeight() - sh) / 2; + } + g.drawImage (backgroundImage, b.getX(), b.getY(), + juce::roundToInt (b.getWidth() * uiScale), + juce::roundToInt (b.getHeight() * uiScale), + sx, sy, sw, sh); + } + else + { + g.setColour (face); + g.fillRect (b); + } + + g.setColour (juce::Colour (0xff08)); + g.addTransform (juce::AffineTransform::scale (uiScale)); // brushed texture g.setColour (juce::Colour::fromRGBA (255, 255, 255, 7)); @@ -61,70 +129,62 @@ void RackEditor::paint (juce::Graphics& g) 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); + g.drawFittedText ("RMX-19", 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 + // rear engraving text (kept clear of the UI-scale dropdown at the bottom right) 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); + g.drawFittedText (ser, 12, b.getHeight() - 16, b.getWidth() - 128, 11, juce::Justification::centred, 1); } void RackEditor::resized() { - const auto b = getLocalBounds(); + // UI-scale dropdown lives in real (unscaled) coordinates, bottom right. + scaleBox.setBounds (getWidth() - 112, getHeight() - 30, 104, 20); + + // everything else renders in base 656x248 space, scaled by an affine transform + const juce::AffineTransform tf = juce::AffineTransform::scale (uiScale); // LCD recessed module const juce::Rectangle lcdArea (14, 24, 356, 168); + display.setTransform (tf); 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 juce::Rectangle navArea (lcdArea.getRight() + 16, 26, baseW - 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); + up->setTransform (tf); up->setBounds (cx + 6, rowY + 1, bw + 3, bh); + left->setTransform (tf); left->setBounds (navArea.getX() + 1, rowY + bh + 6, bw + 2, bh); + down->setTransform (tf); down->setBounds (cx + 6, rowY + bh + 6 + 1, bw + 3, bh); + right->setTransform (tf); right->setBounds (navArea.getRight() - bw + 3, rowY + bh + 6, bw - 3, 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); + enterB->setTransform (tf); enterB->setBounds (navArea.getX() + 2, rowY + 2 * (bh + 6) + 4, bw + 2, bh); + backB->setTransform (tf); backB->setBounds (navArea.getRight() - bw + 3, rowY + 2 * (bh + 6) + 4, bw - 3, bh); +} + +void RackEditor::setScale (float s) +{ + uiScale = s; + setSize (juce::roundToInt (baseW * s), juce::roundToInt (baseH * s)); + resized(); // ensure child layouts refresh at the new size } void RackEditor::timerCallback() { powerPulse = 0.5f + 0.5f * std::sin (juce::Time::getMillisecondCounter() * 0.0022f); - repaint (getLocalBounds()); + // only the pulsing power LED needs redraws; everything else is static + const int ledX = getWidth() - 20, ledY = 18; + repaint (ledX - 26, ledY - 7, 30, 14); } bool RackEditor::keyPressed (const juce::KeyPress& k) @@ -193,4 +253,4 @@ void RackEditor::OkButton::paintButton (juce::Graphics& g, bool over, bool down) 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 index 9a6628b..532ec3e 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -2,6 +2,7 @@ #include #include "LcdPanel.h" #include "PluginProcessor.h" +#include "BinaryData.h" class RackEditor : public juce::AudioProcessorEditor, private juce::Timer { @@ -42,11 +43,17 @@ private: NavButton* left = nullptr; NavButton* right = nullptr; OkButton* enterB = nullptr; OkButton* backB = nullptr; + juce::ComboBox scaleBox; LcdPanel display; juce::Colour face; + juce::Image backgroundImage; float powerPulse = 0.0f; + float uiScale = 1.5f; + static constexpr int baseW = 656, baseH = 248; + + void setScale (float s); void timerCallback() override; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (RackEditor) diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 8202d92..5690a88 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -3,6 +3,16 @@ // --------------------------------------------------------------------------- +static inline void dbgNote (const char* ev, int note, float vel) +{ + juce::File (juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile ("rmx19_notes.log")) + .appendText (juce::String ("t=") + juce::String ((juce::int64) juce::Time::getMillisecondCounter()) + + " " + ev + " n=" + juce::String (note) + + (vel >= 0.0f ? juce::String (" v=") + juce::String (vel, 2) : juce::String()) + + "\n"); +} + class RMX19AudioProcessor::FxEngine { public: @@ -14,7 +24,7 @@ public: delL.prepare ({ sampleRate, 1u, (juce::uint32) maxSamples }); delR.prepare ({ sampleRate, 1u, (juce::uint32) maxSamples }); - const int maxDelay = (int) (sampleRate * 1.5); + const int maxDelay = (int) (sampleRate * prm::delaySyncMaxTime()); chorusL.setMaximumDelayInSamples (maxDelay); chorusR.setMaximumDelayInSamples (maxDelay); delL.setMaximumDelayInSamples (maxDelay); @@ -28,7 +38,7 @@ public: chPh = 0.0; } - void process (const VParams& p, float* L, float* R, int num) + void process (const VParams& p, float* L, float* R, int num, int syncIdx, double bpm) { const int type = (int) p.fxType; if (type == 0) return; @@ -37,7 +47,9 @@ public: 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); + float delSamples = (float) (p.fxTime * sr); + if (syncIdx > 0 && bpm > 1.0) + delSamples = (float) ((60.0 / bpm) * prm::delaySyncBeats (syncIdx) * sr); const float feedback = 0.40f; for (int s = 0; s < num; ++s) @@ -104,6 +116,7 @@ void RMX19AudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) engine.allNotesOff (true); fx = std::make_unique(); fx->prepare (sampleRate, samplesPerBlock); + osBuf.setSize (2, juce::nextPowerOfTwo (samplesPerBlock * 2) + 4, false, true); } void RMX19AudioProcessor::releaseResources() @@ -125,10 +138,12 @@ void RMX19AudioProcessor::processMidi (juce::MidiBuffer& midi, const VParams& p) if (msg.isNoteOn()) { engine.noteOn (msg.getNoteNumber(), msg.getFloatVelocity()); + dbgNote ("ON", msg.getNoteNumber(), msg.getFloatVelocity()); } else if (msg.isNoteOff()) { engine.noteOff (msg.getNoteNumber()); + dbgNote ("OFF", msg.getNoteNumber(), -1.0f); } else if (msg.isPitchWheel()) { @@ -141,7 +156,10 @@ void RMX19AudioProcessor::processMidi (juce::MidiBuffer& midi, const VParams& p) switch (cc) { case 64: engine.sustainPedal (msg.getControllerValue() >= 64); break; - case 1: engine.modWheel (v); break; + case 1: if (auto* par = apvts.getParameter ("fltcut")) par->setValueNotifyingHost (v); break; + case 2: if (auto* par = apvts.getParameter ("fltres")) par->setValueNotifyingHost (v); break; + case 3: if (auto* par = apvts.getParameter ("lforate")) par->setValueNotifyingHost (v); break; + case 4: if (auto* par = apvts.getParameter ("fxamt")) par->setValueNotifyingHost (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; @@ -169,6 +187,18 @@ void RMX19AudioProcessor::processBlock (juce::AudioBuffer& buffer, juce:: VParams p; p.update (apvts); + // host tempo (fall back to 120 if not playing / unknown) + double bpm = 120.0; + if (auto* ph = getPlayHead()) + if (auto pos = ph->getPosition()) + if (pos->getIsPlaying() && pos->getBpm().hasValue()) + bpm = juce::jlimit (20.0, 300.0, *pos->getBpm()); + const int syncIdx = (int) apvts.getRawParameterValue ("delaysync")->load(); + + const bool hq = p.hq >= 0.5f; + const double engineSr = hq ? getSampleRate() * 2.0 : getSampleRate(); + engine.setSampleRate (engineSr); + // patch follow const int sel = (int) apvts.getRawParameterValue ("patchsel")->load(); if (sel != lastPatchParam && sel >= 0 && sel < bank.size()) @@ -189,8 +219,36 @@ void RMX19AudioProcessor::processBlock (juce::AudioBuffer& buffer, juce:: if (L != nullptr && R != nullptr) { - engine.render (p, L, R, numSamples); - if (fx) fx->process (p, L, R, numSamples); + if (hq) + { + osBuf.clear(); + engine.render (p, osBuf.getWritePointer (0), osBuf.getWritePointer (1), numSamples * 2); + for (int s = 0; s < numSamples; ++s) + { + const int o = s * 2; + L[s] = 0.5f * (osBuf.getSample (0, o) + osBuf.getSample (0, o + 1)); + R[s] = 0.5f * (osBuf.getSample (1, o) + osBuf.getSample (1, o + 1)); + } + } + else + { + engine.render (p, L, R, numSamples); + } + if (fx) fx->process (p, L, R, numSamples, syncIdx, bpm); + + // loud-pop protection: remove DC steps and soft-clamp any blowout + const float dcCoef = 0.9995f; + for (int s = 0; s < numSamples; ++s) + { + const float tL = std::tanh (L[s]); + const float tR = std::tanh (R[s]); + const float oL = tL - dcPrevL + dcCoef * dcL; + const float oR = tR - dcPrevR + dcCoef * dcR; + dcPrevL = tL; dcPrevR = tR; + dcL = oL; dcR = oR; + L[s] = oL; + R[s] = oR; + } if (p.vol != 1.0f) buffer.applyGain (p.vol); } else if (L != nullptr) @@ -198,14 +256,44 @@ void RMX19AudioProcessor::processBlock (juce::AudioBuffer& buffer, juce:: juce::AudioBuffer dummy (1, numSamples); dummy.clear(); float* dL = dummy.getWritePointer (0); - float* dR = dummy.getWritePointer (0); - engine.render (p, dL, dR, numSamples); + if (hq) + { + osBuf.clear(); + engine.render (p, osBuf.getWritePointer (0), osBuf.getWritePointer (1), numSamples * 2); + for (int s = 0; s < numSamples; ++s) + { + const int o = s * 2; + dL[s] = 0.5f * (osBuf.getSample (0, o) + osBuf.getSample (1, o) + + osBuf.getSample (0, o + 1) + osBuf.getSample (1, o + 1)) * 0.5f; + } + } + else + { + float* dR = dummy.getWritePointer (0); + engine.render (p, dL, dR, numSamples); + } + if (fx) fx->process (p, dL, dL, numSamples, syncIdx, bpm); + for (int s = 0; s < numSamples; ++s) + { + dL[s] = std::tanh (dL[s] * 0.5f); + } 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); + + // real-time health check (offline probe + debug): flag blocks near the limit + const float blockMs = (numSamples == 0) ? 0.0f + : (float) numSamples / (float) std::max (1.0, getSampleRate()) * 1000.0f; + const float usedMs = (float) (juce::Time::getMillisecondCounter() - start); + if (usedMs > 0.5f * blockMs) + juce::File (juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile ("rmx19_notes.log")) + .appendText (juce::String ("OVER t=") + juce::String ((juce::int64) juce::Time::getMillisecondCounter()) + + " used=" + juce::String (usedMs, 2) + + "ms budget=" + juce::String (blockMs, 2) + "ms\n"); } void RMX19AudioProcessor::selectPatch (int i) diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index be67ce1..8e3f6d4 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -22,7 +22,7 @@ public: 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; } + double getTailLengthSeconds() const override { return prm::delaySyncMaxTime() + 1.0; } int getNumPrograms() override; int getCurrentProgram() override; @@ -37,10 +37,8 @@ public: PresetBank& getBank() { return bank; } SynthEngine& getEngine() { return engine; } - int getPatchIndex() const noexcept { return patchIndex; } +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; } @@ -59,6 +57,8 @@ private: int lastPatchParam = 0; juce::uint32 lastMidi = 0; float cpu = 0.0f; + float dcL = 0.0f, dcR = 0.0f, dcPrevL = 0.0f, dcPrevR = 0.0f; + juce::AudioBuffer osBuf; // internal 2x oversample target (HQ mode) JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (RMX19AudioProcessor) }; \ No newline at end of file diff --git a/Source/PresetBank.cpp b/Source/PresetBank.cpp index 9391726..dd88d5d 100644 --- a/Source/PresetBank.cpp +++ b/Source/PresetBank.cpp @@ -15,116 +15,284 @@ enum { O_SIN = 0, O_TRI, O_SAW, O_PUL, O_NOI }; 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 } }, + 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.0f, 0, 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 } }, + 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.92f, 0.0f, 12, 0.0f, 0, 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 } }, + 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.0f, 0, 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 } }, + 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.0f, 0, 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 } }, + 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, 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 } }, + 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, 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 } }, + 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.90f, 0.0f, 12, 0.0f, 0, 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 } }, + 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.92f, 0.0f, 12, 0.0f, 0, 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 } }, + 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, 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 } }, + 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.86f, 0.0f, 12, 0.0f, 0, 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 } }, + 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.94f, 0.0f, 1, 0.04f, 0, 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 } }, + 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.94f, 0.0f, 1, 0.03f, 0, 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 } }, + 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, 1, 0.06f, 0, 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 } }, + 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, 1, 0.05f, 0, 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 } }, + 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.0f, 0, 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 } }, + 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, 0 } }, + // 17 Shimmer Pad ----------------------------------------------------- + { "Shimmer Pad", "POLY PAD", { O_TRI, O_TRI, 14, 55, 2, 1800, 0.50f, 60, 20, + 0.50f, 2.0f, 0.85f, 2.0f, + 0.30f, 1.5f, 0.40f, 1.0f, + 5.2f, 0, 0.20f, 2, + 3, 0.38f, 0.55f, + 0.90f, 0.0f, 16, 0.0f, 0, 0 } }, + // 18 Strings Ensemble ------------------------------------------------- + { "Strings Ens.", "POLY PAD", { O_SAW, O_PUL, 8, 58, 1, 3000, 0.10f, 65, 55, + 0.25f, 1.2f, 0.82f, 1.8f, + 0.20f, 0.7f, 0.40f, 0.6f, + 3.8f, 0, 0.10f, 0, + 1, 0.30f, 0.30f, + 0.80f, 0.0f, 16, 0.0f, 0, 0 } }, + // 19 Church Bell ----------------------------------------------------- + { "Church Bell", "KEY", { O_SIN, O_SIN, 0, 40, 2, 3200, 0.40f, 40, 30, + 0.002f, 2.5f, 0.10f, 1.8f, + 0.002f, 0.3f, 0.10f, 0.6f, + 4.4f, 0, 0.05f, 2, + 3, 0.30f, 0.60f, + 0.92f, 0.0f, 10, 0.0f, 0, 0 } }, + // 20 E.Piano 2 ------------------------------------------------------- + { "E.Piano 2", "KEY", { O_PUL, O_TRI, 4, 58, 1, 2500, 0.30f, 40, 35, + 0.003f, 1.2f, 0.40f, 0.5f, + 0.003f, 0.25f, 0.20f, 0.3f, + 6.0f, 0, 0.04f, 2, + 1, 0.35f, 0.30f, + 0.80f, 0.0f, 12, 0.0f, 0, 0 } }, + // 21 Acid Bass ------------------------------------------------------- + { "Acid Bass", "BASS", { O_SAW, O_PUL, 0, 72, 1, 650, 0.60f, 70, 0, + 0.002f, 0.30f, 0.75f, 0.15f, + 0.002f, 0.10f, 0.12f, 0.05f, + 0.6f, 1, 0.30f, 0, + 0, 0.0f, 0.30f, + 0.90f, 0.0f, 1, 0.04f, 0, 0 } }, + // 22 Analog Bass II -------------------------------------------------- + { "Analog Bass II", "BASS", { O_PUL, O_SIN, 0, 82, 1, 420, 0.40f, 45, 20, + 0.002f, 0.25f, 0.80f, 0.12f, + 0.002f, 0.12f, 0.20f, 0.08f, + 4.0f, 0, 0.08f, 1, + 1, 0.25f, 0.20f, + 0.88f, 0.0f, 1, 0.08f, 0, 0 } }, + // 23 Detuned Lead ---------------------------------------------------- + { "Detuned Lead", "LEAD", { O_SAW, O_SAW, 18, 45, 1, 4100, 0.20f, 35, 60, + 0.004f, 0.22f, 0.85f, 0.16f, + 0.004f, 0.12f, 0.30f, 0.10f, + 6.8f, 0, 0.09f, 1, + 2, 0.25f, 0.45f, + 0.85f, 0.0f, 1, 0.05f, 0, 0 } }, + // 24 Square Lead ----------------------------------------------------- + { "Square Lead", "LEAD", { O_PUL, O_PUL, 10, 55, 1, 5200, 0.25f, 30, 65, + 0.004f, 0.35f, 0.72f, 0.20f, + 0.004f, 0.15f, 0.25f, 0.12f, + 7.2f, 0, 0.08f, 1, + 1, 0.30f, 0.25f, + 0.85f, 0.0f, 1, 0.06f, 0, 0 } }, + // 25 Deep Space Pad -------------------------------------------------- + { "Deep Space Pad","POLY PAD", { O_SAW, O_SAW, 16, 60, 1, 1500, 0.45f, 70, 35, + 0.90f, 2.4f, 0.85f, 2.2f, + 0.50f, 1.6f, 0.45f, 1.2f, + 5.0f, 0, 0.22f, 2, + 3, 0.42f, 0.70f, + 0.80f, 0.0f, 16, 0.0f, 0, 0 } }, + // 26 Liquid Keys ----------------------------------------------------- + { "Liquid Keys", "KEY", { O_TRI, O_TRI, 8, 55, 1, 2800, 0.30f, 55, 30, + 0.30f, 1.6f, 0.60f, 0.90f, + 0.20f, 1.0f, 0.30f, 0.60f, + 3.6f, 0, 0.16f, 2, + 3, 0.36f, 0.50f, + 0.84f, 0.0f, 12, 0.0f, 0, 0 } }, + // 27 Techno Bass ----------------------------------------------------- + { "Techno Bass", "BASS", { O_SAW, O_PUL, 0, 88, 1, 480, 0.45f, 60, 10, + 0.003f, 0.40f, 0.75f, 0.10f, + 0.003f, 0.12f, 0.15f, 0.08f, + 2.0f, 2, 0.35f, 0, + 2, 0.32f, 0.28f, + 0.92f, 0.0f, 1, 0.03f, 0, 0 } }, + // 28 Octave Lead ----------------------------------------------------- + { "Octave Lead", "LEAD", { O_PUL, O_SAW, 0, 45, 1, 4200, 0.20f, 25, 65, + 0.004f, 0.28f, 0.78f, 0.18f, + 0.004f, 0.14f, 0.28f, 0.12f, + 6.0f, 0, 0.12f, 1, + 2, 0.28f, 0.35f, + 0.86f, 0.0f, 1, 0.05f, 0, 0 } }, + // 29 Glassy Chime ---------------------------------------------------- + { "Glassy Chime", "KEY", { O_SIN, O_TRI, 20, 65, 2, 4200, 0.35f, 35, 40, + 0.005f, 2.0f, 0.30f, 1.6f, + 0.004f, 0.35f, 0.15f, 0.6f, + 5.6f, 0, 0.08f, 2, + 3, 0.34f, 0.60f, + 0.82f, 0.0f, 10, 0.0f, 0, 0 } }, + // 30 Chorus Organ ---------------------------------------------------- + { "Chorus Organ", "KEY", { O_SAW, O_SAW, 0, 42, 1, 8000, 0.14f, 30, 75, + 0.010f, 0.10f, 0.95f, 0.12f, + 0.010f, 0.12f, 0.60f, 0.15f, + 7.5f, 3, 0.04f, 1, + 1, 0.40f, 0.34f, + 0.86f, 0.0f, 8, 0.0f, 0, 0 } }, + // 31 Night Strings --------------------------------------------------- + { "Night Strings","POLY PAD", { O_SAW, O_TRI, 10, 52, 0, 2600, 0.10f, 60, 50, + 1.10f, 2.6f, 0.85f, 2.0f, + 0.55f, 1.6f, 0.40f, 1.1f, + 3.2f, 0, 0.14f, 0, + 3, 0.40f, 0.55f, + 0.84f, 0.0f, 16, 0.0f, 0, 0 } }, + // 32 Pulse Echo ------------------------------------------------------ + { "Pulse Echo", "LEAD", { O_PUL, O_PUL, 0, 35, 2, 6000, 0.18f, 15, 60, + 0.005f, 0.32f, 0.70f, 0.20f, + 0.005f, 0.16f, 0.30f, 0.15f, + 8.0f, 0, 0.10f, 0, + 2, 0.45f, 0.38f, + 0.84f, 0.0f, 1, 0.04f, 0, 0 } }, + // 33 Sine Sub Bass --------------------------------------------------- + { "Sine Sub Bass","BASS", { O_SIN, O_SIN, 0, 100, 1, 380, 0.20f, 55, 10, + 0.003f, 0.35f, 0.85f, 0.10f, + 0.003f, 0.10f, 0.30f, 0.10f, + 3.5f, 0, 0.06f, 0, + 2, 0.25f, 0.20f, + 0.90f, 0.0f, 1, 0.05f, 0, 0 } }, + // 34 Space Choir ----------------------------------------------------- + { "Space Choir", "POLY PAD", { O_TRI, O_SAW, 12, 50, 1, 2400, 0.35f, 65, 45, + 0.45f, 1.4f, 0.82f, 1.7f, + 0.30f, 0.90f, 0.40f, 0.9f, + 3.8f, 1, 0.15f, 0, + 3, 0.46f, 0.65f, + 0.82f, 0.0f, 12, 0.0f, 0, 0 } }, + // 35 Retro Clav ------------------------------------------------------ + { "Retro Clav", "KEY", { O_PUL, O_TRI, 2, 55, 1, 2600, 0.35f, 40, 35, + 0.003f, 0.60f, 0.40f, 0.15f, + 0.002f, 0.10f, 0.08f, 0.12f, + 5.0f, 0, 0.05f, 2, + 3, 0.25f, 0.30f, + 0.86f, 0.0f, 10, 0.0f, 0, 0 } }, + // 36 High Strings ---------------------------------------------------- + { "High Strings", "POLY PAD", { O_SAW, O_PUL, 6, 60, 1, 3400, 0.10f, 55, 60, + 0.20f, 1.0f, 0.80f, 1.6f, + 0.15f, 0.60f, 0.40f, 0.7f, + 4.2f, 0, 0.10f, 0, + 1, 0.35f, 0.40f, + 0.82f, 0.0f, 16, 0.0f, 0, 0 } }, + // 37 Echo Flute ------------------------------------------------------ + { "Echo Flute", "LEAD", { O_TRI, O_SIN, 0, 85, 1, 3000, 0.15f, 20, 55, + 0.05f, 0.50f, 0.75f, 0.25f, + 0.04f, 0.30f, 0.40f, 0.20f, + 4.8f, 0, 0.10f, 1, + 2, 0.40f, 0.45f, + 0.86f, 0.0f, 1, 0.06f, 0, 0 } }, + // 38 Dub Rez Bass ---------------------------------------------------- + { "Dub Rez Bass", "BASS", { O_SAW, O_PUL, -5, 60, 1, 500, 0.65f, 45, 5, + 0.004f, 0.35f, 0.80f, 0.12f, + 0.004f, 0.15f, 0.25f, 0.12f, + 0.8f, 3, 0.30f, 0, + 2, 0.40f, 0.36f, + 0.90f, 0.0f, 1, 0.05f, 0, 0 } }, + // 39 Crystal Pad ----------------------------------------------------- + { "Crystal Pad", "POLY PAD", { O_SIN, O_TRI, 18, 55, 2, 3600, 0.40f, 50, 25, + 0.60f, 2.2f, 0.85f, 2.0f, + 0.40f, 1.4f, 0.40f, 1.2f, + 5.5f, 0, 0.20f, 2, + 3, 0.40f, 0.60f, + 0.80f, 0.0f, 12, 0.0f, 0, 0 } }, + // 40 Funk Wah -------------------------------------------------------- + { "Funk Wah", "KEY", { O_PUL, O_SAW, 0, 40, 1, 1800, 0.55f, 75, 20, + 0.004f, 0.70f, 0.50f, 0.20f, + 0.004f, 0.20f, 0.20f, 0.15f, + 7.0f, 2, 0.40f, 0, + 1, 0.32f, 0.30f, + 0.88f, 0.0f, 1, 0.03f, 0, 0 } }, }; PresetBank::PresetBank() @@ -146,6 +314,9 @@ void PresetBank::writeTo (int i, juce::AudioProcessorValueTreeState& apvts) cons i = juce::jlimit (0, n - 1, i); const Patch& p = patches[i]; for (int k = 0; k < prm::num; ++k) + { + if (k == static_cast (prm::Id::hq)) continue; // global, not per-patch 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/Probe.cpp b/Source/Probe.cpp new file mode 100644 index 0000000..100574b --- /dev/null +++ b/Source/Probe.cpp @@ -0,0 +1,121 @@ +// Offline probe: feed the real RMX19AudioProcessor MIDI and analyze output. +#include "PluginProcessor.h" +#include +#include +#include + +static int runScenario (int patch, + bool holdG, + int noteOffCAtBlock, + long* cutAt, + bool hqOn = false) +{ + RMX19AudioProcessor proc; + proc.setRateAndBufferSizeDetails (48000.0, 512); + proc.prepareToPlay (48000.0, 512); + proc.selectPatch (patch); + if (hqOn) + if (auto* h = proc.apvts.getParameter ("hq")) + h->setValue (1.0f); + + const int C = 60, G = 67; + const int totalBlocks = 240; + juce::AudioBuffer buf (2, 512); + std::vector peak (totalBlocks, 0.0f); + + auto noteOn = [](int n, float v){ return juce::MidiMessage::noteOn (1, (juce::uint8) n, v); }; + auto noteOff = [](int n){ return juce::MidiMessage::noteOff (1, (juce::uint8) n); }; + + for (int b = 0; b < totalBlocks; ++b) + { + juce::MidiBuffer midi; + const int cOn = 0, gOn = 40; // C held from block 0, G pressed at block 40 + if (b == cOn) midi.addEvent (noteOn (C, 0.8f), 0); + if (b == gOn) midi.addEvent (noteOn (G, 0.8f), 0); + if (noteOffCAtBlock >= 0 && b == noteOffCAtBlock) midi.addEvent (noteOff (C), 0); + + buf.clear(); + proc.processBlock (buf, midi); + + float p = 0.0f; + for (int s = 0; s < 512; ++s) + p = std::max (p, std::abs (buf.getSample (0, s))); + peak[b] = p; + } + proc.releaseResources(); + + // find the first block after G onset where amplitude collapses for a run + long cmax = 250; + float gPeak = 0.0f; + for (int b = 41; b < totalBlocks; ++b) gPeak = std::max (gPeak, peak[b]); + // detect a "cut": after G onset, a gap where peak < 2% of max for >= 8 blocks + int run = 0; + for (int b = 45; b < totalBlocks; ++b) + { + if (peak[b] < 0.02f * gPeak + 1e-5f) + { + if (++run >= 8) + { + cmax = b - run; + break; + } + } + else run = 0; + } + *cutAt = cmax; + + printf ("patch %2d | G peak=%.3f cut-at-block=%s\n", patch, gPeak, + cmax >= 250 ? "NONE" : std::to_string (cmax).c_str()); + return (int) gPeak; +} + +int main() +{ + long cut = -1; + // patch 18 Strings Ens.: C held, G pressed -> does G survive? + runScenario (18, true, -1, &cut); + // same but release C while G is held + runScenario (18, true, 80, &cut); + // sanity: a working patch + runScenario (1, true, -1, &cut); + runScenario (4, true, -1, &cut); + runScenario (18, true, -1, &cut, /*hqOn*/ true); + + // cover more held keys after C+G while they are held (fills all 16 voices) + { + RMX19AudioProcessor proc; + proc.setRateAndBufferSizeDetails (48000.0, 512); + proc.prepareToPlay (48000.0, 512); + proc.selectPatch (18); + const int totalBlocks = 240; + juce::AudioBuffer buf (2, 512); + std::vector peak (totalBlocks, 0.0f); + std::vector perBlock (totalBlocks, 1.0f); + for (int b = 0; b < totalBlocks; ++b) + { + juce::MidiBuffer midi; + if (b == 0) midi.addEvent (juce::MidiMessage::noteOn (1, 60, 0.8f), 0); + if (b == 40) midi.addEvent (juce::MidiMessage::noteOn (1, 67, 0.8f), 0); + // after G, spread another 14 held notes so all 16 voices are busy + if (b >= 45 && b < 59) midi.addEvent (juce::MidiMessage::noteOn (1, (juce::uint8)(36 + (b - 45)), 0.8f), 0); + buf.clear(); + proc.processBlock (buf, midi); + float p = 0.0f; + for (int s = 0; s < 512; ++s) p = std::max (p, std::abs (buf.getSample (0, s))); + peak[b] = p; + } + // sustain = peak never collapses below 5% of the pre-collapse max for a long stretch + float m50 = 0.0f; + for (int b = 41; b < 120; ++b) m50 = std::max (m50, peak[b]); + int lowRun = 0, maxLowRun = 0; + for (int b = 60; b < totalBlocks; ++b) + { + if (peak[b] < 0.05f * m50) { ++lowRun; maxLowRun = std::max (maxLowRun, lowRun); } + else lowRun = 0; + } + printf ("16-voice fill: block20max=%.3f maxSilentRun=%d blocks %s\n", + m50, maxLowRun, maxLowRun >= 12 ? "<- CUT detected" : "sustained"); + proc.releaseResources(); + } + return 0; +} \ No newline at end of file diff --git a/Source/SynthEngine.cpp b/Source/SynthEngine.cpp index 9982d76..5290565 100644 --- a/Source/SynthEngine.cpp +++ b/Source/SynthEngine.cpp @@ -64,8 +64,9 @@ SynthEngine::Voice* SynthEngine::acquireVoice() void SynthEngine::noteOn (int midiNote, float velocity) { - const float noteFreq = (float) (440.0 * std::pow (2.0, (midiNote - 69.0) / 12.0)); + const float noteFreq = (float) (440.0 * std::exp2 ((midiNote - 69.0) / 12.0)); Voice* v = acquireVoice(); + const bool fresh = ! v->active; v->active = true; v->note = midiNote; v->vel = 1.0f - std::pow (1.0f - velocity, 1.35f); @@ -77,6 +78,7 @@ void SynthEngine::noteOn (int midiNote, float velocity) v->lfoIdx = 0; v->lfoSH = rng.nextFloat() * 2.0f - 1.0f; v->lp = v->bp = 0.0f; + v->fcSm = 0.0f; v->outSmooth = 0.0f; const float p = juce::jlimit (-1.0f, 1.0f, lastPan + (rng.nextFloat() - 0.5f) * 0.1f); @@ -88,7 +90,7 @@ void SynthEngine::noteOn (int midiNote, float velocity) v->amp.reset(); v->flt.reset(); v->amp.noteOn(); v->flt.noteOn(); lastNoteFreq = noteFreq; - ++active; + if (fresh) ++active; } void SynthEngine::noteOff (int midiNote) @@ -106,9 +108,6 @@ void SynthEngine::noteOff (int midiNote) 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); @@ -120,8 +119,8 @@ void SynthEngine::render (const VParams& p, float* left, float* right, int num) 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 detuneRatio = std::exp2f (p.detune / 1200.0f); + const float tuneRatio = std::exp2f (p.tune / 12.0f); const float maxFc = 18000.0f, minFc = 30.0f; for (int s = 0; s < num; ++s) @@ -160,10 +159,10 @@ void SynthEngine::render (const VParams& p, float* left, float* right, int num) // 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); + freq *= std::exp2f (lfo * p.lfoDepth * 1.6f / 12.0f); if (bendSmooth != bendCents) bendSmooth += (bendCents - bendSmooth) * 0.08f; - freq *= std::pow (2.0f, bendSmooth / 1200.0f); + freq *= std::exp2f (bendSmooth / 1200.0f); const float d1 = (float) (freq / sr); const float d2 = d1 * detuneRatio; @@ -180,16 +179,19 @@ void SynthEngine::render (const VParams& p, float* left, float* right, int num) // 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); + fc *= std::exp2f (p.fltKey * 0.01f * std::log2f (freq / 261.6256f)); + fc *= std::exp2f (p.fltEnv * 0.01f * fe * std::log2f (maxFc / fc)); 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::exp2f (lfo * p.lfoDepth * 10.0f / 12.0f); + fc *= std::exp2f (mod); fc = std::min (maxFc, std::max (minFc, fc)); + v.fcSm += (fc - v.fcSm) * 0.25f; + fc = v.fcSm; - // 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); + // SVF (cap f1 to keep the Chamberlin structure stable; past ~1.5 the + // resonance feedback can self-oscillate and sputter at maxed cutoff+res) + const float f1 = std::min (1.50f, 2.0f * std::sin (juce::MathConstants::pi * fc / (float) sr)); + const float q1 = 1.0f / (1.0f + p.fltRes * 3.0f); v.f1c = f1; const float hp = sig - v.lp - q1 * v.bp; v.bp += f1 * hp; @@ -211,7 +213,8 @@ void SynthEngine::render (const VParams& p, float* left, float* right, int num) 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; + float o = v.outSmooth * (ae * ampMod) * v.vel; + if (! std::isfinite (o)) o = 0.0f; mixL += o * v.panL; mixR += o * v.panR; @@ -223,13 +226,12 @@ void SynthEngine::render (const VParams& p, float* left, float* right, int num) } } - peakL = std::max (peakL, std::abs (mixL)); - peakR = std::max (peakR, std::abs (mixR)); + if (! std::isfinite (mixL)) mixL = 0.0f; + if (! std::isfinite (mixR)) mixR = 0.0f; + mixL = std::tanh (mixL); + mixR = std::tanh (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 index 01651ca..a2d54ec 100644 --- a/Source/SynthEngine.h +++ b/Source/SynthEngine.h @@ -11,6 +11,7 @@ struct VParams 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; + float hq = 0.0f; void update (const juce::AudioProcessorValueTreeState& apvts) { @@ -28,6 +29,7 @@ struct VParams fxType = get ("fxtype"); fxAmt = get ("fxamt"); fxTime = get ("fxtime"); vol = get ("vol"); pan = get ("pan"); poly = get ("poly"); glide = get ("glide"); tune = get ("tune"); + hq = get ("hq"); } }; @@ -108,7 +110,6 @@ public: 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 @@ -123,6 +124,7 @@ private: int lfoIdx = 0; Env amp, flt; float lp = 0.0f, bp = 0.0f, f1c = 0.0f; + float fcSm = 0.0f; float outSmooth = 0.0f; }; @@ -135,7 +137,6 @@ private: 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; diff --git a/b2.png b/b2.png new file mode 100644 index 0000000..1d89fcd Binary files /dev/null and b/b2.png differ