Compare commits
16
Commits
5b99f51bd1
...
42d2302c28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d2302c28 | ||
|
|
05fd6440bd | ||
|
|
a50666355c | ||
|
|
f10409ad1e | ||
|
|
a1feeb76f0 | ||
|
|
421f5c6ced | ||
|
|
769e85e4e4 | ||
|
|
6e4d399a04 | ||
|
|
0a94208d0f | ||
|
|
a9abca9e15 | ||
|
|
5a80534a2e | ||
|
|
4021b9cdf7 | ||
|
|
44960b9acb | ||
|
|
fc9d16b302 | ||
|
|
6e2859a1f6 | ||
|
|
d6705ab29f |
+18
@@ -0,0 +1,18 @@
|
||||
# CMake build directories
|
||||
/build/
|
||||
/build_*/
|
||||
/cmake-build-*/
|
||||
|
||||
# Windows output staging
|
||||
/SerumAlt_Windows/
|
||||
|
||||
# CMake generated files
|
||||
CMakeUserPresets.json
|
||||
compile_commands.json
|
||||
|
||||
# IDE and OS files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.user
|
||||
.DS_Store
|
||||
.cache/
|
||||
@@ -0,0 +1,83 @@
|
||||
# SerumAlt agent guidance
|
||||
|
||||
## Project
|
||||
|
||||
SerumAlt is a JUCE 7 wavetable synthesiser using C++17. Project metadata is in
|
||||
`CMakeLists.txt`. Production formats are VST3 and Standalone on Linux, VST3/AU/Standalone
|
||||
on macOS, and VST3 on Windows. First-party classes use `namespace serum`; executable
|
||||
entry points and JUCE's `createPluginFilter` are outside that namespace.
|
||||
|
||||
## Build and verification
|
||||
|
||||
Read README.md's Building section before changing scripts, dependencies, or CMake.
|
||||
It is the maintained source for prerequisites, options and artifact paths.
|
||||
|
||||
- Linux production: `./build_linux.sh`.
|
||||
- macOS production, on a Mac: `./build_macos.sh`.
|
||||
- Windows x64 VST3, cross-compiled on Linux: `./build_windows.sh`.
|
||||
- Native QA build and execution: `./build_harness.sh --run`.
|
||||
- Shell syntax: `for script in build_*.sh; do bash -n "$script"; done`.
|
||||
|
||||
Production scripts explicitly enable `SERUMALT_BUILD_PLUGIN` and disable
|
||||
`SERUMALT_BUILD_TESTS`. The harness script does the reverse in a separate directory.
|
||||
Building `SerumAltTest` alone does not execute it; `--run` invokes CTest.
|
||||
When changing CMake, verify production has no `SerumAltTest` target and the
|
||||
harness-only build has no `SerumAlt`, VST3, AU, or Standalone target.
|
||||
|
||||
Keep existing build trees and local changes intact. Use a new `BUILD_DIR` for
|
||||
clean-build verification. Use a fresh Windows `OUTPUT_DIR` for distribution checks,
|
||||
since staging does not remove extra files. Never edit generated CMakeCache.txt files.
|
||||
The checked-in MinGW toolchain is the single source of cross-compiler configuration.
|
||||
|
||||
## Current source layout
|
||||
|
||||
`CMakeLists.txt` owns the explicit `SERUMALT_SOURCES` list and shared target setup.
|
||||
`cmake/Plugin.cmake` owns production formats and MinGW link flags;
|
||||
`cmake/Harness.cmake` owns the console harness and CTest registration.
|
||||
The harness compiles the same processor/editor sources independently of the plugin.
|
||||
|
||||
Most C++ files still live directly under `Source/`. Existing implementation
|
||||
subdirectories are `EffectUnits/`, `GUI/`, `Presets/`, `Resources/`, and `Tests/`.
|
||||
`FXProcessor`, `PluginEditor`, `Resources`, and `RAVEButton` remain at the source root.
|
||||
`RAVEButton.{h,cpp}` defines `RaveController`.
|
||||
|
||||
The lowercase `Source/engine`, `modulation`, `params`, `plugin`, and `synth`
|
||||
directories currently hold guidance, not relocated implementations. Consult their
|
||||
AGENT.md files when editing the related root-level sources. Consult the matching
|
||||
AGENT.md when editing an existing implementation subdirectory.
|
||||
`AUDIT_REPORT.md` is a historical audit and proposed refactor, not a pending
|
||||
instruction to move files. Update the explicit source list and includes only when
|
||||
an actual source move is part of the requested task.
|
||||
|
||||
## Style and ownership
|
||||
|
||||
Follow the surrounding file. Existing C++ generally uses four-space indentation,
|
||||
`#pragma once`, PascalCase types, camelCase members without prefixes, `k`-prefixed
|
||||
constants, scoped enums, and `noexcept` on cheap getters and DSP paths. Use `Count`
|
||||
only for enums that need a size or iteration sentinel. Headers include JuceHeader;
|
||||
implementation files normally include their own header first.
|
||||
|
||||
Prefer value members or `std::unique_ptr` for owned objects and attachments.
|
||||
`addAndMakeVisible` does not transfer ownership or delete children. Raw allocations
|
||||
returned by `createEditor` and `createPluginFilter` transfer ownership to JUCE callers.
|
||||
Guard parameter lookups and invalid state data. Use `jassert` sparingly for programmer
|
||||
errors and return `bool` from bounded inserts to report failure.
|
||||
|
||||
## Real-time and concurrency requirements
|
||||
|
||||
For new or changed audio paths, allocate buffers during preparation and avoid heap
|
||||
allocation, blocking locks and I/O in `processBlock` or per-sample DSP. Account for
|
||||
variable host block sizes without allocating in the callback. Keep GUI updates on
|
||||
the JUCE message thread. Exchange GUI/audio state through a safe snapshot or bounded
|
||||
handoff; a timer or a writer-only mutex does not make concurrent access safe.
|
||||
|
||||
These are requirements, not claims that the current code already satisfies them.
|
||||
The historical audit records lazy wavetable allocation, per-block buffer resizing,
|
||||
and unsynchronised modulation/LFO edits. When changing those paths, inspect the
|
||||
current implementation and verify the fix separately. The QA smoke test does not
|
||||
prove real-time safety or absence of data races.
|
||||
|
||||
## Scope of guidance
|
||||
|
||||
Module guidance refines this file for its related sources. Preserve the root
|
||||
real-time and ownership requirements if an example conflicts with them.
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
# SerumAlt codebase audit (Phase 1)
|
||||
|
||||
Historical snapshot, taken before the platform/harness build separation. Build
|
||||
commands, defaults, target locations and line numbers below describe that earlier
|
||||
state. Use README.md's Building section and the current CMake files for builds.
|
||||
The proposed source moves are suggestions, not an approved implementation plan.
|
||||
|
||||
Scope: the SerumAlt C++/JUCE VST3 + Standalone synthesiser. This report records the
|
||||
implicit standards, anti-patterns, memory choices, error handling, concurrency model and
|
||||
module boundaries found by reading the real repository files. No source, build or
|
||||
documentation files were modified. All findings are cited with `path:line` references read
|
||||
during this task.
|
||||
|
||||
Repository note: at the start of this audit the working tree already had uncommitted
|
||||
changes to `CMakeLists.txt`, several `Source/` files and several `third_party/JUCE/` files,
|
||||
plus untracked `build/`, `build_windows/` and `SerumAlt_Windows/` directories. Those
|
||||
changes predate this audit and are not analysed here except where the current on-disk
|
||||
content is the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build & test setup
|
||||
|
||||
### Project metadata and targets
|
||||
|
||||
- Project name and version: `project(SerumAlt VERSION 1.0.0 LANGUAGES CXX)` at `CMakeLists.txt:3`.
|
||||
- C++ standard: C++17, required, extensions off at `CMakeLists.txt:8-10`
|
||||
(`CMAKE_CXX_STANDARD 17`, `CMAKE_CXX_STANDARD_REQUIRED ON`, `CMAKE_CXX_EXTENSIONS OFF`).
|
||||
- The plugin target is `SerumAlt`, declared with `juce_add_plugin(...)` at
|
||||
`CMakeLists.txt:33-46`. Formats are `VST3 AU Standalone` at `CMakeLists.txt:44`. JUCE
|
||||
generates the concrete per-format targets (`SerumAlt_VST3`, `SerumAlt_Standalone`, and
|
||||
`SerumAlt_AU` on macOS). There is no separate `PluginProcessor`/`PluginEditor` target;
|
||||
those are ordinary classes compiled into `SerumAlt`.
|
||||
- A console test target `SerumAltTest` is added at `CMakeLists.txt:126-140` via
|
||||
`juce_add_console_app`. It compiles the entire `${SERUMALT_SOURCES}` list plus
|
||||
`Source/Tests/TestMain.cpp` (`CMakeLists.txt:128`).
|
||||
|
||||
### Source list
|
||||
|
||||
Sources are an explicit list, not a glob. `set(SERUMALT_SOURCES ...)` spans
|
||||
`CMakeLists.txt:50-87` and is consumed by `target_sources(SerumAlt PRIVATE
|
||||
${SERUMALT_SOURCES})` at `CMakeLists.txt:89`. The list enumerates every `.cpp` used by the
|
||||
plugin, including the subdirectory files `Source/EffectUnits/*.cpp`, `Source/GUI/*.cpp`
|
||||
and `Source/Presets/FactoryPresets.cpp`.
|
||||
|
||||
Two header-only files are intentionally absent from the list because they have no
|
||||
translation unit: `Source/EffectUnits/Biquad.h` and `Source/GUI/SerumLookAndFeel.h`.
|
||||
|
||||
Consequence for later phases: any file move or rename must update this list, because there
|
||||
is no glob to pick files up automatically.
|
||||
|
||||
### Custom flags and linkage
|
||||
|
||||
- Compile definitions at `CMakeLists.txt:91-95`: `JUCE_WEB_BROWSER=0`, `JUCE_USE_CURL=0`,
|
||||
`JUCE_VST3_CAN_REPLACE_VST2=0`.
|
||||
- Link libraries at `CMakeLists.txt:97-103`: `juce::juce_audio_utils` and `juce::juce_dsp`
|
||||
(private), `juce::juce_recommended_config_flags` and `juce::juce_recommended_warning_flags`
|
||||
(public).
|
||||
- Warning flags at `CMakeLists.txt:142-146`: `/W4` under MSVC, `-Wall -Wextra` otherwise.
|
||||
- MinGW cross-build static runtime at `CMakeLists.txt:113-118`: when
|
||||
`CMAKE_CROSSCOMPILING`, `SerumAlt_VST3` gets `-static-libgcc -static-libstdc++ -static`
|
||||
so the DLL is self-contained.
|
||||
- VST3 manifest handling at `CMakeLists.txt:23-31`: `SERUMALT_VST3_AUTO_MANIFEST` is `FALSE`
|
||||
when cross-compiling (because JUCE 7.0.12 builds the manifest helper with the same
|
||||
toolchain, producing a Windows `.exe` that cannot run on the Linux host). The manifest is
|
||||
instead injected by `build_windows.sh`.
|
||||
|
||||
### Windows cross-build (build_windows.sh)
|
||||
|
||||
`build_windows.sh` is the canonical Windows build path. Exact steps, in order:
|
||||
|
||||
1. Verify `x86_64-w64-mingw32-g++` exists, else exit with an error (`build_windows.sh:16-21`).
|
||||
2. Regenerate `mingw64-toolchain.cmake` from a heredoc inside the script
|
||||
(`build_windows.sh:26-42`). This is identical in content to the checked-in
|
||||
`mingw64-toolchain.cmake` (see below), so the toolchain file is defined in two places.
|
||||
3. Configure: `cmake -S . -B build_windows -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
-DCMAKE_TOOLCHAIN_FILE=mingw64-toolchain.cmake -DSERUMALT_BUILD_TESTS=OFF`
|
||||
(`build_windows.sh:47-50`). Note the test harness is explicitly disabled here.
|
||||
4. Build: `cmake --build build_windows --target SerumAlt_VST3` (`build_windows.sh:53`).
|
||||
5. Inject `Contents/Resources/moduleinfo.json` into the `.vst3` bundle by writing the file
|
||||
directly, because the automatic manifest step was disabled (`build_windows.sh:63-126`).
|
||||
6. Copy the bundle to `SerumAlt_Windows/SerumAlt.vst3` (`build_windows.sh:129-131`).
|
||||
|
||||
The toolchain (`mingw64-toolchain.cmake:1-15`) sets `CMAKE_SYSTEM_NAME Windows`,
|
||||
`CMAKE_SYSTEM_PROCESSOR x86_64`, the `x86_64-w64-mingw32-*` compilers/tools, and
|
||||
`CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32` with `PROGRAM NEVER` and the other
|
||||
`..._MODE_*` values `ONLY`.
|
||||
|
||||
### Native Linux build (README only)
|
||||
|
||||
`README.md:76-78` documents a native build:
|
||||
|
||||
```
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
Outputs listed at `README.md:80-84` are
|
||||
`build/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3` and the standalone app.
|
||||
|
||||
### Tests
|
||||
|
||||
Tests exist. `Source/Tests/TestMain.cpp` is a console harness that instantiates the
|
||||
processor, plays notes through every factory preset, checks for NaN/inf and silence, and
|
||||
toggles RAVE (`TestMain.cpp:37-135`). It returns a non-zero exit code on failure
|
||||
(`TestMain.cpp:134`).
|
||||
|
||||
Tests are run through the native build, documented at `README.md:96-98`:
|
||||
|
||||
```
|
||||
cmake --build build --target SerumAltTest
|
||||
./build/SerumAltTest_artefacts/Release/SerumAltTest
|
||||
```
|
||||
|
||||
The Windows cross-build does not build or run tests: `build_windows.sh:50` passes
|
||||
`-DSERUMALT_BUILD_TESTS=OFF`.
|
||||
|
||||
### Clean build and test command
|
||||
|
||||
There is no clean step in `build_windows.sh`. The script configures into `build_windows/`
|
||||
and builds without removing or cleaning it first. A clean rebuild is not scripted; it would
|
||||
require manually removing `build_windows/` (or invoking Ninja's clean target, which the
|
||||
script does not do). A combined "clean build + run tests" command does not exist: the
|
||||
Windows script disables tests, and the README test instructions assume an already-built
|
||||
native `build/` tree.
|
||||
|
||||
---
|
||||
|
||||
## 2. Proposed module boundaries
|
||||
|
||||
The codebase already has three real subdirectory boundaries (`EffectUnits/`, `GUI/`,
|
||||
`Presets/`) plus `Resources/` (assets only) and `Tests/`. The flat files at `Source/` root
|
||||
mix four distinct concerns, and three root files clearly belong inside existing
|
||||
subdirectories. The proposal below is based on the include graph actually present, not on
|
||||
generic synth architecture.
|
||||
|
||||
### Dependency facts that drive the split
|
||||
|
||||
- `Params.h`/`Params.cpp` depend only on `<JuceHeader.h>` (`Params.h:3`). It declares the
|
||||
`ids`, `maps` namespaces, all enums, `ModSource`/`ModTarget`, and the wavetable name
|
||||
table. It is the shared foundation: nearly every other file includes it.
|
||||
- `Wavetable`, `Oscillator`, `SubOscillator`, `NoiseOscillator`, `Envelope`, `LFO`,
|
||||
`Filter`, `FilterBank`, `SynthVoice` depend only on `Params`, `JuceHeader`, and each
|
||||
other (`Oscillator.h:4-5`, `FilterBank.h:4-5`, `SynthVoice.h:4-12`).
|
||||
- `ModulationMatrix` and `MacroControls` depend only on `Params` (`ModulationMatrix.h:4`,
|
||||
`MacroControls.h:4`).
|
||||
- `RAVEButton.h`/`RaveController` depends only on `Params` (`RAVEButton.h:4`) but manipulates
|
||||
the APVTS, so it sits between modulation and plugin plumbing.
|
||||
- `FXProcessor.h` depends only on `Params` (`FXProcessor.h:4`); every file in
|
||||
`EffectUnits/` includes `../FXProcessor.h` (`Hyper.h:3`, `Chorus.h:3`, etc.), so
|
||||
`FXProcessor` is the base the effect units build on.
|
||||
- `Engine` aggregates the DSP: `Engine.h:4-10` includes `Wavetable`, `SynthVoice`, `LFO`,
|
||||
`FXProcessor`, `ModulationMatrix`, `MacroControls`.
|
||||
- `PluginProcessor.h` includes `Params`, `Engine`, `RAVEButton` (`PluginProcessor.h:4-6`);
|
||||
it is the JUCE `AudioProcessor` boundary plus state/preset/RAVE glue.
|
||||
- `PluginEditor.h` includes `PluginProcessor` and every `GUI/` header
|
||||
(`PluginEditor.h:4-14`); it is pure GUI.
|
||||
- `Resources.h` depends on `JuceHeader` plus `Params` for the format helpers
|
||||
(`Resources.cpp:2`); it holds the theme and SVG strings. The SVG asset files live in
|
||||
`Source/Resources/`, but the `Resources.{h,cpp}` code lives at root, splitting one
|
||||
concept across two locations.
|
||||
- `Presets/FactoryPresets` depends on `Params` and `ModulationMatrix`
|
||||
(`FactoryPresets.h:4-5`).
|
||||
- `Tests/TestMain.cpp` depends only on `PluginProcessor` (`TestMain.cpp:4`).
|
||||
|
||||
### Proposed modules
|
||||
|
||||
| Module | Files (current location) | Depends on |
|
||||
| --- | --- | --- |
|
||||
| params (foundation) | `Params.{h,cpp}` | JuceHeader only |
|
||||
| synth (DSP building blocks + voice) | `Wavetable.{h,cpp}`, `Oscillator.{h,cpp}`, `SubOscillator.{h,cpp}`, `NoiseOscillator.{h,cpp}`, `Envelope.{h,cpp}`, `LFO.{h,cpp}`, `Filter.{h,cpp}`, `FilterBank.{h,cpp}`, `SynthVoice.{h,cpp}` | params |
|
||||
| modulation | `ModulationMatrix.{h,cpp}`, `MacroControls.{h,cpp}`, `RAVEButton.{h,cpp}` | params |
|
||||
| effects | `FXProcessor.{h,cpp}` plus all of `EffectUnits/` (including `Biquad.h`) | params |
|
||||
| engine | `Engine.{h,cpp}` | synth, effects, modulation |
|
||||
| gui | `PluginEditor.{h,cpp}` plus all of `GUI/` (including `SerumLookAndFeel.h`) | params, synth (for wavetable display), plugin |
|
||||
| resources | `Resources.{h,cpp}` plus `Resources/*.svg` | params |
|
||||
| presets | `Presets/FactoryPresets.{h,cpp}` | params, modulation |
|
||||
| plugin (JUCE plumbing) | `PluginProcessor.{h,cpp}` | engine, modulation, presets, params |
|
||||
| tests | `Tests/TestMain.cpp` | plugin |
|
||||
|
||||
### Where the current layout violates the boundaries
|
||||
|
||||
- `FXProcessor.{h,cpp}` sits at `Source/` root but is the base class and owner of the
|
||||
`EffectUnits/` classes; `EffectUnits/*.h` include it as `../FXProcessor.h`. It belongs in
|
||||
`EffectUnits/` (or in a renamed `FX/` directory with the units).
|
||||
- `PluginEditor.{h,cpp}` sits at root but is pure GUI and includes every `GUI/` header. It
|
||||
belongs in `GUI/`.
|
||||
- `Resources.{h,cpp}` holds the theme and SVG strings, while the `.svg` assets live in
|
||||
`Source/Resources/`. The code and assets should live together under one `resources`
|
||||
module.
|
||||
- `RAVEButton.{h,cpp}` is orphaned at root under a filename that does not match its class
|
||||
(`RaveController`). It is a macro-like boost over the APVTS, so it fits the `modulation`
|
||||
module, though its APVTS manipulation also makes `plugin` a defensible home.
|
||||
- The remaining root files (`Params`, `Wavetable`, `Oscillator`, `SubOscillator`,
|
||||
`NoiseOscillator`, `Envelope`, `LFO`, `Filter`, `FilterBank`, `SynthVoice`,
|
||||
`ModulationMatrix`, `MacroControls`, `Engine`, `PluginProcessor`) are the synthesis core
|
||||
and JUCE boundary. Grouping them into `synth/` and `modulation/` subdirectories would
|
||||
match the dependencies above, but the flat layout is at least internally consistent:
|
||||
they are the only files that legitimately belong at root today.
|
||||
|
||||
---
|
||||
|
||||
## 3. Memory management standards
|
||||
|
||||
### Dominant idiom: value semantics plus `std::unique_ptr` for ownership
|
||||
|
||||
- Owned sub-objects are held by value, not by pointer: `juce::AudioProcessorValueTreeState
|
||||
parameters; Engine engine; RaveController rave;` at `PluginProcessor.h:56-58`; the
|
||||
`Engine` holds `std::array<SynthVoice, kNumVoices> voices`, `std::array<LFO, kNumLfos>
|
||||
lfos`, and `WavetableLibrary`, `FXProcessor`, `ModulationMatrix`, `MacroControls` by value
|
||||
at `Engine.h:45-50`.
|
||||
- Owned polymorphic objects use `std::unique_ptr`: `std::array<std::unique_ptr<FXUnit>, 9>
|
||||
units` at `FXProcessor.h:50`, populated with `std::make_unique` at `FXProcessor.cpp:15-26`.
|
||||
- GUI attachments are owned by `std::unique_ptr`: `std::vector<std::unique_ptr<...
|
||||
SliderAttachment>>` and `...ComboBoxAttachment` at `PluginEditor.h:103-104`, filled at
|
||||
`PluginEditor.cpp:169-170` and `183-184`.
|
||||
- Transient GUI objects are `std::unique_ptr`: `std::unique_ptr<juce::Label> valuePopup`
|
||||
at `Knob.h:38`, created at `Knob.cpp:61` and released with `valuePopup.reset()` at
|
||||
`Knob.cpp:83`.
|
||||
- JUCE-managed objects are returned as raw `new` pointers because JUCE takes ownership
|
||||
through `addAndMakeVisible` or the plugin API: `createEditor()` returns
|
||||
`new PluginEditor (*this)` at `PluginProcessor.cpp:195`; the plugin entry point returns
|
||||
`new serum::SerumAltAudioProcessor()` at `PluginProcessor.cpp:380`; GUI children are
|
||||
created with `new` at `PluginEditor.cpp:166,178,193,602,606`. These raw pointers are never
|
||||
deleted by the code, which is correct only because JUCE's component tree and plugin host
|
||||
own them.
|
||||
- Non-owning cross-object references are raw pointers or references: `juce::RangedAudioParameter*
|
||||
param` at `ToggleButton.h:38`, `const WavetableLibrary* wtLib` at `WaveformDisplay.h:24`,
|
||||
and the `RenderContext` raw `const` pointers to the library/matrix/macros at
|
||||
`SynthVoice.h:24,32-33`.
|
||||
|
||||
### Containers and buffers
|
||||
|
||||
- `std::vector<float>` is the standard delay line / table buffer: `Wavetable.h:24`
|
||||
(`frames`), `Filter.h:39` (`combLine`), `Chorus.h:21`, `Delay.h:21`, `Reverb.h:22-29`.
|
||||
- `std::array` is used for fixed-size DSP state: `Oscillator.h:62`
|
||||
(`std::array<SubVoice, kMaxUnison>`), `Filter.h:36,44`, `Reverb.h:22-27`.
|
||||
- `juce::AudioBuffer<float>` is used for block buffers: `Engine.h:59` (`mixBuffer`),
|
||||
`SynthVoice.h:95` (`scratch`), `FXProcessor.h:51` (`dry`, `wet`). These are sized once in
|
||||
`prepare()` (for example `Engine.cpp:68`, `SynthVoice.cpp:23`, `FXProcessor.cpp:34-35`)
|
||||
and cleared per block.
|
||||
|
||||
### Realtime vs non-realtime allocation
|
||||
|
||||
The code intends to allocate only at prepare time, but it does not fully achieve this:
|
||||
|
||||
- Prepare-time allocation is the norm: delay lines and buffers are sized in `prepare()`
|
||||
(`Filter.cpp:24`, `Chorus.cpp:9-11`, `Reverb.cpp:17-33`, `Engine.cpp:68`).
|
||||
- Violation 1: `Engine::processBlock` calls `mixBuffer.setSize (2, n, false, false, true)`
|
||||
on every block at `Engine.cpp:211`. `AudioBuffer::setSize` is a no-op when the size is
|
||||
unchanged, but it is still a per-callback reconfiguration call on the audio thread.
|
||||
- Violation 2: wavetables are built lazily. `WavetableLibrary::getTable` builds the table
|
||||
on first access (`Wavetable.cpp:126-132`) and `getTable` is reached from the audio path at
|
||||
`SynthVoice.cpp:177-178`. The `prebuild()` method exists at `Wavetable.cpp:120-124` but is
|
||||
never called (it is not referenced anywhere else, and `Engine::prepare` at
|
||||
`Engine.cpp:58-70` does not call it). The first rendered block for a wavetable therefore
|
||||
allocates `256 x 2048` floats plus harmonic work buffers on the audio thread.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error handling
|
||||
|
||||
There is no exception handling and no user-facing alerting. Failures are handled with a
|
||||
mix of asserts, null guards, and return codes.
|
||||
|
||||
- `jassert` is used exactly once in the entire codebase: `jassert (freqHz > 0.0)` at
|
||||
`Oscillator.cpp:20`. There are no `throw`, `try` or `catch` statements (verified by grep).
|
||||
- Null guards with silent fallback are the dominant pattern. The APVTS helper `v()` returns
|
||||
`0.0f` when a parameter is missing: `Engine.cpp:8-13`. State restore silently returns on
|
||||
malformed input: `PluginProcessor.cpp:313-315` returns when the XML is null and
|
||||
`PluginProcessor.cpp:318-319` returns when the `ValueTree` is invalid. Preset loads skip
|
||||
missing parameters with `if (auto* param = ...)` at `PluginProcessor.cpp:248-250`.
|
||||
- Return codes carry failure for the two bounded collections:
|
||||
`ModulationMatrix::addConnection` returns `false` when the target is `None` or the
|
||||
connection limit is reached (`ModulationMatrix.cpp:6-12`), and
|
||||
`MacroControls::addAssignment` returns `false` similarly (`MacroControls.cpp:6-13`).
|
||||
These return values are ignored by their callers (`PluginEditor.cpp:536`, `PluginEditor.cpp:687`,
|
||||
`PluginProcessor.cpp:254`, `PluginProcessor.cpp:258`).
|
||||
- Out-of-range lookups degrade to empty/default values: `getProgramName` returns an empty
|
||||
string for a bad index (`PluginProcessor.cpp:218-224`); the enum-string converters fall
|
||||
through to `return {}` (`Params.cpp:27,44,91`).
|
||||
- There are no `juce::AlertWindow`, no `MessageManager` calls, and no logging in the plugin
|
||||
itself. The only reporting is the test harness printing to `std::cout` and returning an
|
||||
exit code (`TestMain.cpp:73,103,129,132-134`).
|
||||
|
||||
Net style: programmer errors use `jassert` (very sparingly), runtime/state errors use null
|
||||
guards or default values, and bounded-insertion failures use `bool` return codes that
|
||||
callers currently ignore.
|
||||
|
||||
---
|
||||
|
||||
## 5. Concurrency
|
||||
|
||||
The codebase is single-threaded and has no explicit synchronisation. A grep for threads,
|
||||
mutexes, locks, atomics, `CriticalSection`, `SpinLock` and `ScopedLock` returns nothing
|
||||
outside JUCE's own headers.
|
||||
|
||||
- The only non-audio execution context is the JUCE message thread, driven by a `juce::Timer`:
|
||||
`PluginEditor` inherits `juce::Timer` (`PluginEditor.h:24`), starts it at 30 Hz
|
||||
(`PluginEditor.cpp:135`), and refreshes visuals in `timerCallback` (`PluginEditor.cpp:875-880`).
|
||||
- The audio thread is `SerumAltAudioProcessor::processBlock` (`PluginProcessor.cpp:187-191`)
|
||||
delegating to `Engine::processBlock` (`Engine.cpp:176-318`). It reads the playhead tempo at
|
||||
`Engine.cpp:184-187`.
|
||||
|
||||
The significant issue is unsynchronised sharing between the message thread and the audio
|
||||
thread:
|
||||
|
||||
- The GUI mutates `ModulationMatrix::connections` via `addConnection`/`removeConnection`/
|
||||
`clear` (`PluginEditor.cpp:536,549,559`) while the audio thread iterates the same vector in
|
||||
`SynthVoice::render` at `SynthVoice.cpp:114`. `connections` is a public `std::vector`
|
||||
(`ModulationMatrix.h:30`).
|
||||
- The GUI mutates `MacroControls::assignments` (`PluginEditor.cpp:687,699`) while the audio
|
||||
thread iterates it at `SynthVoice.cpp:125`. `assignments` is a public `std::array` of
|
||||
vectors (`MacroControls.h:28`).
|
||||
- The GUI writes LFO shape data through `engine.setLfoShapeData` (`PluginEditor.cpp:483-486`)
|
||||
which calls `LFO::setShapeData` (`Engine.cpp:84-88`, `LFO.cpp:51-59`), while the audio
|
||||
thread reads the same `shapeBuffer` in `LFO::shapeValue` (`LFO.cpp:82-91`).
|
||||
|
||||
None of these shared collections are locked or double-buffered. This is a real data race
|
||||
today: resizing `connections` while the audio thread iterates it is undefined behaviour.
|
||||
|
||||
The processor header acknowledges GUI-thread access but does not address the race: the
|
||||
comment "Public DSP state (read/write from the GUI thread)" at `PluginProcessor.h:55` only
|
||||
describes intent.
|
||||
|
||||
---
|
||||
|
||||
## 6. Naming & style conventions
|
||||
|
||||
- Namespace: every file is inside `namespace serum` (`PluginProcessor.h:8`, `Engine.h:12`,
|
||||
etc.). File-local helpers use an anonymous namespace (`Engine.cpp:6`, `SynthVoice.cpp:6`,
|
||||
`PluginEditor.cpp:6`, `FactoryPresets.cpp:6`).
|
||||
- Include guard: `#pragma once` in every header (for example `PluginProcessor.h:1`,
|
||||
`Params.h:1`, `Biquad.h:1`). No `#ifndef` guards exist.
|
||||
- Include order: `<JuceHeader.h>` first, then local headers (`PluginProcessor.h:3-6`).
|
||||
Subdirectory files include root files with a `../` relative path (`Hyper.h:3`,
|
||||
`Knob.h:5`, `FactoryPresets.h:4`).
|
||||
- File/header pairs: one primary class per `.h`/`.cpp` pair, with the pair named after the
|
||||
class (`Oscillator.h`/`Oscillator.cpp`, `FilterBank.h`/`FilterBank.cpp`). Header-only
|
||||
helpers exist where they have no state logic worth a TU (`Biquad.h`,
|
||||
`SerumLookAndFeel.h`).
|
||||
- Class and struct names: PascalCase, no prefix (`SynthVoice`, `RenderContext`,
|
||||
`FilterBankParams`, `ModConnection`).
|
||||
- Members: no `m_` prefix and no trailing underscore. Plain camelCase fields such as
|
||||
`note`, `velocity`, `baseFreq`, `active`, `released`, `noteId` at `SynthVoice.h:83-88`,
|
||||
and `sr`, `blockSize`, `bpm`, `pitchBend` at `Engine.h:52-57`.
|
||||
- Constants: `k` prefix for compile-time constants (`kNumVoices`, `kNumLfos` at
|
||||
`Params.h:221-227`; `kFrames`, `kTableSize` at `Wavetable.h:18-19`; `kMaxConnections` at
|
||||
`ModulationMatrix.h:28`; `kMaxAssignments` at `MacroControls.h:26`). Local constant arrays
|
||||
in anonymous namespaces also use `k` (`kEnvAttack` at `Engine.cpp:30`, `kFxType` at
|
||||
`PluginEditor.cpp:97`).
|
||||
- Enums: `enum class` (scoped) with PascalCase enumerators (`WarpMode::BendPlus`,
|
||||
`ModSource::Lfo1`, `ModTarget::Filter1Cutoff`) at `Params.h:213-219` and `Params.h:232-256`.
|
||||
- Namespaces for related constants: `ids::` for parameter ID strings (`Params.h:18`),
|
||||
`theme::` for colours (`Resources.h:12`), `maps::` for unit mappings (`Params.h:309`).
|
||||
- Method naming: camelCase with a leading verb (`noteOn`, `processAdd`, `getValue`).
|
||||
JUCE overrides keep JUCE's spelling (`processBlock`, `prepareToPlay`, `resized`,
|
||||
`timerCallback`).
|
||||
- `noexcept` is applied consistently to the per-sample DSP hot paths (`Oscillator.cpp:93`,
|
||||
`Filter.cpp:181,236`, `Wavetable.cpp:81`, `Envelope.cpp:54`, `SynthVoice.cpp:72`,
|
||||
`LFO.cpp:98`) and to cheap getters (`SynthVoice.h:71-74`).
|
||||
- Formatting: two-space indentation; a space before the opening parenthesis in function
|
||||
calls and definitions (`f (x)` at `PluginProcessor.cpp:12`, `juce::jlimit (...)`;
|
||||
`if (...)`, `for (...)`). Aligned member initialiser lists (`PluginEditor.cpp:113-115`).
|
||||
- Section dividers: `// ===...` banners at class/file tops and `// ---...` dividers within
|
||||
files (`PluginProcessor.cpp:17,198,261`, `PluginEditor.cpp:161`).
|
||||
- Casting idiom: `(size_t)` casts on int loop indices when indexing `std::vector`/`std::array`
|
||||
(`Engine.cpp:220`, `SynthVoice.cpp:79`, `FactoryPresets.cpp` throughout), and `(int)`
|
||||
casts on `size()` results.
|
||||
|
||||
---
|
||||
|
||||
## 7. Anti-pattern catalog
|
||||
|
||||
Each entry lists the location, the problem, and the standard that a later AGENT.md phase
|
||||
should encode.
|
||||
|
||||
1. Parameter ID tables duplicated three times.
|
||||
- `Params.h:18-208` (canonical `ids::`), `Engine.cpp:30-55` (`kEnv*`, `kLfo*`, `kFx*`),
|
||||
`PluginEditor.cpp:97-108,433-437,458-464,820-825,833` (the same arrays again), plus the
|
||||
display names and defaults in `PluginProcessor.cpp:31-171`.
|
||||
- Standard: one source of truth in `Params.h` (or a single generated table); engine and
|
||||
editor must reference it rather than re-declaring ID arrays.
|
||||
|
||||
2. Wavetable library allocates on the audio thread.
|
||||
- `WavetableLibrary::getTable` builds lazily (`Wavetable.cpp:126-132`), reached from
|
||||
`SynthVoice::render` (`SynthVoice.cpp:177-178`); `prebuild()` is defined
|
||||
(`Wavetable.cpp:120-124`) but never called.
|
||||
- Standard: call `prebuild()` (or build all tables) in `prepareToPlay`, never in the
|
||||
render path.
|
||||
|
||||
3. Per-block buffer resize in the audio callback.
|
||||
- `Engine::processBlock` calls `mixBuffer.setSize(...)` every block at `Engine.cpp:211`.
|
||||
- Standard: size the buffer once in `prepare()` and only `clear()` per block.
|
||||
|
||||
4. Unsynchronised GUI/audio sharing of container state.
|
||||
- `ModulationMatrix::connections` is public (`ModulationMatrix.h:30`), written by the GUI
|
||||
(`PluginEditor.cpp:536,549,559`) and read on the audio thread (`SynthVoice.cpp:114`).
|
||||
`MacroControls::assignments` is public (`MacroControls.h:28`), written
|
||||
(`PluginEditor.cpp:687,699`) and read (`SynthVoice.cpp:125`). LFO `shapeBuffer` is
|
||||
written via `setShapeData` (`LFO.cpp:51-59`) and read (`LFO.cpp:82-91`).
|
||||
- Standard: either protect these collections with a lock, or use atomic/double-buffered
|
||||
exchange between the message and audio threads.
|
||||
|
||||
5. Class name does not match its file.
|
||||
- `Source/RAVEButton.h` declares `RaveController` (`RAVEButton.h:14`), and
|
||||
`RAVEButton.cpp` implements it.
|
||||
- Standard: file pair name matches the class (`RaveController.h/.cpp`), or the class is
|
||||
renamed to match the file.
|
||||
|
||||
6. Dead code in `SubOscillator::noteOn`.
|
||||
- `SubOscillator.cpp:9-12` computes `mult` and then discards it with `(void) freqHz;
|
||||
(void) mult;` because the octave shift is already baked into the frequency by the voice.
|
||||
- Standard: remove the unused parameters or make the signature reflect what is actually
|
||||
used (the octave shift is applied at `SynthVoice.cpp:179`).
|
||||
|
||||
7. No-op override.
|
||||
- `changeProgramName` is an empty override at `PluginProcessor.cpp:226-228`.
|
||||
- Standard: implement it or do not override it.
|
||||
|
||||
8. Ignored prepare-time parameter.
|
||||
- `Filter::prepare` accepts `maxBlockSize` and discards it with `(void) maxBlockSize;`
|
||||
(`Filter.cpp:21-29`); effect units name the parameter `int` but never use it
|
||||
(`Hyper.cpp:6`, `Chorus.cpp:6`, `Flanger.cpp:6`, etc.).
|
||||
- Standard: keep the `FXUnit` interface uniform but do not add unused parameters to
|
||||
concrete `prepare()` implementations.
|
||||
|
||||
9. Duplicated envelope curve constants.
|
||||
- The attack/decay shape mapping `0.3 + curve * 2.7` / `3.0 - curve * 2.7` appears in
|
||||
`Envelope.cpp:44-45` and again in the display `EnvelopeDisplay.cpp:19-20`.
|
||||
- Standard: expose one shared helper so the preview cannot drift from the DSP.
|
||||
|
||||
10. Filter response reimplemented for display.
|
||||
- `FilterDisplay::magnitude` (`FilterDisplay.cpp:6-36`) hardcodes a parallel set of
|
||||
transfer-function approximations that duplicate the real DSP in `Filter.cpp`
|
||||
(`Filter.cpp:87-234`).
|
||||
- Standard: keep the display response derived from one documented source, or mark it as
|
||||
an approximation with a comment that the two are not the same code.
|
||||
|
||||
11. Inconsistent pi constants.
|
||||
- `kTwoPi` is defined at `Oscillator.h:67` and `SubOscillator.h:26`; `twoPi` at
|
||||
`Wavetable.cpp:21`; `kPi` at `Wavetable.cpp:156` and `Filter.cpp:8`; raw literals
|
||||
`6.28318530717958647692` appear at `Chorus.cpp:40-41`, `Flanger.cpp:38-39`,
|
||||
`Phaser.cpp:30-31`, and `6.2831853f` at `LFO.cpp:67` and `LFODisplay.cpp:19`.
|
||||
- Standard: use `juce::MathConstants<T>::pi` / `twoPi` everywhere.
|
||||
|
||||
12. Public mutable state exposes internals.
|
||||
- `PluginProcessor.h:56-58` exposes `parameters`, `engine` and `rave` as public fields;
|
||||
`ModulationMatrix.h:30` and `MacroControls.h:28` expose their collections;
|
||||
`Engine.h:36-38` returns non-const references from `getMatrix()`, `getMacros()` and
|
||||
`getWavetables()`.
|
||||
- Standard: return `const&` from accessors and route mutation through member functions,
|
||||
except where a public field is an explicit, documented design choice.
|
||||
|
||||
13. Unchecked raw parameter dereference.
|
||||
- `PluginEditor.cpp:794` dereferences `getRawParameterValue(id)->load()` without a null
|
||||
check, while the equivalent helper in `Engine.cpp:8-13` guards the pointer.
|
||||
- Standard: always guard `getRawParameterValue`/`getParameter` results before use.
|
||||
|
||||
14. Mixed ownership for GUI children.
|
||||
- `makeKnob`/`makeCombo`/`makeToggle` return raw pointers to `new`-allocated children
|
||||
that JUCE owns via `addAndMakeVisible` (`PluginEditor.cpp:162-199`), and `fxUp`/
|
||||
`fxDown` use raw `new` at `PluginEditor.cpp:602,606`. This is correct only because of
|
||||
JUCE's component-tree ownership, but it is easy to misread as a leak.
|
||||
- Standard: document that JUCE-owned children are raw pointers and never deleted, and
|
||||
keep `std::unique_ptr` for everything the plugin owns directly.
|
||||
|
||||
---
|
||||
|
||||
## 8. Hard constraints worth codifying
|
||||
|
||||
Only rules that the current code and build actually support are listed.
|
||||
|
||||
1. Do not allocate or resize containers in the audio callback. Evidence that this is the
|
||||
intent: all DSP state is sized in `prepare()` (`Engine.cpp:68`, `SynthVoice.cpp:23`,
|
||||
`FXProcessor.cpp:34-35`) and the code uses pre-sized `juce::AudioBuffer` and
|
||||
`std::array`. The current violations (`Engine.cpp:211` and the lazy wavetable build) are
|
||||
exceptions to fix, not the rule.
|
||||
2. All audio parameters are normalised to 0..1 in the APVTS, and physical units are mapped
|
||||
only through `maps::` (`Params.h:309-342`). DSP must consume the normalised values, not
|
||||
physical units.
|
||||
3. Parameter IDs live in the `ids` namespace in `Params.h` (`Params.h:18-208`). Engine,
|
||||
editor and preset code must reference `ids::`, not re-declare string literals. (This is
|
||||
the stated intent at `Params.h:5-9`; the current duplication in `Engine.cpp` and
|
||||
`PluginEditor.cpp` is a violation to eliminate.)
|
||||
4. The CMake source list is explicit (`CMakeLists.txt:50-87`), not a glob. Every file move
|
||||
or rename must update `SERUMALT_SOURCES`.
|
||||
5. All code is inside `namespace serum`, and every header uses `#pragma once` (verified
|
||||
across all headers).
|
||||
6. No exceptions and no user-facing alert dialogs. Use `jassert` for programmer errors,
|
||||
null guards for runtime lookups, and `bool` returns for bounded inserts (see
|
||||
`Oscillator.cpp:20`, `Engine.cpp:8-13`, `ModulationMatrix.cpp:6-12`).
|
||||
7. When cross-compiling for Windows, keep the VST3 manifest step disabled and let
|
||||
`build_windows.sh` inject `moduleinfo.json`, because JUCE's manifest helper must not be
|
||||
built as a Windows executable on a Linux host (`CMakeLists.txt:23-31`,
|
||||
`build_windows.sh:55-126`). Keep the injected metadata in sync with
|
||||
`PLUGIN_CODE`/`PLUGIN_MANUFACTURER_CODE`/`VERSION` (`build_windows.sh:60-62`).
|
||||
8. The project is single-threaded by design. Any future thread, mutex or atomic must come
|
||||
with an explicit plan for the GUI-to-audio boundary, because the current matrix/macro/LFO
|
||||
state is shared without synchronisation (section 5).
|
||||
@@ -0,0 +1,95 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
project(SerumAlt VERSION 1.0.0 LANGUAGES CXX)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C++ standard
|
||||
# ---------------------------------------------------------------------------
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
option(SERUMALT_BUILD_PLUGIN "Build production plugin and standalone formats" ON)
|
||||
option(SERUMALT_BUILD_TESTS "Build the headless QA harness" OFF)
|
||||
|
||||
if(NOT SERUMALT_BUILD_PLUGIN AND NOT SERUMALT_BUILD_TESTS)
|
||||
message(FATAL_ERROR "Enable SERUMALT_BUILD_PLUGIN or SERUMALT_BUILD_TESTS")
|
||||
endif()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JUCE
|
||||
# ---------------------------------------------------------------------------
|
||||
if(NOT DEFINED JUCE_ROOT)
|
||||
set(JUCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/third_party/JUCE")
|
||||
endif()
|
||||
add_subdirectory("${JUCE_ROOT}" "${CMAKE_CURRENT_BINARY_DIR}/third_party/JUCE")
|
||||
|
||||
set(SERUMALT_SOURCES
|
||||
Source/PluginProcessor.cpp
|
||||
Source/PluginEditor.cpp
|
||||
Source/Params.cpp
|
||||
Source/Wavetable.cpp
|
||||
Source/Oscillator.cpp
|
||||
Source/SubOscillator.cpp
|
||||
Source/NoiseOscillator.cpp
|
||||
Source/Filter.cpp
|
||||
Source/FilterBank.cpp
|
||||
Source/Envelope.cpp
|
||||
Source/LFO.cpp
|
||||
Source/ModulationMatrix.cpp
|
||||
Source/FXProcessor.cpp
|
||||
Source/MacroControls.cpp
|
||||
Source/RAVEButton.cpp
|
||||
Source/SynthVoice.cpp
|
||||
Source/Engine.cpp
|
||||
Source/Resources.cpp
|
||||
Source/EffectUnits/Hyper.cpp
|
||||
Source/EffectUnits/Chorus.cpp
|
||||
Source/EffectUnits/Flanger.cpp
|
||||
Source/EffectUnits/Phaser.cpp
|
||||
Source/EffectUnits/Distortion.cpp
|
||||
Source/EffectUnits/EQ.cpp
|
||||
Source/EffectUnits/Compressor.cpp
|
||||
Source/EffectUnits/Delay.cpp
|
||||
Source/EffectUnits/Reverb.cpp
|
||||
Source/GUI/Knob.cpp
|
||||
Source/GUI/Slider.cpp
|
||||
Source/GUI/ToggleButton.cpp
|
||||
Source/GUI/Display.cpp
|
||||
Source/GUI/WaveformDisplay.cpp
|
||||
Source/GUI/FilterDisplay.cpp
|
||||
Source/GUI/EnvelopeDisplay.cpp
|
||||
Source/GUI/LFODisplay.cpp
|
||||
Source/GUI/Panel.cpp
|
||||
Source/Presets/FactoryPresets.cpp)
|
||||
|
||||
function(serumalt_configure_target target)
|
||||
juce_generate_juce_header(${target})
|
||||
target_sources(${target} PRIVATE ${SERUMALT_SOURCES})
|
||||
target_compile_definitions(${target}
|
||||
PUBLIC
|
||||
JUCE_WEB_BROWSER=0
|
||||
JUCE_USE_CURL=0
|
||||
JUCE_VST3_CAN_REPLACE_VST2=0)
|
||||
target_link_libraries(${target}
|
||||
PRIVATE
|
||||
juce::juce_audio_utils
|
||||
juce::juce_dsp
|
||||
PUBLIC
|
||||
juce::juce_recommended_config_flags
|
||||
juce::juce_recommended_warning_flags)
|
||||
if(MSVC)
|
||||
target_compile_options(${target} PRIVATE /W4)
|
||||
else()
|
||||
target_compile_options(${target} PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
if(SERUMALT_BUILD_PLUGIN)
|
||||
include(cmake/Plugin.cmake)
|
||||
endif()
|
||||
|
||||
if(SERUMALT_BUILD_TESTS)
|
||||
enable_testing()
|
||||
include(cmake/Harness.cmake)
|
||||
endif()
|
||||
@@ -68,36 +68,173 @@ macro assignments and LFO shape data.
|
||||
|
||||
## Building
|
||||
|
||||
Requirements: CMake ≥ 3.22, a C++17 compiler, and the Linux dev libraries
|
||||
(`libasound2-dev`, `libjack-dev`, `libfreetype-dev`, `libcurl`, X11, OpenGL).
|
||||
JUCE 7.0.12 is vendored under `third_party/JUCE`.
|
||||
Each script configures and builds its own directory. Production builds disable the
|
||||
QA harness explicitly; the harness script creates no plugin or standalone targets.
|
||||
JUCE 7.0.12 is vendored under `third_party/JUCE` and is not downloaded by the scripts.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
All scripts require Bash, CMake 3.22+, Ninja, and C/C++17 compilers. The harness
|
||||
also uses CTest, included with CMake, when invoked with `--run`.
|
||||
|
||||
On Linux, install GCC or Clang, `pkg-config`, and the ALSA, FreeType, X11 and OpenGL
|
||||
development libraries. For Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build
|
||||
sudo apt update
|
||||
sudo apt install build-essential cmake ninja-build pkg-config \
|
||||
libasound2-dev libfreetype6-dev libx11-dev libxcomposite-dev \
|
||||
libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev libxrender-dev \
|
||||
libgl1-mesa-dev
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
- `build/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3` — VST3
|
||||
- `build/SerumAlt_artefacts/Release/Standalone/SerumAlt` — standalone app
|
||||
- (on macOS, the `AU` target is produced automatically)
|
||||
|
||||
Cross-compilation for Windows/macOS is handled by JUCE: configure with the relevant
|
||||
toolchain and add `-DCMAKE_TOOLCHAIN_FILE=...` as usual.
|
||||
|
||||
## Headless QA harness
|
||||
|
||||
A console app drives the processor through every factory preset, plays a chord,
|
||||
verifies finite (non-NaN) output and non-silence, and exercises the RAVE toggle.
|
||||
Build and run with:
|
||||
For Arch/CachyOS:
|
||||
|
||||
```bash
|
||||
cmake --build build --target SerumAltTest
|
||||
./build/SerumAltTest_artefacts/Release/SerumAltTest
|
||||
sudo pacman -S --needed base-devel cmake ninja pkgconf alsa-lib freetype2 \
|
||||
libx11 libxcomposite libxcursor libxext libxinerama libxrandr libxrender libglvnd
|
||||
```
|
||||
|
||||
(Disable it with `-DSERUMALT_BUILD_TESTS=OFF`.)
|
||||
JACK is disabled by JUCE's default `JUCE_JACK=0`. Curl and the web browser are
|
||||
explicitly disabled in both project targets. Their development packages are not
|
||||
required for these builds, even though JUCE probes for them during configuration.
|
||||
See `third_party/JUCE/docs/Linux Dependencies.md` if enabling additional JUCE modules.
|
||||
|
||||
On macOS, install Apple's Command Line Tools with `xcode-select --install`, or
|
||||
select an installed Xcode toolchain. Install CMake and Ninja, for example with
|
||||
`brew install cmake ninja` if Homebrew is already installed. The macOS script checks
|
||||
that `xcrun` can locate the macOS SDK. It must run on a Mac; JUCE does not provide
|
||||
an Apple cross-compilation toolchain for Linux.
|
||||
|
||||
For Windows cross-compilation, also install MinGW-w64 on the Linux host:
|
||||
|
||||
```bash
|
||||
sudo apt install gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 binutils-mingw-w64-x86-64
|
||||
```
|
||||
|
||||
On Arch/CachyOS, use `sudo pacman -S --needed mingw-w64-gcc` instead. Keep the
|
||||
native Linux tools and libraries installed: JUCE builds `juceaide` for the host
|
||||
before compiling the Windows plugin. The checked-in `mingw64-toolchain.cmake`
|
||||
selects the `x86_64-w64-mingw32-*` tools and isolates target library searches.
|
||||
|
||||
### Production builds
|
||||
|
||||
| Host | Command | Build directory | Formats |
|
||||
| --- | --- | --- | --- |
|
||||
| Linux | `./build_linux.sh` | `build_linux/` | VST3, Standalone |
|
||||
| macOS | `./build_macos.sh` | `build_macos/` | VST3, AU, Standalone |
|
||||
| Linux targeting Windows x64 | `./build_windows.sh` | `build_windows/` | VST3 only |
|
||||
|
||||
Default Release artifacts:
|
||||
|
||||
- Linux: `build_linux/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3` and
|
||||
`build_linux/SerumAlt_artefacts/Release/Standalone/SerumAlt`.
|
||||
- macOS: `build_macos/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3`,
|
||||
`build_macos/SerumAlt_artefacts/Release/AU/SerumAlt.component`, and
|
||||
`build_macos/SerumAlt_artefacts/Release/Standalone/SerumAlt.app`.
|
||||
- Windows: `build_windows/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3`, also
|
||||
copied to `SerumAlt_Windows/SerumAlt.vst3`.
|
||||
|
||||
The Windows script reuses the toolchain file without rewriting it. It injects
|
||||
`moduleinfo.json` because the cross-compiled JUCE manifest helper cannot run on
|
||||
Linux. Keep this manifest's metadata in sync with `CMakeLists.txt`,
|
||||
`cmake/Plugin.cmake`, and the vendored VST3 SDK. MinGW links its C++ and threading
|
||||
runtimes statically via `-static-libgcc -static-libstdc++ -static`.
|
||||
|
||||
macOS builds target the host architecture by default. To request a universal binary:
|
||||
|
||||
```bash
|
||||
CMAKE_OSX_ARCHITECTURES='arm64;x86_64' ./build_macos.sh
|
||||
```
|
||||
|
||||
`CMAKE_OSX_DEPLOYMENT_TARGET` can also be passed to that script. Architecture and
|
||||
SDK settings persist in CMake's cache. Use a separate build directory when changing
|
||||
toolchains. These scripts build local artifacts, not signed or notarized releases,
|
||||
and do not install plugins into a DAW's plugin directories.
|
||||
|
||||
### Independent QA harness
|
||||
|
||||
On Linux or macOS, build the console harness without production targets:
|
||||
|
||||
```bash
|
||||
./build_harness.sh
|
||||
```
|
||||
|
||||
Build and run the checks:
|
||||
|
||||
```bash
|
||||
./build_harness.sh --run
|
||||
```
|
||||
|
||||
Or rerun an already-built Release harness:
|
||||
|
||||
```bash
|
||||
ctest --test-dir build_harness --build-config Release --output-on-failure --no-tests=error
|
||||
```
|
||||
|
||||
The executable is `build_harness/SerumAltTest_artefacts/Release/SerumAltTest`.
|
||||
It renders the initial state and every factory preset, checks for non-finite
|
||||
samples and aggregate non-silence, and exercises the RAVE toggle. A failed check
|
||||
returns a nonzero exit status. This is a processor integration smoke test, not a
|
||||
complete DSP, GUI, or real-time safety test suite.
|
||||
|
||||
The harness does not open an editor or audio device, but it still compiles the real
|
||||
processor, editor and JUCE GUI dependencies. Its separate build directory and
|
||||
CMake options isolate its outputs from production bundles; it is not a DSP-only
|
||||
library. It is built and run natively, not as a Windows cross-compiled executable.
|
||||
|
||||
### Build options and layout
|
||||
|
||||
- `BUILD_TYPE` defaults to `Release`; set it to `Debug` for a debug build.
|
||||
- `BUILD_DIR` overrides a script's build directory. Relative paths are resolved
|
||||
from the repository root, even when the script is invoked elsewhere.
|
||||
- `CMAKE_BUILD_PARALLEL_LEVEL` defaults to `2` to limit memory use, including JUCE's
|
||||
configure-time helper build. Increase it if memory permits.
|
||||
- Windows-only `OUTPUT_DIR` overrides the staging directory.
|
||||
|
||||
For example:
|
||||
|
||||
```bash
|
||||
BUILD_TYPE=Debug BUILD_DIR=build_harness_debug ./build_harness.sh --run
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=4 ./build_linux.sh
|
||||
```
|
||||
|
||||
`CMakeLists.txt` owns the shared source list and target configuration.
|
||||
`cmake/Plugin.cmake` owns production formats and platform-specific linking.
|
||||
`cmake/Harness.cmake` owns the test executable and its CTest registration.
|
||||
|
||||
For direct CMake use, `SERUMALT_BUILD_PLUGIN` defaults to `ON` and
|
||||
`SERUMALT_BUILD_TESTS` defaults to `OFF`. A harness-only configure is:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build_harness -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DSERUMALT_BUILD_PLUGIN=OFF -DSERUMALT_BUILD_TESTS=ON
|
||||
cmake --build build_harness --target SerumAltTest
|
||||
```
|
||||
|
||||
An existing CMake cache retains its old option values; changing the defaults does
|
||||
not turn tests off in an old `build/` directory. Use the scripts to set the flags
|
||||
explicitly. An alternate JUCE checkout can be selected with `-DJUCE_ROOT=/path/to/JUCE`
|
||||
when configuring directly with CMake.
|
||||
|
||||
### Fresh builds
|
||||
|
||||
The scripts reuse their build directories without deleting files, resetting Git,
|
||||
or installing packages. Generated `build/`, `build_*/`, and `SerumAlt_Windows/`
|
||||
directories are git-ignored. Leave existing builds intact and select an unused
|
||||
build directory for a fresh configure:
|
||||
|
||||
```bash
|
||||
BUILD_DIR=build_linux_fresh ./build_linux.sh
|
||||
```
|
||||
|
||||
Windows staging updates generated files without deleting extra files already in
|
||||
the destination. For a clean distribution bundle, choose unused build and output
|
||||
paths together:
|
||||
|
||||
```bash
|
||||
BUILD_DIR=build_windows_fresh OUTPUT_DIR=build_windows_fresh/staged ./build_windows.sh
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# effects module
|
||||
|
||||
## Owned files
|
||||
|
||||
- Related rack implementation: `../FXProcessor.h`, `../FXProcessor.cpp`
|
||||
- Biquad.h
|
||||
- Hyper.h, Hyper.cpp
|
||||
- Chorus.h, Chorus.cpp
|
||||
- Flanger.h, Flanger.cpp
|
||||
- Phaser.h, Phaser.cpp
|
||||
- Distortion.h, Distortion.cpp
|
||||
- EQ.h, EQ.cpp
|
||||
- Compressor.h, Compressor.cpp
|
||||
- Delay.h, Delay.cpp
|
||||
- Reverb.h, Reverb.cpp
|
||||
|
||||
## Rules
|
||||
|
||||
- Implement the `FXUnit` interface with `prepare`, `reset` and `process (float* l, float* r, int numSamples, const float p[4])`.
|
||||
- Process full-wet in place; FXProcessor handles the dry/wet mix.
|
||||
- Document what p[0] through p[3] mean for each unit in the header comment.
|
||||
- Use `std::vector<float>` for delay lines and size them in `prepare()`.
|
||||
- Use `juce::MathConstants<T>::pi` and `twoPi` in delay LFOs, not raw literals.
|
||||
- Never allocate or resize a delay line inside `process`.
|
||||
- Guard empty delay lines with an early `if (len < 4) return;`.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF a unit needs a delay line THEN allocate it in `prepare()` at the full required length and only read and write it in `process`.
|
||||
- Include the existing rack interface via `../FXProcessor.h`; no source relocation is implied by this guidance.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: reallocates a delay line on the audio thread
|
||||
void process (float* l, float* r, int n, const float p[4]) override
|
||||
{
|
||||
std::vector<float> delay ((size_t) (sr * 0.05), 0.0f);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: sized once at prepare time, reused per block
|
||||
void prepare (double sampleRate, int) override
|
||||
{
|
||||
sr = sampleRate;
|
||||
delayL.assign ((size_t) (sr * 0.05), 0.0f);
|
||||
delayR.assign ((size_t) (sr * 0.05), 0.0f);
|
||||
}
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Small RBJ-cookbook biquad (direct form 1) used by EQ and the Hyper crossover.
|
||||
// ===========================================================================
|
||||
class Biquad
|
||||
{
|
||||
public:
|
||||
void setSampleRate (double sr) noexcept { sampleRate = sr; }
|
||||
|
||||
void clear() noexcept
|
||||
{
|
||||
x1 = x2 = y1 = y2 = 0.0;
|
||||
}
|
||||
|
||||
float process (float in) noexcept
|
||||
{
|
||||
const double out = b0 * in + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
|
||||
x2 = x1; x1 = in;
|
||||
y2 = y1; y1 = out;
|
||||
return (float) out;
|
||||
}
|
||||
|
||||
void setLowpass (double freq, double q)
|
||||
{
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
const double a0 = 1.0 + alpha;
|
||||
b0 = ((1.0 - cw) / 2.0) / a0;
|
||||
b1 = (1.0 - cw) / a0;
|
||||
b2 = b0;
|
||||
a1 = (-2.0 * cw) / a0;
|
||||
a2 = (1.0 - alpha) / a0;
|
||||
}
|
||||
|
||||
void setHighpass (double freq, double q)
|
||||
{
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
const double a0 = 1.0 + alpha;
|
||||
b0 = ((1.0 + cw) / 2.0) / a0;
|
||||
b1 = -(1.0 + cw) / a0;
|
||||
b2 = b0;
|
||||
a1 = (-2.0 * cw) / a0;
|
||||
a2 = (1.0 - alpha) / a0;
|
||||
}
|
||||
|
||||
void setLowShelf (double freq, double gainDb)
|
||||
{
|
||||
const double a = std::pow (10.0, gainDb / 40.0);
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / 2.0 * std::sqrt (2.0);
|
||||
const double a0 = (a + 1.0) + (a - 1.0) * cw + 2.0 * std::sqrt (a) * alpha;
|
||||
b0 = (a * ((a + 1.0) - (a - 1.0) * cw + 2.0 * std::sqrt (a) * alpha)) / a0;
|
||||
b1 = (2.0 * a * ((a - 1.0) - (a + 1.0) * cw)) / a0;
|
||||
b2 = (a * ((a + 1.0) - (a - 1.0) * cw - 2.0 * std::sqrt (a) * alpha)) / a0;
|
||||
a1 = (-2.0 * ((a - 1.0) + (a + 1.0) * cw)) / a0;
|
||||
a2 = ((a + 1.0) + (a - 1.0) * cw - 2.0 * std::sqrt (a) * alpha) / a0;
|
||||
}
|
||||
|
||||
void setHighShelf (double freq, double gainDb)
|
||||
{
|
||||
const double a = std::pow (10.0, gainDb / 40.0);
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / 2.0 * std::sqrt (2.0);
|
||||
const double a0 = (a + 1.0) - (a - 1.0) * cw + 2.0 * std::sqrt (a) * alpha;
|
||||
b0 = (a * ((a + 1.0) + (a - 1.0) * cw + 2.0 * std::sqrt (a) * alpha)) / a0;
|
||||
b1 = (-2.0 * a * ((a - 1.0) + (a + 1.0) * cw)) / a0;
|
||||
b2 = (a * ((a + 1.0) + (a - 1.0) * cw - 2.0 * std::sqrt (a) * alpha)) / a0;
|
||||
a1 = (2.0 * ((a - 1.0) - (a + 1.0) * cw)) / a0;
|
||||
a2 = ((a + 1.0) - (a - 1.0) * cw - 2.0 * std::sqrt (a) * alpha) / a0;
|
||||
}
|
||||
|
||||
void setPeak (double freq, double gainDb, double q)
|
||||
{
|
||||
const double a = std::pow (10.0, gainDb / 40.0);
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
const double a0 = 1.0 + alpha / a;
|
||||
b0 = (1.0 + alpha * a) / a0;
|
||||
b1 = (-2.0 * cw) / a0;
|
||||
b2 = (1.0 - alpha * a) / a0;
|
||||
a1 = (-2.0 * cw) / a0;
|
||||
a2 = (1.0 - alpha / a) / a0;
|
||||
}
|
||||
|
||||
double sampleRate = 44100.0;
|
||||
double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0;
|
||||
double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,63 @@
|
||||
#include "Chorus.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void ChorusUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
const int maxDelay = (int) (sr * 0.05); // 50ms
|
||||
delayL.assign ((size_t) maxDelay, 0.0f);
|
||||
delayR.assign ((size_t) maxDelay, 0.0f);
|
||||
reset();
|
||||
}
|
||||
|
||||
void ChorusUnit::reset()
|
||||
{
|
||||
std::fill (delayL.begin(), delayL.end(), 0.0f);
|
||||
std::fill (delayR.begin(), delayR.end(), 0.0f);
|
||||
writePos = 0;
|
||||
lfoPhase = 0.0;
|
||||
}
|
||||
|
||||
void ChorusUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const int len = (int) delayL.size();
|
||||
if (len < 4)
|
||||
return;
|
||||
|
||||
const double rate = 0.1 + p[0] * 4.9; // 0.1..5 Hz
|
||||
const double depth = (0.5 + p[1] * 5.5) * sr / 1000.0; // 0.5..6 ms
|
||||
const double base = 8.0 * sr / 1000.0; // 8 ms centre
|
||||
const float width = p[2];
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
const float inL = l[i], inR = r[i];
|
||||
delayL[(size_t) writePos] = inL;
|
||||
delayR[(size_t) writePos] = inR;
|
||||
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
|
||||
float sumL = 0.0f, sumR = 0.0f;
|
||||
for (int v = 0; v < 2; ++v)
|
||||
{
|
||||
const double mod = std::sin (lfoPhase + v * 3.14159265358979323846) * depth;
|
||||
const double d = base + mod + (v == 1 ? width * depth * 0.5 : 0.0);
|
||||
double readPos = writePos - d;
|
||||
if (readPos < 0.0) readPos += len;
|
||||
const int i0 = (int) readPos;
|
||||
const int i1 = (i0 + 1) % len;
|
||||
const float frac = (float) (readPos - std::floor (readPos));
|
||||
sumL += delayL[(size_t) i0] * (1.0f - frac) + delayL[(size_t) i1] * frac;
|
||||
sumR += delayR[(size_t) i0] * (1.0f - frac) + delayR[(size_t) i1] * frac;
|
||||
}
|
||||
|
||||
l[i] = sumL * 0.5f;
|
||||
r[i] = sumR * 0.5f;
|
||||
writePos = (writePos + 1) % len;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Chorus — two LFO-modulated delay taps per channel. p[0]=rate, p[1]=depth,
|
||||
// p[2]=width, p[3]=unused.
|
||||
// ===========================================================================
|
||||
class ChorusUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
std::vector<float> delayL, delayR;
|
||||
int writePos = 0;
|
||||
double lfoPhase = 0.0;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "Compressor.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void CompressorUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
reset();
|
||||
}
|
||||
|
||||
void CompressorUnit::reset()
|
||||
{
|
||||
envL = envR = 0.0f;
|
||||
gainL = gainR = 1.0f;
|
||||
}
|
||||
|
||||
void CompressorUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const double thresholdDb = -40.0 + p[0] * 40.0; // -40..0 dB
|
||||
const double ratio = 2.0 + p[1] * 18.0; // 2..20
|
||||
const double attackMs = 2.0 + p[2] * 98.0;
|
||||
const double releaseMs = 30.0 + p[3] * 470.0;
|
||||
const double attackCoef = std::exp (-1.0 / (attackMs / 1000.0 * sr));
|
||||
const double releaseCoef = std::exp (-1.0 / (releaseMs / 1000.0 * sr));
|
||||
const double threshold = std::pow (10.0, thresholdDb / 20.0);
|
||||
const double makeup = std::pow (10.0, -thresholdDb * 0.5 / 20.0);
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
const float aL = std::abs (l[i]);
|
||||
const float aR = std::abs (r[i]);
|
||||
|
||||
envL = aL > envL ? (float) (attackCoef * envL + (1.0 - attackCoef) * aL)
|
||||
: (float) (releaseCoef * envL + (1.0 - releaseCoef) * aL);
|
||||
envR = aR > envR ? (float) (attackCoef * envR + (1.0 - attackCoef) * aR)
|
||||
: (float) (releaseCoef * envR + (1.0 - releaseCoef) * aR);
|
||||
|
||||
const double targetL = (envL > threshold) ? threshold * std::pow (envL / threshold, 1.0 / ratio) : envL;
|
||||
const double targetR = (envR > threshold) ? threshold * std::pow (envR / threshold, 1.0 / ratio) : envR;
|
||||
const float desiredL = (float) juce::jlimit (0.05, 1.0, (envL > 1e-6) ? targetL / envL : 1.0);
|
||||
const float desiredR = (float) juce::jlimit (0.05, 1.0, (envR > 1e-6) ? targetR / envR : 1.0);
|
||||
|
||||
gainL += 0.01f * (desiredL - gainL);
|
||||
gainR += 0.01f * (desiredR - gainR);
|
||||
|
||||
l[i] = (float) (l[i] * gainL * makeup);
|
||||
r[i] = (float) (r[i] * gainR * makeup);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Compressor — soft-knee RMS compressor with makeup gain. p[0]=threshold,
|
||||
// p[1]=ratio, p[2]=attack, p[3]=release.
|
||||
// ===========================================================================
|
||||
class CompressorUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
float envL = 0.0f, envR = 0.0f;
|
||||
float gainL = 1.0f, gainR = 1.0f;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "Delay.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void DelayUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
const int maxDelay = (int) (sr * 2.1); // 2.1 seconds
|
||||
delayL.assign ((size_t) maxDelay, 0.0f);
|
||||
delayR.assign ((size_t) maxDelay, 0.0f);
|
||||
reset();
|
||||
}
|
||||
|
||||
void DelayUnit::reset()
|
||||
{
|
||||
std::fill (delayL.begin(), delayL.end(), 0.0f);
|
||||
std::fill (delayR.begin(), delayR.end(), 0.0f);
|
||||
writePos = 0;
|
||||
dampL = dampR = 0.0f;
|
||||
}
|
||||
|
||||
void DelayUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const int len = (int) delayL.size();
|
||||
if (len < 4)
|
||||
return;
|
||||
|
||||
const double delaySamples = juce::jlimit (2.0, (double) len - 2.0, maps::delayToMs (p[0]) / 1000.0 * sr);
|
||||
const float feedback = p[1] * 0.92f;
|
||||
const float dampCoef = 1.0f - p[2] * 0.95f; // 1 = no damping
|
||||
const float pingpong = p[3];
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
double readPos = writePos - delaySamples;
|
||||
if (readPos < 0.0) readPos += len;
|
||||
const int i0 = (int) readPos;
|
||||
const int i1 = (i0 + 1) % len;
|
||||
const float frac = (float) (readPos - std::floor (readPos));
|
||||
|
||||
float dl = delayL[(size_t) i0] * (1.0f - frac) + delayL[(size_t) i1] * frac;
|
||||
float dr = delayR[(size_t) i0] * (1.0f - frac) + delayR[(size_t) i1] * frac;
|
||||
|
||||
// Damping lowpass in the feedback path.
|
||||
dampL = dl * (1.0f - dampCoef) + dampL * dampCoef;
|
||||
dampR = dr * (1.0f - dampCoef) + dampR * dampCoef;
|
||||
|
||||
// Ping-pong: cross-feed between channels.
|
||||
const float feedL = dampR * pingpong + dampL * (1.0f - pingpong);
|
||||
const float feedR = dampL * pingpong + dampR * (1.0f - pingpong);
|
||||
|
||||
delayL[(size_t) writePos] = l[i] + feedL * feedback;
|
||||
delayR[(size_t) writePos] = r[i] + feedR * feedback;
|
||||
|
||||
l[i] = dl;
|
||||
r[i] = dr;
|
||||
writePos = (writePos + 1) % len;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Delay — stereo delay with feedback, damping and ping-pong. p[0]=time,
|
||||
// p[1]=feedback, p[2]=damping, p[3]=ping-pong/width.
|
||||
// ===========================================================================
|
||||
class DelayUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
std::vector<float> delayL, delayR;
|
||||
int writePos = 0;
|
||||
float dampL = 0.0f, dampR = 0.0f;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,44 @@
|
||||
#include "Distortion.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void DistortionUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
lpL.setSampleRate (sr); lpR.setSampleRate (sr);
|
||||
lpL.setLowpass (8000.0, 0.7071); lpR.setLowpass (8000.0, 0.7071);
|
||||
reset();
|
||||
}
|
||||
|
||||
void DistortionUnit::reset()
|
||||
{
|
||||
lpL.clear(); lpR.clear();
|
||||
}
|
||||
|
||||
float DistortionUnit::shape (float x, float shapeParam) noexcept
|
||||
{
|
||||
if (shapeParam < 0.33f) return std::tanh (x);
|
||||
if (shapeParam < 0.66f) return juce::jlimit (-1.0f, 1.0f, x);
|
||||
return std::sin (x * 1.5707963267948966f); // sine fold
|
||||
}
|
||||
|
||||
void DistortionUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const float drive = 1.0f + p[0] * 24.0f;
|
||||
const float shapeP = p[1];
|
||||
const float output = 0.4f + p[3] * 1.2f;
|
||||
|
||||
// Tone: re-tune the lowpass from 800..16000 Hz.
|
||||
const double toneHz = 800.0 + p[2] * 15200.0;
|
||||
lpL.setLowpass (toneHz, 0.7071);
|
||||
lpR.setLowpass (toneHz, 0.7071);
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
l[i] = lpL.process (shape (l[i] * drive, shapeP) * output);
|
||||
r[i] = lpR.process (shape (r[i] * drive, shapeP) * output);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
#include "Biquad.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Distortion — waveshaper with drive and a post lowpass tone. p[0]=drive,
|
||||
// p[1]=shape (soft/hard/fold), p[2]=tone, p[3]=output.
|
||||
// ===========================================================================
|
||||
class DistortionUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
Biquad lpL, lpR;
|
||||
|
||||
static float shape (float x, float shapeParam) noexcept;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "EQ.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void EQUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
lowL.setSampleRate (sr); lowR.setSampleRate (sr);
|
||||
midL.setSampleRate (sr); midR.setSampleRate (sr);
|
||||
highL.setSampleRate (sr); highR.setSampleRate (sr);
|
||||
reset();
|
||||
}
|
||||
|
||||
void EQUnit::reset()
|
||||
{
|
||||
lowL.clear(); lowR.clear(); midL.clear(); midR.clear(); highL.clear(); highR.clear();
|
||||
}
|
||||
|
||||
void EQUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const double lowGain = (p[0] - 0.5f) * 2.0f * 15.0;
|
||||
const double midGain = (p[1] - 0.5f) * 2.0f * 12.0;
|
||||
const double highGain = (p[2] - 0.5f) * 2.0f * 15.0;
|
||||
const double midFreq = 200.0 + p[3] * 4800.0;
|
||||
|
||||
lowL.setLowShelf (200.0, lowGain); lowR.setLowShelf (200.0, lowGain);
|
||||
midL.setPeak (midFreq, midGain, 0.8); midR.setPeak (midFreq, midGain, 0.8);
|
||||
highL.setHighShelf (4000.0, highGain); highR.setHighShelf (4000.0, highGain);
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
const float xL = l[i], xR = r[i];
|
||||
l[i] = highL.process (midL.process (lowL.process (xL)));
|
||||
r[i] = highR.process (midR.process (lowR.process (xR)));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
#include "Biquad.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// EQ — three-band (low shelf / peak / high shelf). p[0]=low gain, p[1]=mid
|
||||
// gain, p[2]=high gain, p[3]=mid frequency.
|
||||
// ===========================================================================
|
||||
class EQUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
Biquad lowL, lowR, midL, midR, highL, highR;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "Flanger.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void FlangerUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
const int maxDelay = (int) (sr * 0.03); // 30ms
|
||||
delayL.assign ((size_t) maxDelay, 0.0f);
|
||||
delayR.assign ((size_t) maxDelay, 0.0f);
|
||||
reset();
|
||||
}
|
||||
|
||||
void FlangerUnit::reset()
|
||||
{
|
||||
std::fill (delayL.begin(), delayL.end(), 0.0f);
|
||||
std::fill (delayR.begin(), delayR.end(), 0.0f);
|
||||
writePos = 0;
|
||||
lfoPhase = 0.0;
|
||||
}
|
||||
|
||||
void FlangerUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const int len = (int) delayL.size();
|
||||
if (len < 4)
|
||||
return;
|
||||
|
||||
const double rate = 0.05 + p[0] * 2.0;
|
||||
const double depth = (0.3 + p[1] * 4.7) * sr / 1000.0; // 0.3..5 ms
|
||||
const double base = 1.5 * sr / 1000.0;
|
||||
const float feedback = p[2] * 0.9f;
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
const float inL = l[i], inR = r[i];
|
||||
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
const double mod = std::sin (lfoPhase) * depth;
|
||||
|
||||
double readPos = writePos - (base + mod);
|
||||
if (readPos < 0.0) readPos += len;
|
||||
const int i0 = (int) readPos;
|
||||
const int i1 = (i0 + 1) % len;
|
||||
const float frac = (float) (readPos - std::floor (readPos));
|
||||
const float outL = delayL[(size_t) i0] * (1.0f - frac) + delayL[(size_t) i1] * frac;
|
||||
const float outR = delayR[(size_t) i0] * (1.0f - frac) + delayR[(size_t) i1] * frac;
|
||||
|
||||
delayL[(size_t) writePos] = inL + outL * feedback;
|
||||
delayR[(size_t) writePos] = inR + outR * feedback;
|
||||
|
||||
l[i] = outL;
|
||||
r[i] = outR;
|
||||
writePos = (writePos + 1) % len;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Flanger — LFO-modulated delay with feedback. p[0]=rate, p[1]=depth,
|
||||
// p[2]=feedback, p[3]=unused.
|
||||
// ===========================================================================
|
||||
class FlangerUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
std::vector<float> delayL, delayR;
|
||||
int writePos = 0;
|
||||
double lfoPhase = 0.0;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,80 @@
|
||||
#include "Hyper.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void HyperUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
lpL.setSampleRate (sr); lpR.setSampleRate (sr);
|
||||
hpL.setSampleRate (sr); hpR.setSampleRate (sr);
|
||||
lpL.setLowpass (150.0, 0.7071); lpR.setLowpass (150.0, 0.7071);
|
||||
hpL.setHighpass (2500.0, 0.7071); hpR.setHighpass (2500.0, 0.7071);
|
||||
reset();
|
||||
}
|
||||
|
||||
void HyperUnit::reset()
|
||||
{
|
||||
lpL.clear(); lpR.clear(); hpL.clear(); hpR.clear();
|
||||
for (auto& b : bL) { b.env = 0.0f; b.gain = 1.0f; }
|
||||
for (auto& b : bR) { b.env = 0.0f; b.gain = 1.0f; }
|
||||
}
|
||||
|
||||
float HyperUnit::computeGain (Band& band, float absIn, float intensity) noexcept
|
||||
{
|
||||
// Peak envelope follower with fast attack, slower release.
|
||||
const float attack = (absIn > band.env) ? 0.3f : 0.003f;
|
||||
band.env += attack * (absIn - band.env);
|
||||
|
||||
const float env = band.env + 1e-6f;
|
||||
const float ratio = 4.0f + 8.0f * intensity;
|
||||
const float upAmount = intensity * 0.7f;
|
||||
|
||||
// Downward compression above the reference level, upward expansion below.
|
||||
float target;
|
||||
if (env > 1.0f)
|
||||
target = 1.0f + (env - 1.0f) / ratio;
|
||||
else
|
||||
target = 1.0f - (1.0f - env) * (1.0f - upAmount);
|
||||
|
||||
float desired = target / env;
|
||||
desired = juce::jlimit (0.2f, 4.0f, desired);
|
||||
band.gain += 0.01f * (desired - band.gain);
|
||||
return band.gain;
|
||||
}
|
||||
|
||||
void HyperUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const float intensity = p[0];
|
||||
const float lowAmt = 0.4f + 0.6f * p[1];
|
||||
const float highAmt = 0.4f + 0.6f * p[2];
|
||||
const float output = 0.5f + p[3] * 1.0f;
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
const float inL = l[i], inR = r[i];
|
||||
|
||||
const float loL = lpL.process (inL);
|
||||
const float restL = inL - loL;
|
||||
const float hiL = hpL.process (restL);
|
||||
const float midL = restL - hiL;
|
||||
|
||||
const float loR = lpR.process (inR);
|
||||
const float restR = inR - loR;
|
||||
const float hiR = hpR.process (restR);
|
||||
const float midR = restR - hiR;
|
||||
|
||||
const float gLL = computeGain (bL[0], std::abs (loL), intensity);
|
||||
const float gML = computeGain (bL[1], std::abs (midL), intensity);
|
||||
const float gHL = computeGain (bL[2], std::abs (hiL), intensity);
|
||||
|
||||
const float gLR = computeGain (bR[0], std::abs (loR), intensity);
|
||||
const float gMR = computeGain (bR[1], std::abs (midR), intensity);
|
||||
const float gHR = computeGain (bR[2], std::abs (hiR), intensity);
|
||||
|
||||
l[i] = (loL * gLL * lowAmt + midL * gML + hiL * gHL * highAmt) * output;
|
||||
r[i] = (loR * gLR * lowAmt + midR * gMR + hiR * gHR * highAmt) * output;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
#include "Biquad.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Hyper — an OTT-style three-band upward/downward compressor. p[0]=intensity,
|
||||
// p[1]=low amount, p[2]=high amount, p[3]=output.
|
||||
// ===========================================================================
|
||||
class HyperUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
struct Band
|
||||
{
|
||||
float env = 0.0f;
|
||||
float gain = 1.0f;
|
||||
};
|
||||
|
||||
double sr = 44100.0;
|
||||
Biquad lpL, lpR, hpL, hpR;
|
||||
Band bL[3], bR[3];
|
||||
|
||||
float computeGain (Band& band, float absIn, float intensity) noexcept;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "Phaser.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void PhaserUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
reset();
|
||||
}
|
||||
|
||||
void PhaserUnit::reset()
|
||||
{
|
||||
lfoPhase = 0.0;
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
xL[i] = xR[i] = yL[i] = yR[i] = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
void PhaserUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const double rate = 0.05 + p[0] * 2.0;
|
||||
const float depth = 0.3f + p[1] * 0.6f;
|
||||
const float feedback = p[2] * 0.7f;
|
||||
const int stages = juce::jlimit (2, 6, 2 + (int) (p[3] * 4.0f + 0.5f));
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
|
||||
const float sweep = (float) (0.5 + 0.5 * std::sin (lfoPhase));
|
||||
const float a = 0.3f + depth * sweep; // allpass coefficient
|
||||
|
||||
float outL = l[i] + yL[5] * feedback;
|
||||
float outR = r[i] + yR[5] * feedback;
|
||||
|
||||
for (int s = 0; s < stages; ++s)
|
||||
{
|
||||
// y = a*x + x_prev - a*y_prev
|
||||
const float inL = outL, inR = outR;
|
||||
outL = a * inL + xL[s] - a * yL[s];
|
||||
outR = a * inR + xR[s] - a * yR[s];
|
||||
xL[s] = inL; xR[s] = inR;
|
||||
yL[s] = outL; yR[s] = outR;
|
||||
}
|
||||
|
||||
l[i] = outL;
|
||||
r[i] = outR;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Phaser — cascade of four first-order allpass stages with an LFO-modulated
|
||||
// coefficient and feedback. p[0]=rate, p[1]=depth, p[2]=feedback, p[3]=stages.
|
||||
// ===========================================================================
|
||||
class PhaserUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
double lfoPhase = 0.0;
|
||||
float xL[6] {}, xR[6] {}, yL[6] {}, yR[6] {};
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,132 @@
|
||||
#include "Reverb.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int combTuning[8] = { 1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617 };
|
||||
constexpr int allpassTuning[4] = { 556, 441, 341, 225 };
|
||||
}
|
||||
|
||||
void ReverbUnit::prepare (double sampleRate, int)
|
||||
{
|
||||
sr = sampleRate;
|
||||
const double scale = sr / 44100.0;
|
||||
|
||||
for (int i = 0; i < 8; ++i)
|
||||
{
|
||||
const int len = (int) (combTuning[i] * scale) + 1;
|
||||
combL[(size_t) i].assign ((size_t) len, 0.0f);
|
||||
combR[(size_t) i].assign ((size_t) len, 0.0f);
|
||||
}
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
const int len = (int) (allpassTuning[i] * scale) + 1;
|
||||
apL[(size_t) i].assign ((size_t) len, 0.0f);
|
||||
apR[(size_t) i].assign ((size_t) len, 0.0f);
|
||||
}
|
||||
|
||||
const int preLen = (int) (sr * 0.2);
|
||||
preL.assign ((size_t) preLen, 0.0f);
|
||||
preR.assign ((size_t) preLen, 0.0f);
|
||||
reset();
|
||||
}
|
||||
|
||||
void ReverbUnit::reset()
|
||||
{
|
||||
for (int i = 0; i < 8; ++i)
|
||||
{
|
||||
std::fill (combL[(size_t) i].begin(), combL[(size_t) i].end(), 0.0f);
|
||||
std::fill (combR[(size_t) i].begin(), combR[(size_t) i].end(), 0.0f);
|
||||
combPosL[(size_t) i] = combPosR[(size_t) i] = 0;
|
||||
combFiltL[(size_t) i] = combFiltR[(size_t) i] = 0.0f;
|
||||
}
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
std::fill (apL[(size_t) i].begin(), apL[(size_t) i].end(), 0.0f);
|
||||
std::fill (apR[(size_t) i].begin(), apR[(size_t) i].end(), 0.0f);
|
||||
apPosL[(size_t) i] = apPosR[(size_t) i] = 0;
|
||||
}
|
||||
std::fill (preL.begin(), preL.end(), 0.0f);
|
||||
std::fill (preR.begin(), preR.end(), 0.0f);
|
||||
prePos = 0;
|
||||
}
|
||||
|
||||
void ReverbUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const float feedback = maps::sizeToValue (p[0]);
|
||||
const float damping = p[1];
|
||||
const float width = p[2];
|
||||
const int preLen = (int) preL.size();
|
||||
const float preDelay = (preLen > 0) ? p[3] * 0.15f * sr : 0.0f;
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
// Predelay.
|
||||
float inL = l[i], inR = r[i];
|
||||
if (preLen > 0)
|
||||
{
|
||||
const int read = (prePos - (int) preDelay + preLen) % preLen;
|
||||
preL[(size_t) prePos] = inL;
|
||||
preR[(size_t) prePos] = inR;
|
||||
inL = preL[(size_t) read];
|
||||
inR = preR[(size_t) read];
|
||||
prePos = (prePos + 1) % preLen;
|
||||
}
|
||||
|
||||
// Combs.
|
||||
float sumL = 0.0f, sumR = 0.0f;
|
||||
for (int c = 0; c < 8; ++c)
|
||||
{
|
||||
auto& buf = combL[(size_t) c];
|
||||
const int pos = combPosL[(size_t) c];
|
||||
float out = buf[(size_t) pos];
|
||||
combFiltL[(size_t) c] = out * (1.0f - damping) + combFiltL[(size_t) c] * damping;
|
||||
buf[(size_t) pos] = inL + combFiltL[(size_t) c] * feedback;
|
||||
combPosL[(size_t) c] = (pos + 1) % (int) buf.size();
|
||||
sumL += out;
|
||||
}
|
||||
for (int c = 0; c < 8; ++c)
|
||||
{
|
||||
auto& buf = combR[(size_t) c];
|
||||
const int pos = combPosR[(size_t) c];
|
||||
float out = buf[(size_t) pos];
|
||||
combFiltR[(size_t) c] = out * (1.0f - damping) + combFiltR[(size_t) c] * damping;
|
||||
buf[(size_t) pos] = inR + combFiltR[(size_t) c] * feedback;
|
||||
combPosR[(size_t) c] = (pos + 1) % (int) buf.size();
|
||||
sumR += out;
|
||||
}
|
||||
|
||||
// Allpasses.
|
||||
float aL = sumL, aR = sumR;
|
||||
for (int a = 0; a < 4; ++a)
|
||||
{
|
||||
auto& buf = apL[(size_t) a];
|
||||
const int pos = apPosL[(size_t) a];
|
||||
const float stored = buf[(size_t) pos];
|
||||
buf[(size_t) pos] = aL + stored * 0.5f;
|
||||
aL = stored - aL;
|
||||
apPosL[(size_t) a] = (pos + 1) % (int) buf.size();
|
||||
}
|
||||
for (int a = 0; a < 4; ++a)
|
||||
{
|
||||
auto& buf = apR[(size_t) a];
|
||||
const int pos = apPosR[(size_t) a];
|
||||
const float stored = buf[(size_t) pos];
|
||||
buf[(size_t) pos] = aR + stored * 0.5f;
|
||||
aR = stored - aR;
|
||||
apPosR[(size_t) a] = (pos + 1) % (int) buf.size();
|
||||
}
|
||||
|
||||
// Width (stereo cross-mix).
|
||||
const float w = 1.0f - width * 0.5f;
|
||||
const float outL = aL * w + aR * (1.0f - w);
|
||||
const float outR = aR * w + aL * (1.0f - w);
|
||||
|
||||
l[i] = outL;
|
||||
r[i] = outR;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include "../FXProcessor.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Reverb — Freeverb-style Schroeder reverb (8 combs + 4 allpasses per channel)
|
||||
// with predelay. p[0]=size, p[1]=damping, p[2]=width, p[3]=predelay.
|
||||
// ===========================================================================
|
||||
class ReverbUnit : public FXUnit
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize) override;
|
||||
void reset() override;
|
||||
void process (float* l, float* r, int numSamples, const float p[4]) override;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
|
||||
std::array<std::vector<float>, 8> combL, combR;
|
||||
std::array<int, 8> combPosL {}, combPosR {};
|
||||
std::array<float, 8> combFiltL {}, combFiltR {};
|
||||
|
||||
std::array<std::vector<float>, 4> apL, apR;
|
||||
std::array<int, 4> apPosL {}, apPosR {};
|
||||
|
||||
std::vector<float> preL, preR;
|
||||
int prePos = 0;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,320 @@
|
||||
#include "Engine.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
inline float v (juce::AudioProcessorValueTreeState& apvts, const char* id)
|
||||
{
|
||||
if (auto* p = apvts.getRawParameterValue (id))
|
||||
return p->load();
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
inline int vic (juce::AudioProcessorValueTreeState& apvts, const char* id, int maxValue)
|
||||
{
|
||||
return juce::jlimit (0, maxValue, (int) std::llround (v (apvts, id) * maxValue));
|
||||
}
|
||||
|
||||
inline float limit (float x) noexcept
|
||||
{
|
||||
const float ax = std::fabs (x);
|
||||
if (ax < 0.8f)
|
||||
return x;
|
||||
const float over = ax - 0.8f;
|
||||
const float clipped = 0.8f + std::tanh (over) * 0.2f;
|
||||
return std::copysign (clipped, x);
|
||||
}
|
||||
|
||||
const char* kEnvAttack[kNumEnvelopes] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
|
||||
const char* kEnvDecay[kNumEnvelopes] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
|
||||
const char* kEnvSustain[kNumEnvelopes]= { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
|
||||
const char* kEnvRelease[kNumEnvelopes]= { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
|
||||
const char* kEnvCurve[kNumEnvelopes] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
|
||||
|
||||
const char* kLfoRate[kNumLfos] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate };
|
||||
const char* kLfoSync[kNumLfos] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync };
|
||||
const char* kLfoBeat[kNumLfos] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat };
|
||||
const char* kLfoShape[kNumLfos] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
|
||||
const char* kLfoPhase[kNumLfos] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase };
|
||||
const char* kLfoFade[kNumLfos] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade };
|
||||
const char* kLfoDelay[kNumLfos] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay };
|
||||
|
||||
const char* kFxType[kNumFxSlots] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
|
||||
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
|
||||
const char* kFxMix[kNumFxSlots] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
|
||||
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
|
||||
const char* kFxP1[kNumFxSlots] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1,
|
||||
ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 };
|
||||
const char* kFxP2[kNumFxSlots] = { ids::fx1P2, ids::fx2P2, ids::fx3P2, ids::fx4P2,
|
||||
ids::fx5P2, ids::fx6P2, ids::fx7P2, ids::fx8P2 };
|
||||
const char* kFxP3[kNumFxSlots] = { ids::fx1P3, ids::fx2P3, ids::fx3P3, ids::fx4P3,
|
||||
ids::fx5P3, ids::fx6P3, ids::fx7P3, ids::fx8P3 };
|
||||
const char* kFxP4[kNumFxSlots] = { ids::fx1P4, ids::fx2P4, ids::fx3P4, ids::fx4P4,
|
||||
ids::fx5P4, ids::fx6P4, ids::fx7P4, ids::fx8P4 };
|
||||
}
|
||||
|
||||
void Engine::prepare (double sampleRate, int maxBlockSize)
|
||||
{
|
||||
sr = sampleRate;
|
||||
blockSize = maxBlockSize;
|
||||
|
||||
for (auto& voice : voices)
|
||||
voice.prepare (sampleRate, maxBlockSize);
|
||||
for (auto& lfo : lfos)
|
||||
lfo.prepare (sampleRate);
|
||||
fx.prepare (sampleRate, maxBlockSize);
|
||||
mixBuffer.setSize (2, maxBlockSize, false, false, true);
|
||||
reset();
|
||||
}
|
||||
|
||||
void Engine::reset()
|
||||
{
|
||||
for (auto& voice : voices)
|
||||
voice.reset();
|
||||
for (auto& lfo : lfos)
|
||||
lfo.reset();
|
||||
fx.reset();
|
||||
pitchBend = 0.0f;
|
||||
modWheel = 0.0f;
|
||||
mixBuffer.clear();
|
||||
}
|
||||
|
||||
void Engine::setLfoShapeData (int index, const std::vector<float>& data, int steps)
|
||||
{
|
||||
index = juce::jlimit (0, kNumLfos - 1, index);
|
||||
lfos[(size_t) index].setShapeData (data, steps);
|
||||
}
|
||||
|
||||
int Engine::getActiveVoiceCount() const
|
||||
{
|
||||
int count = 0;
|
||||
for (const auto& v : voices)
|
||||
if (v.isActive())
|
||||
++count;
|
||||
return count;
|
||||
}
|
||||
|
||||
SynthVoice* Engine::findFreeVoice()
|
||||
{
|
||||
for (auto& v : voices)
|
||||
if (! v.isActive())
|
||||
return &v;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
SynthVoice* Engine::stealVoice()
|
||||
{
|
||||
// Prefer stealing an already-released voice, then the oldest active one.
|
||||
SynthVoice* best = nullptr;
|
||||
juce::uint64 bestId = std::numeric_limits<juce::uint64>::max();
|
||||
|
||||
for (auto& v : voices)
|
||||
if (v.isActive() && v.isReleased() && v.getNoteId() < bestId)
|
||||
{
|
||||
best = &v;
|
||||
bestId = v.getNoteId();
|
||||
}
|
||||
if (best != nullptr)
|
||||
return best;
|
||||
|
||||
for (auto& v : voices)
|
||||
if (v.isActive() && v.getNoteId() < bestId)
|
||||
{
|
||||
best = &v;
|
||||
bestId = v.getNoteId();
|
||||
}
|
||||
return best != nullptr ? best : &voices[0];
|
||||
}
|
||||
|
||||
void Engine::noteOn (int noteNumber, float velocity01)
|
||||
{
|
||||
SynthVoice* voice = findFreeVoice();
|
||||
if (voice == nullptr)
|
||||
voice = stealVoice();
|
||||
|
||||
const double freq = juce::MidiMessage::getMidiNoteInHertz (noteNumber);
|
||||
voice->noteOn (noteNumber, juce::jlimit (0.0f, 1.0f, velocity01), freq, (juce::uint32) (++noteCounter));
|
||||
}
|
||||
|
||||
void Engine::noteOff (int noteNumber)
|
||||
{
|
||||
for (auto& v : voices)
|
||||
if (v.isActive() && v.getNote() == noteNumber && ! v.isReleased())
|
||||
v.noteOff();
|
||||
}
|
||||
|
||||
void Engine::allNotesOff()
|
||||
{
|
||||
for (auto& v : voices)
|
||||
if (v.isActive())
|
||||
v.noteOff();
|
||||
}
|
||||
|
||||
void Engine::readOscParams (juce::AudioProcessorValueTreeState& apvts, const char* prefix, OscParams& o)
|
||||
{
|
||||
const juce::String p = prefix;
|
||||
auto g = [&] (const char* suffix) { return v (apvts, (p + suffix).toRawUTF8()); };
|
||||
|
||||
o.enabled = g ("On") > 0.5f;
|
||||
o.wave = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (g ("Wave") * (kNumWavetables - 1)));
|
||||
o.wtPos = g ("WtPos");
|
||||
o.warp = (int) std::llround (g ("Warp") * 7.0f);
|
||||
o.warpAmt = g ("WarpAmt");
|
||||
o.coarse = (int) std::llround (g ("Coarse") * 48.0f) - 24;
|
||||
o.fine = (int) std::llround (g ("Fine") * 200.0f) - 100;
|
||||
o.level = g ("Level");
|
||||
o.pan = g ("Pan") * 2.0f - 1.0f;
|
||||
o.unison = 1 + (int) std::llround (g ("Unison") * 15.0f);
|
||||
o.detune = g ("Detune");
|
||||
o.spread = g ("Spread");
|
||||
o.phase = g ("Phase");
|
||||
o.randPhase = g ("RandPh");
|
||||
}
|
||||
|
||||
void Engine::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi,
|
||||
juce::AudioProcessorValueTreeState& apvts,
|
||||
juce::AudioPlayHead* playhead)
|
||||
{
|
||||
const int n = buffer.getNumSamples();
|
||||
const int numCh = buffer.getNumChannels();
|
||||
|
||||
// Tempo.
|
||||
if (playhead != nullptr)
|
||||
if (auto pos = playhead->getPosition())
|
||||
if (auto b = pos->getBpm())
|
||||
bpm = *b;
|
||||
|
||||
// MIDI.
|
||||
for (const auto meta : midi)
|
||||
{
|
||||
const auto m = meta.getMessage();
|
||||
if (m.isNoteOn() && m.getVelocity() > 0)
|
||||
noteOn (m.getNoteNumber(), m.getFloatVelocity());
|
||||
else if (m.isNoteOff() || (m.isNoteOn() && m.getVelocity() == 0))
|
||||
noteOff (m.getNoteNumber());
|
||||
else if (m.isPitchWheel())
|
||||
pitchBend = (m.getPitchWheelValue() - 8192) / 8192.0f;
|
||||
else if (m.isController())
|
||||
{
|
||||
if (m.getControllerNumber() == 1)
|
||||
modWheel = m.getControllerValue() / 127.0f;
|
||||
else if (m.getControllerNumber() == 120 || m.getControllerNumber() == 123)
|
||||
allNotesOff();
|
||||
}
|
||||
else if (m.isAllNotesOff() || m.isAllSoundOff())
|
||||
allNotesOff();
|
||||
}
|
||||
|
||||
// Prepare the voice mix buffer.
|
||||
mixBuffer.setSize (2, n, false, false, true);
|
||||
mixBuffer.clear();
|
||||
float* mixL = mixBuffer.getWritePointer (0);
|
||||
float* mixR = mixBuffer.getWritePointer (1);
|
||||
|
||||
// Advance LFOs and capture their values (control rate).
|
||||
float lfoValues[kNumLfos];
|
||||
for (int i = 0; i < kNumLfos; ++i)
|
||||
{
|
||||
lfos[(size_t) i].setTempo (bpm);
|
||||
lfos[(size_t) i].setParams (v (apvts, kLfoRate[i]),
|
||||
v (apvts, kLfoSync[i]) > 0.5f,
|
||||
v (apvts, kLfoBeat[i]),
|
||||
vic (apvts, kLfoShape[i], 6),
|
||||
v (apvts, kLfoPhase[i]),
|
||||
v (apvts, kLfoFade[i]),
|
||||
v (apvts, kLfoDelay[i]));
|
||||
for (int s = 0; s < n; ++s)
|
||||
lfos[(size_t) i].process();
|
||||
lfoValues[i] = lfos[(size_t) i].getValue();
|
||||
}
|
||||
|
||||
const float macroValues[kNumMacros] = { v (apvts, ids::macro1), v (apvts, ids::macro2),
|
||||
v (apvts, ids::macro3), v (apvts, ids::macro4) };
|
||||
|
||||
// Build the render context.
|
||||
RenderContext ctx;
|
||||
ctx.sampleRate = sr;
|
||||
ctx.wavetables = &wavetables;
|
||||
for (int i = 0; i < kNumLfos; ++i) ctx.lfoValues[i] = lfoValues[i];
|
||||
for (int i = 0; i < kNumMacros; ++i) ctx.macroValues[i] = macroValues[i];
|
||||
ctx.modWheel = modWheel;
|
||||
ctx.pitchBend = pitchBend;
|
||||
ctx.pitchBendRange = 2.0f;
|
||||
ctx.matrix = &matrix;
|
||||
ctx.macros = ¯os;
|
||||
|
||||
readOscParams (apvts, "oscA", ctx.oscA);
|
||||
readOscParams (apvts, "oscB", ctx.oscB);
|
||||
|
||||
ctx.subOn = v (apvts, ids::subOn) > 0.5f;
|
||||
ctx.subShape = vic (apvts, ids::subShape, 1);
|
||||
ctx.subOct = vic (apvts, ids::subOct, 2) - 2;
|
||||
ctx.subLevel = v (apvts, ids::subLevel);
|
||||
|
||||
ctx.noiseOn = v (apvts, ids::noiseOn) > 0.5f;
|
||||
ctx.noiseType = vic (apvts, ids::noiseType, 1);
|
||||
ctx.noiseLevel = v (apvts, ids::noiseLevel);
|
||||
|
||||
ctx.filters.f1On = v (apvts, ids::f1On) > 0.5f;
|
||||
ctx.filters.f1Type = vic (apvts, ids::f1Type, 6);
|
||||
ctx.filters.f1Cutoff = v (apvts, ids::f1Cutoff);
|
||||
ctx.filters.f1Res = v (apvts, ids::f1Res);
|
||||
ctx.filters.f1Drive = v (apvts, ids::f1Drive);
|
||||
ctx.filters.f1Key = v (apvts, ids::f1Key);
|
||||
ctx.filters.f1Slope = vic (apvts, ids::f1Slope, 2);
|
||||
|
||||
ctx.filters.f2On = v (apvts, ids::f2On) > 0.5f;
|
||||
ctx.filters.f2Type = vic (apvts, ids::f2Type, 6);
|
||||
ctx.filters.f2Cutoff = v (apvts, ids::f2Cutoff);
|
||||
ctx.filters.f2Res = v (apvts, ids::f2Res);
|
||||
ctx.filters.f2Drive = v (apvts, ids::f2Drive);
|
||||
ctx.filters.f2Key = v (apvts, ids::f2Key);
|
||||
ctx.filters.f2Slope = vic (apvts, ids::f2Slope, 2);
|
||||
|
||||
ctx.filters.route = vic (apvts, ids::fRoute, 2);
|
||||
ctx.filters.mix = v (apvts, ids::fMix);
|
||||
ctx.filters.out = v (apvts, ids::fOut) * 1.5f;
|
||||
|
||||
for (int i = 0; i < kNumEnvelopes; ++i)
|
||||
{
|
||||
ctx.envAttack[i] = v (apvts, kEnvAttack[i]);
|
||||
ctx.envDecay[i] = v (apvts, kEnvDecay[i]);
|
||||
ctx.envSustain[i] = v (apvts, kEnvSustain[i]);
|
||||
ctx.envRelease[i] = v (apvts, kEnvRelease[i]);
|
||||
ctx.envCurve[i] = v (apvts, kEnvCurve[i]);
|
||||
}
|
||||
|
||||
// Render all active voices into the mix buffer.
|
||||
for (auto& voice : voices)
|
||||
if (voice.isActive())
|
||||
voice.render (mixL, mixR, n, ctx);
|
||||
|
||||
// FX rack.
|
||||
FxSlotParams slots[kNumFxSlots];
|
||||
for (int i = 0; i < kNumFxSlots; ++i)
|
||||
{
|
||||
slots[i].type = juce::jlimit (0, (int) FxType::Count - 1, (int) std::llround (v (apvts, kFxType[i]) * ((int) FxType::Count - 1)));
|
||||
slots[i].mix = v (apvts, kFxMix[i]);
|
||||
slots[i].p[0] = v (apvts, kFxP1[i]);
|
||||
slots[i].p[1] = v (apvts, kFxP2[i]);
|
||||
slots[i].p[2] = v (apvts, kFxP3[i]);
|
||||
slots[i].p[3] = v (apvts, kFxP4[i]);
|
||||
}
|
||||
|
||||
fx.process (mixBuffer, slots, kNumFxSlots);
|
||||
|
||||
// Master + soft limiting.
|
||||
const float master = v (apvts, ids::master);
|
||||
|
||||
for (int ch = 0; ch < numCh; ++ch)
|
||||
{
|
||||
float* dest = buffer.getWritePointer (ch);
|
||||
const float* src = mixBuffer.getReadPointer (ch < 2 ? ch : 0);
|
||||
for (int i = 0; i < n; ++i)
|
||||
dest[i] = limit (src[i] * master);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
#include "Wavetable.h"
|
||||
#include "SynthVoice.h"
|
||||
#include "LFO.h"
|
||||
#include "FXProcessor.h"
|
||||
#include "ModulationMatrix.h"
|
||||
#include "MacroControls.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// The synthesis engine: a fixed pool of voices, four global LFOs, a wavetable
|
||||
// library and the FX rack. Owns all per-block DSP and MIDI handling.
|
||||
// ===========================================================================
|
||||
class Engine
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int blockSize);
|
||||
void reset();
|
||||
|
||||
void processBlock (juce::AudioBuffer<float>& buffer,
|
||||
juce::MidiBuffer& midi,
|
||||
juce::AudioProcessorValueTreeState& apvts,
|
||||
juce::AudioPlayHead* playhead);
|
||||
|
||||
// MIDI
|
||||
void noteOn (int noteNumber, float velocity01);
|
||||
void noteOff (int noteNumber);
|
||||
void allNotesOff();
|
||||
|
||||
// Modulation / DSP accessors (read-only for the GUI).
|
||||
ModulationMatrix& getMatrix() { return matrix; }
|
||||
MacroControls& getMacros() { return macros; }
|
||||
WavetableLibrary& getWavetables() { return wavetables; }
|
||||
const std::array<LFO, kNumLfos>& getLfos() const { return lfos; }
|
||||
void setLfoShapeData (int index, const std::vector<float>& data, int steps);
|
||||
|
||||
int getActiveVoiceCount() const;
|
||||
|
||||
private:
|
||||
std::array<SynthVoice, kNumVoices> voices;
|
||||
std::array<LFO, kNumLfos> lfos;
|
||||
WavetableLibrary wavetables;
|
||||
FXProcessor fx;
|
||||
ModulationMatrix matrix;
|
||||
MacroControls macros;
|
||||
|
||||
double sr = 44100.0;
|
||||
int blockSize = 512;
|
||||
double bpm = 120.0;
|
||||
float pitchBend = 0.0f;
|
||||
float modWheel = 0.0f;
|
||||
juce::uint64 noteCounter = 0;
|
||||
|
||||
juce::AudioBuffer<float> mixBuffer;
|
||||
|
||||
SynthVoice* findFreeVoice();
|
||||
SynthVoice* stealVoice();
|
||||
|
||||
void readOscParams (juce::AudioProcessorValueTreeState& apvts, const char* prefix, OscParams& out);
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,116 @@
|
||||
#include "Envelope.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void Envelope::reset()
|
||||
{
|
||||
stage = Stage::Idle;
|
||||
value = 0.0f;
|
||||
startValue = endValue = 0.0f;
|
||||
elapsed = duration = 0.0;
|
||||
}
|
||||
|
||||
void Envelope::noteOn()
|
||||
{
|
||||
stage = Stage::Attack;
|
||||
elapsed = 0.0;
|
||||
duration = attackSamples;
|
||||
startValue = value; // retrigger from current level
|
||||
endValue = 1.0f;
|
||||
}
|
||||
|
||||
void Envelope::noteOff()
|
||||
{
|
||||
if (stage == Stage::Idle)
|
||||
return;
|
||||
|
||||
stage = Stage::Release;
|
||||
elapsed = 0.0;
|
||||
duration = releaseSamples;
|
||||
startValue = value;
|
||||
}
|
||||
|
||||
void Envelope::setParams (float attackSec, float decaySec, float sustainLevel, float releaseSec, float curve)
|
||||
{
|
||||
sustain = juce::jlimit (0.0f, 1.0f, sustainLevel);
|
||||
curve = juce::jlimit (0.0f, 1.0f, curve);
|
||||
|
||||
attackSamples = std::max (1.0, (double) attackSec * sr);
|
||||
decaySamples = std::max (1.0, (double) decaySec * sr);
|
||||
releaseSamples = std::max (1.0, (double) releaseSec * sr);
|
||||
|
||||
// curve 0 -> fast attack / long release curve; curve 1 -> slow attack / fast decay
|
||||
attackShape = 0.3 + curve * 2.7; // 0.3 .. 3.0
|
||||
decayShape = 3.0 - curve * 2.7; // 3.0 .. 0.3
|
||||
|
||||
shape.attack = attackSec;
|
||||
shape.decay = decaySec;
|
||||
shape.sustain = sustain;
|
||||
shape.release = releaseSec;
|
||||
shape.curve = curve;
|
||||
}
|
||||
|
||||
float Envelope::process() noexcept
|
||||
{
|
||||
switch (stage)
|
||||
{
|
||||
case Stage::Idle:
|
||||
value = 0.0f;
|
||||
return 0.0f;
|
||||
|
||||
case Stage::Attack:
|
||||
elapsed += 1.0;
|
||||
if (duration <= 1.0 || elapsed >= duration)
|
||||
{
|
||||
value = endValue;
|
||||
stage = Stage::Decay;
|
||||
elapsed = 0.0;
|
||||
duration = decaySamples;
|
||||
startValue = value;
|
||||
endValue = sustain;
|
||||
}
|
||||
else
|
||||
{
|
||||
const double p = elapsed / duration;
|
||||
value = startValue + (endValue - startValue) * (float) std::pow (p, attackShape);
|
||||
}
|
||||
return value;
|
||||
|
||||
case Stage::Decay:
|
||||
elapsed += 1.0;
|
||||
if (duration <= 1.0 || elapsed >= duration)
|
||||
{
|
||||
value = sustain;
|
||||
stage = Stage::Sustain;
|
||||
}
|
||||
else
|
||||
{
|
||||
const double p = elapsed / duration;
|
||||
value = endValue + (startValue - endValue) * (float) std::pow (1.0 - p, decayShape);
|
||||
}
|
||||
return value;
|
||||
|
||||
case Stage::Sustain:
|
||||
value = sustain;
|
||||
return value;
|
||||
|
||||
case Stage::Release:
|
||||
elapsed += 1.0;
|
||||
if (duration <= 1.0 || elapsed >= duration)
|
||||
{
|
||||
value = 0.0f;
|
||||
stage = Stage::Idle;
|
||||
}
|
||||
else
|
||||
{
|
||||
const double p = elapsed / duration;
|
||||
value = startValue * (float) std::pow (1.0 - p, decayShape);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// ADSR envelope with curve tension. Durations are in seconds; the sustain
|
||||
// level is 0..1. Retriggering (noteOn while active) starts from the current
|
||||
// value for click-free legato behaviour.
|
||||
// ===========================================================================
|
||||
class Envelope
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate) { sr = sampleRate; reset(); }
|
||||
void reset();
|
||||
|
||||
void noteOn();
|
||||
void noteOff();
|
||||
|
||||
void setParams (float attackSec, float decaySec, float sustain, float releaseSec, float curve);
|
||||
|
||||
float process() noexcept; // advance one sample and return the value
|
||||
float getValue() const noexcept { return value; }
|
||||
bool isActive() const noexcept { return stage != Stage::Idle; }
|
||||
|
||||
// Snapshot for GUI rendering.
|
||||
struct Shape { float attack = 0, decay = 0, sustain = 0, release = 0, curve = 0.5f; };
|
||||
Shape getShape() const noexcept { return shape; }
|
||||
|
||||
private:
|
||||
enum class Stage { Idle, Attack, Decay, Sustain, Release };
|
||||
|
||||
Stage stage = Stage::Idle;
|
||||
double sr = 44100.0;
|
||||
|
||||
float value = 0.0f;
|
||||
float startValue = 0.0f, endValue = 0.0f;
|
||||
double elapsed = 0.0, duration = 0.0;
|
||||
double attackShape = 1.0, decayShape = 1.0;
|
||||
|
||||
float sustain = 0.7f;
|
||||
double attackSamples = 0.0, decaySamples = 0.0, releaseSamples = 0.0;
|
||||
Shape shape;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,111 @@
|
||||
#include "FXProcessor.h"
|
||||
#include "EffectUnits/Hyper.h"
|
||||
#include "EffectUnits/Chorus.h"
|
||||
#include "EffectUnits/Flanger.h"
|
||||
#include "EffectUnits/Phaser.h"
|
||||
#include "EffectUnits/Distortion.h"
|
||||
#include "EffectUnits/EQ.h"
|
||||
#include "EffectUnits/Compressor.h"
|
||||
#include "EffectUnits/Delay.h"
|
||||
#include "EffectUnits/Reverb.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
FXProcessor::FXProcessor()
|
||||
{
|
||||
units[0] = std::make_unique<HyperUnit>();
|
||||
units[1] = std::make_unique<ChorusUnit>();
|
||||
units[2] = std::make_unique<FlangerUnit>();
|
||||
units[3] = std::make_unique<PhaserUnit>();
|
||||
units[4] = std::make_unique<DistortionUnit>();
|
||||
units[5] = std::make_unique<EQUnit>();
|
||||
units[6] = std::make_unique<CompressorUnit>();
|
||||
units[7] = std::make_unique<DelayUnit>();
|
||||
units[8] = std::make_unique<ReverbUnit>();
|
||||
}
|
||||
|
||||
FXProcessor::~FXProcessor() = default;
|
||||
|
||||
void FXProcessor::prepare (double sampleRate, int maxBlockSize)
|
||||
{
|
||||
for (auto& u : units)
|
||||
u->prepare (sampleRate, maxBlockSize);
|
||||
dry.setSize (2, maxBlockSize, false, false, true);
|
||||
wet.setSize (2, maxBlockSize, false, false, true);
|
||||
reset();
|
||||
}
|
||||
|
||||
void FXProcessor::reset()
|
||||
{
|
||||
for (auto& u : units)
|
||||
u->reset();
|
||||
dry.clear();
|
||||
wet.clear();
|
||||
}
|
||||
|
||||
juce::String FXProcessor::fxTypeName (int type)
|
||||
{
|
||||
switch ((FxType) type)
|
||||
{
|
||||
case FxType::Off: return "Off";
|
||||
case FxType::Hyper: return "Hyper";
|
||||
case FxType::Chorus: return "Chorus";
|
||||
case FxType::Flanger: return "Flanger";
|
||||
case FxType::Phaser: return "Phaser";
|
||||
case FxType::Distortion: return "Distortion";
|
||||
case FxType::EQ: return "EQ";
|
||||
case FxType::Compressor: return "Compressor";
|
||||
case FxType::Delay: return "Delay";
|
||||
case FxType::Reverb: return "Reverb";
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
void FXProcessor::process (juce::AudioBuffer<float>& buffer, const FxSlotParams* slots, int numSlots)
|
||||
{
|
||||
const int n = buffer.getNumSamples();
|
||||
if (n <= 0)
|
||||
return;
|
||||
|
||||
for (int s = 0; s < numSlots; ++s)
|
||||
{
|
||||
const FxSlotParams& slot = slots[s];
|
||||
if (slot.type == (int) FxType::Off)
|
||||
continue;
|
||||
|
||||
const int unitIdx = slot.type - 1;
|
||||
if (unitIdx < 0 || unitIdx >= (int) units.size())
|
||||
continue;
|
||||
|
||||
dry.copyFrom (0, 0, buffer, 0, 0, n);
|
||||
dry.copyFrom (1, 1, buffer, 1, 0, n);
|
||||
wet.copyFrom (0, 0, buffer, 0, 0, n);
|
||||
wet.copyFrom (1, 1, buffer, 1, 0, n);
|
||||
|
||||
units[(size_t) unitIdx]->process (wet.getWritePointer (0), wet.getWritePointer (1), n, slot.p);
|
||||
|
||||
const float mix = juce::jlimit (0.0f, 1.0f, slot.mix);
|
||||
if (mix >= 1.0f)
|
||||
{
|
||||
buffer.copyFrom (0, 0, wet, 0, 0, n);
|
||||
buffer.copyFrom (1, 0, wet, 1, 0, n);
|
||||
}
|
||||
else if (mix > 0.0f)
|
||||
{
|
||||
const float* dL = dry.getReadPointer (0);
|
||||
const float* dR = dry.getReadPointer (1);
|
||||
const float* wL = wet.getReadPointer (0);
|
||||
const float* wR = wet.getReadPointer (1);
|
||||
float* oL = buffer.getWritePointer (0);
|
||||
float* oR = buffer.getWritePointer (1);
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
oL[i] = dL[i] * (1.0f - mix) + wL[i] * mix;
|
||||
oR[i] = dR[i] * (1.0f - mix) + wR[i] * mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Base class for a stereo effect unit. Each unit processes a full-wet buffer.
|
||||
// ===========================================================================
|
||||
class FXUnit
|
||||
{
|
||||
public:
|
||||
virtual ~FXUnit() = default;
|
||||
virtual void prepare (double sampleRate, int maxBlockSize) = 0;
|
||||
virtual void reset() = 0;
|
||||
virtual void process (float* l, float* r, int numSamples, const float p[4]) = 0;
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Snapshot of one FX slot.
|
||||
// ===========================================================================
|
||||
struct FxSlotParams
|
||||
{
|
||||
int type = (int) FxType::Off;
|
||||
float mix = 0.0f;
|
||||
float p[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Reorderable effects rack. Slots are processed in list order; reordering is
|
||||
// simply swapping FxSlotParams entries. Nine effect unit implementations are
|
||||
// owned here and shared across slots.
|
||||
// ===========================================================================
|
||||
class FXProcessor
|
||||
{
|
||||
public:
|
||||
FXProcessor();
|
||||
~FXProcessor();
|
||||
|
||||
void prepare (double sampleRate, int maxBlockSize);
|
||||
void reset();
|
||||
|
||||
void process (juce::AudioBuffer<float>& buffer, const FxSlotParams* slots, int numSlots);
|
||||
|
||||
static juce::String fxTypeName (int type);
|
||||
|
||||
private:
|
||||
std::array<std::unique_ptr<FXUnit>, 9> units;
|
||||
juce::AudioBuffer<float> dry, wet;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,252 @@
|
||||
#include "Filter.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
inline float clampF (float v, float lo, float hi) noexcept
|
||||
{
|
||||
return v < lo ? lo : (v > hi ? hi : v);
|
||||
}
|
||||
|
||||
inline double clampD (double v, double lo, double hi) noexcept
|
||||
{
|
||||
return v < lo ? lo : (v > hi ? hi : v);
|
||||
}
|
||||
}
|
||||
|
||||
void Filter::prepare (double sampleRate, int maxBlockSize)
|
||||
{
|
||||
sr = sampleRate;
|
||||
combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay
|
||||
combWrite = 0;
|
||||
combDamp = 0.0;
|
||||
reset();
|
||||
(void) maxBlockSize;
|
||||
}
|
||||
|
||||
void Filter::reset()
|
||||
{
|
||||
ic1eq = ic2eq = 0.0;
|
||||
lastG = lastK = 0.0;
|
||||
a1 = a2 = a3 = 0.0;
|
||||
stage.fill (0.0);
|
||||
for (auto& s : formantState) s.fill (0.0);
|
||||
std::fill (combLine.begin(), combLine.end(), 0.0f);
|
||||
combWrite = 0;
|
||||
combDamp = 0.0;
|
||||
}
|
||||
|
||||
void Filter::updateSvf (double g, double k) noexcept
|
||||
{
|
||||
if (g == lastG && k == lastK)
|
||||
return;
|
||||
lastG = g;
|
||||
lastK = k;
|
||||
a1 = 1.0 / (1.0 + g * (g + k));
|
||||
a2 = g * a1;
|
||||
a3 = g * a2;
|
||||
}
|
||||
|
||||
double Filter::svfLow (double in, double g, double k) noexcept
|
||||
{
|
||||
updateSvf (g, k);
|
||||
const double v3 = in - ic2eq;
|
||||
const double v1 = a1 * ic1eq + a2 * v3;
|
||||
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
|
||||
ic1eq = 2.0 * v1 - ic1eq;
|
||||
ic2eq = 2.0 * v2 - ic2eq;
|
||||
return v2;
|
||||
}
|
||||
|
||||
double Filter::svfBand (double in, double g, double k) noexcept
|
||||
{
|
||||
updateSvf (g, k);
|
||||
const double v3 = in - ic2eq;
|
||||
const double v1 = a1 * ic1eq + a2 * v3;
|
||||
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
|
||||
ic1eq = 2.0 * v1 - ic1eq;
|
||||
ic2eq = 2.0 * v2 - ic2eq;
|
||||
return v1;
|
||||
}
|
||||
|
||||
double Filter::svfHigh (double in, double g, double k) noexcept
|
||||
{
|
||||
updateSvf (g, k);
|
||||
const double v3 = in - ic2eq;
|
||||
const double v1 = a1 * ic1eq + a2 * v3;
|
||||
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
|
||||
ic1eq = 2.0 * v1 - ic1eq;
|
||||
ic2eq = 2.0 * v2 - ic2eq;
|
||||
return in - k * v1 - v2;
|
||||
}
|
||||
|
||||
double Filter::ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept
|
||||
{
|
||||
stages = juce::jlimit (1, 4, stages);
|
||||
res = clampD (res, 0.0, 0.97);
|
||||
|
||||
double x;
|
||||
if (diode)
|
||||
{
|
||||
// Diode ladder: softer, asymmetric feedback and diode clipping.
|
||||
x = in - 3.0 * res * (stage[(size_t) (stages - 1)] - in * 0.5);
|
||||
x = x / (1.0 + std::abs (x)); // diode curve
|
||||
x = x * (1.0 + drive * 3.0);
|
||||
}
|
||||
else
|
||||
{
|
||||
x = in - 4.0 * res * stage[(size_t) (stages - 1)];
|
||||
x = std::tanh (x * (1.0 + drive * 6.0)); // Moog-ish input saturation
|
||||
}
|
||||
|
||||
for (int i = 0; i < stages; ++i)
|
||||
{
|
||||
stage[(size_t) i] += g * (x - stage[(size_t) i]);
|
||||
x = stage[(size_t) i];
|
||||
}
|
||||
|
||||
x = clampD (x, -8.0, 8.0);
|
||||
return x;
|
||||
}
|
||||
|
||||
double Filter::comb (double in, double freqHz, double res, double drive) noexcept
|
||||
{
|
||||
const int len = (int) combLine.size();
|
||||
if (len < 4)
|
||||
return in;
|
||||
|
||||
const double freq = clampD (freqHz, 20.0, sr * 0.45);
|
||||
double delay = sr / freq;
|
||||
delay = clampD (delay, 2.0, (double) len - 2.0);
|
||||
|
||||
int readPos = combWrite - (int) delay;
|
||||
if (readPos < 0) readPos += len;
|
||||
const int readPos2 = (readPos + 1) % len;
|
||||
const float frac = (float) (delay - std::floor (delay));
|
||||
|
||||
const float y0 = combLine[(size_t) readPos];
|
||||
const float y1 = combLine[(size_t) readPos2];
|
||||
float y = y0 + (y1 - y0) * frac;
|
||||
|
||||
// Damping lowpass in the feedback path (drive controls damping).
|
||||
const double dampCoef = 1.0 - clampD (drive, 0.0, 0.99);
|
||||
combDamp = y * (1.0 - dampCoef) + combDamp * dampCoef;
|
||||
|
||||
const float feedback = clampF ((float) res * 0.9f, 0.0f, 0.98f);
|
||||
combLine[(size_t) combWrite] = (float) in + (float) combDamp * feedback;
|
||||
combWrite = (combWrite + 1) % len;
|
||||
|
||||
return (double) y;
|
||||
}
|
||||
|
||||
double Filter::formant (double in, double morph, double res) noexcept
|
||||
{
|
||||
// morph (0..1) sweeps the three bandpass centres to produce vowel-like spectra.
|
||||
const double m = clampD (morph, 0.0, 1.0);
|
||||
const double base[3] = { 400.0, 1200.0, 2600.0 };
|
||||
const double k = clampD (2.0 * (1.0 - res), 0.05, 2.0);
|
||||
|
||||
double out = 0.0;
|
||||
const double gains[3] = { 1.0, 0.8, 0.5 };
|
||||
for (int i = 0; i < 3; ++i)
|
||||
{
|
||||
const double fc = base[i] * (0.7 + 1.6 * m) * (i == 2 ? 0.9 : 1.0);
|
||||
const double g = std::tan (kPi * clampD (fc, 30.0, sr * 0.45) / sr);
|
||||
const double a1 = 1.0 / (1.0 + g * (g + k));
|
||||
const double a2 = g * a1;
|
||||
const double a3 = g * a2;
|
||||
const double v3 = in - formantState[(size_t) i][1];
|
||||
const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3;
|
||||
const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * v3;
|
||||
formantState[(size_t) i][0] = 2.0 * v1 - formantState[(size_t) i][0];
|
||||
formantState[(size_t) i][1] = 2.0 * v2 - formantState[(size_t) i][1];
|
||||
out += v1 * gains[i];
|
||||
}
|
||||
return clampD (out * 0.5, -8.0, 8.0);
|
||||
}
|
||||
|
||||
double Filter::screamer (double in, double cutoffHz, double res, double drive) noexcept
|
||||
{
|
||||
const double g = std::tan (kPi * clampD (cutoffHz, 30.0, sr * 0.45) / sr);
|
||||
const double k = clampD (2.0 * (1.0 - res), 0.05, 2.0);
|
||||
const double band = svfBand (in, g, k);
|
||||
const double driven = std::tanh (band * (1.0 + drive * 12.0));
|
||||
return driven * (1.0 - drive * 0.4);
|
||||
}
|
||||
|
||||
float Filter::processSample (float in, float cutoffHz, float res, float drive, int type, int slope) noexcept
|
||||
{
|
||||
res = clampF (res, 0.0f, 0.98f);
|
||||
drive = clampF (drive, 0.0f, 1.0f);
|
||||
|
||||
// Ladder stages are cascaded one-poles, which need an exponential coefficient
|
||||
// (always in (0,1]) for unconditional stability. The TPT SVF (formant/screamer)
|
||||
// computes its own tan()-based g internally.
|
||||
const double fc = clampD (cutoffHz, 20.0, sr * 0.45);
|
||||
const double g = 1.0 - std::exp (-2.0 * kPi * fc / sr);
|
||||
double out = (double) in;
|
||||
|
||||
switch ((FilterModel) type)
|
||||
{
|
||||
case FilterModel::LadderLP:
|
||||
{
|
||||
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
|
||||
out = ladder (in, g, res, drive, stages, false);
|
||||
break;
|
||||
}
|
||||
case FilterModel::LadderHP:
|
||||
{
|
||||
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
|
||||
out = (double) in - ladder (in, g, res, drive, stages, false);
|
||||
break;
|
||||
}
|
||||
case FilterModel::LadderBP:
|
||||
{
|
||||
int stages = (slope == 0) ? 2 : (slope == 1) ? 2 : 4;
|
||||
out = ladder (in, g, res, drive, stages, false);
|
||||
out = stage[0] - stage[(size_t) (stages - 1)];
|
||||
break;
|
||||
}
|
||||
case FilterModel::Diode:
|
||||
{
|
||||
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
|
||||
out = ladder (in, g, res, drive, stages, true);
|
||||
break;
|
||||
}
|
||||
case FilterModel::Comb:
|
||||
out = comb (in, cutoffHz, res, drive);
|
||||
break;
|
||||
case FilterModel::Formant:
|
||||
out = formant (in, maps::hzToCutoff (cutoffHz), res);
|
||||
break;
|
||||
case FilterModel::Screamer:
|
||||
out = screamer (in, cutoffHz, res, drive);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (float) clampD (out, -8.0, 8.0);
|
||||
}
|
||||
|
||||
void Filter::process (float* samples, int numSamples, float cutoffNorm, float res,
|
||||
float drive, float keytrack, float noteHz, int type, int slope) noexcept
|
||||
{
|
||||
if (samples == nullptr || numSamples <= 0)
|
||||
return;
|
||||
|
||||
// Keytrack shifts the cutoff with note pitch.
|
||||
const float noteNumber = (noteHz > 0.0f) ? (69.0f + 12.0f * std::log2f (noteHz / 440.0f)) : 60.0f;
|
||||
const float baseHz = maps::cutoffToHz (cutoffNorm);
|
||||
const float keyFactor = std::pow (2.0f, keytrack * (noteNumber - 60.0f) / 12.0f);
|
||||
const float cutoffHz = clampF (baseHz * keyFactor, 20.0f, 18000.0f);
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
samples[i] = processSample (samples[i], cutoffHz, res, drive, type, slope);
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Mono filter with 7 models (Ladder LP/HP/BP, Diode, Comb, Formant, Screamer),
|
||||
// 6/12/24 dB slopes (ladder family) and per-sample drive + keytrack. All state
|
||||
// is clamped to prevent NaN/inf at extreme settings.
|
||||
// ===========================================================================
|
||||
class Filter
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize);
|
||||
void reset();
|
||||
|
||||
// Process a mono buffer in-place. cutoffNorm is 0..1 (mapped to 20Hz..20kHz).
|
||||
void process (float* samples, int numSamples, float cutoffNorm, float res,
|
||||
float drive, float keytrack, float noteHz, int type, int slope) noexcept;
|
||||
|
||||
// Single-sample version (used by the comb/naive paths where convenient).
|
||||
float processSample (float in, float cutoffHz, float res, float drive, int type, int slope) noexcept;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
|
||||
// TPT SVF state (also reused by formant/screamer).
|
||||
double ic1eq = 0.0, ic2eq = 0.0;
|
||||
double lastG = 0.0, lastK = 0.0;
|
||||
double a1 = 0.0, a2 = 0.0, a3 = 0.0;
|
||||
|
||||
// Ladder stage state.
|
||||
std::array<double, 4> stage { { 0.0, 0.0, 0.0, 0.0 } };
|
||||
|
||||
// Comb delay line.
|
||||
std::vector<float> combLine;
|
||||
int combWrite = 0;
|
||||
double combDamp = 0.0;
|
||||
|
||||
// Formant: three parallel bandpass SVFs (state pairs).
|
||||
std::array<std::array<double, 2>, 3> formantState { { { { 0.0, 0.0 } }, { { 0.0, 0.0 } }, { { 0.0, 0.0 } } } };
|
||||
|
||||
void updateSvf (double g, double k) noexcept;
|
||||
double svfLow (double in, double g, double k) noexcept;
|
||||
double svfBand (double in, double g, double k) noexcept;
|
||||
double svfHigh (double in, double g, double k) noexcept;
|
||||
double ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept;
|
||||
double comb (double in, double freqHz, double res, double drive) noexcept;
|
||||
double formant (double in, double morph, double res) noexcept;
|
||||
double screamer (double in, double cutoffHz, double res, double drive) noexcept;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,79 @@
|
||||
#include "FilterBank.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void FilterBank::prepare (double sampleRate, int maxBlockSize)
|
||||
{
|
||||
f1L.prepare (sampleRate, maxBlockSize);
|
||||
f1R.prepare (sampleRate, maxBlockSize);
|
||||
f2L.prepare (sampleRate, maxBlockSize);
|
||||
f2R.prepare (sampleRate, maxBlockSize);
|
||||
}
|
||||
|
||||
void FilterBank::reset()
|
||||
{
|
||||
f1L.reset(); f1R.reset(); f2L.reset(); f2R.reset();
|
||||
}
|
||||
|
||||
void FilterBank::applySlot (Filter& f, float* buf, int n, bool on, int type, float cutoff,
|
||||
float res, float drive, float key, int slope, float noteHz) noexcept
|
||||
{
|
||||
if (on)
|
||||
f.process (buf, n, cutoff, res, drive, key, noteHz, type, slope);
|
||||
}
|
||||
|
||||
void FilterBank::process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept
|
||||
{
|
||||
const bool anyFilter = p.f1On || p.f2On;
|
||||
if (! anyFilter)
|
||||
return;
|
||||
|
||||
if (p.route == (int) FilterRoute::Serial)
|
||||
{
|
||||
applySlot (f1L, l, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
|
||||
applySlot (f1R, r, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
|
||||
applySlot (f2L, l, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
|
||||
applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
|
||||
}
|
||||
else if (p.route == (int) FilterRoute::Parallel)
|
||||
{
|
||||
// Run both filters on copies and crossfade.
|
||||
float f1l = 0.0f, f1r = 0.0f, f2l = 0.0f, f2r = 0.0f;
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
f1l = l[i]; f1r = r[i];
|
||||
f2l = l[i]; f2r = r[i];
|
||||
if (p.f1On)
|
||||
{
|
||||
f1l = f1L.processSample (f1l, maps::cutoffToHz (p.f1Cutoff), p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
|
||||
f1r = f1R.processSample (f1r, maps::cutoffToHz (p.f1Cutoff), p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
|
||||
}
|
||||
if (p.f2On)
|
||||
{
|
||||
f2l = f2L.processSample (f2l, maps::cutoffToHz (p.f2Cutoff), p.f2Res, p.f2Drive, p.f2Type, p.f2Slope);
|
||||
f2r = f2R.processSample (f2r, maps::cutoffToHz (p.f2Cutoff), p.f2Res, p.f2Drive, p.f2Type, p.f2Slope);
|
||||
}
|
||||
const float m = p.mix;
|
||||
l[i] = f1l * (1.0f - m) + f2l * m;
|
||||
r[i] = f1r * (1.0f - m) + f2r * m;
|
||||
}
|
||||
}
|
||||
else // Split: filter 1 -> left, filter 2 -> right
|
||||
{
|
||||
applySlot (f1L, l, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
|
||||
applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
|
||||
}
|
||||
|
||||
// Post-filter output level.
|
||||
if (p.out != 1.0f)
|
||||
{
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
l[i] *= p.out;
|
||||
r[i] *= p.out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
#include "Filter.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Snapshot of the two-slot filter section.
|
||||
// ===========================================================================
|
||||
struct FilterBankParams
|
||||
{
|
||||
bool f1On = false, f2On = false;
|
||||
int f1Type = 0, f2Type = 0;
|
||||
float f1Cutoff = 0.5f, f2Cutoff = 0.5f;
|
||||
float f1Res = 0.0f, f2Res = 0.0f;
|
||||
float f1Drive = 0.0f, f2Drive = 0.0f;
|
||||
float f1Key = 0.0f, f2Key = 0.0f;
|
||||
int f1Slope = 2, f2Slope = 2;
|
||||
int route = 0;
|
||||
float mix = 0.5f;
|
||||
float out = 1.0f;
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Two filter slots with serial / parallel / split routing, processed stereo.
|
||||
// ===========================================================================
|
||||
class FilterBank
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize);
|
||||
void reset();
|
||||
|
||||
void process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept;
|
||||
|
||||
private:
|
||||
Filter f1L, f1R, f2L, f2R;
|
||||
|
||||
static void applySlot (Filter& f, float* buf, int n, bool on, int type, float cutoff,
|
||||
float res, float drive, float key, int slope, float noteHz) noexcept;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,47 @@
|
||||
# gui module
|
||||
|
||||
## Owned files
|
||||
|
||||
- Related editor implementation: `../PluginEditor.h`, `../PluginEditor.cpp`
|
||||
- SerumLookAndFeel.h
|
||||
- Knob.h, Knob.cpp
|
||||
- Slider.h, Slider.cpp
|
||||
- ToggleButton.h, ToggleButton.cpp
|
||||
- Display.h, Display.cpp
|
||||
- Panel.h, Panel.cpp
|
||||
- WaveformDisplay.h, WaveformDisplay.cpp
|
||||
- FilterDisplay.h, FilterDisplay.cpp
|
||||
- EnvelopeDisplay.h, EnvelopeDisplay.cpp
|
||||
- LFODisplay.h, LFODisplay.cpp
|
||||
|
||||
## Rules
|
||||
|
||||
- Draw all controls through `SerumLookAndFeel`.
|
||||
- Use `theme::` colours from Resources.h instead of hardcoded colour values.
|
||||
- Own GUI attachments with `std::unique_ptr`.
|
||||
- Own component children by value or with `std::unique_ptr`; `addAndMakeVisible` only attaches and shows them, and does not manage their lifetime.
|
||||
- Refresh visuals from a `juce::Timer` callback on the message thread, not from the audio thread.
|
||||
- Keep layout constants in the `layout` namespace and derive every position from them.
|
||||
- Use `std::function` formatters for value readouts.
|
||||
- Reference `ids::` for APVTS parameter IDs.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF a control reads a DSP value THEN refresh it on the message thread from a thread-safe snapshot or atomic value. A `timerCallback` alone does not make a shared DSP read safe.
|
||||
- IF a raw parameter pointer is dereferenced THEN null-guard it first.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: unchecked raw parameter dereference
|
||||
const float v = processor.parameters.getRawParameterValue (ids::f1Cutoff)->load();
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: null guard with fallback
|
||||
const float v = processor.parameters.getRawParameterValue (ids::f1Cutoff)
|
||||
? processor.parameters.getRawParameterValue (ids::f1Cutoff)->load()
|
||||
: 0.0f;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
@@ -0,0 +1,27 @@
|
||||
#include "Display.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void Display::paint (juce::Graphics& g)
|
||||
{
|
||||
const auto b = getLocalBounds().toFloat();
|
||||
g.setColour (juce::Colours::black.withAlpha (0.45f));
|
||||
g.fillRoundedRectangle (b, 4.0f);
|
||||
g.setColour (theme::outline);
|
||||
g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f);
|
||||
|
||||
if (title.isNotEmpty())
|
||||
{
|
||||
g.setColour (theme::textDim);
|
||||
g.setFont (juce::Font (10.0f));
|
||||
g.drawText (title, 0, 3, getWidth(), 14, juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
g.setColour (theme::accent);
|
||||
g.setFont (juce::Font (14.0f, juce::Font::bold));
|
||||
g.drawText (value, 0, title.isNotEmpty() ? 16 : 0, getWidth(), getHeight() - (title.isNotEmpty() ? 16 : 0),
|
||||
juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// LED-style text readout (e.g. preset name / master level).
|
||||
// ===========================================================================
|
||||
class Display : public juce::Component
|
||||
{
|
||||
public:
|
||||
void setTitle (const juce::String& t) { title = t; repaint(); }
|
||||
void setValue (const juce::String& v) { value = v; repaint(); }
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
|
||||
private:
|
||||
juce::String title, value;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,80 @@
|
||||
#include "EnvelopeDisplay.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c)
|
||||
{
|
||||
attack = a; decay = d; sustain = s; release = r; curve = c;
|
||||
}
|
||||
|
||||
void EnvelopeDisplay::paint (juce::Graphics& g)
|
||||
{
|
||||
const auto b = getLocalBounds().toFloat().reduced (4.0f);
|
||||
g.setColour (juce::Colours::black.withAlpha (0.4f));
|
||||
g.fillRoundedRectangle (b.expanded (4.0f), 4.0f);
|
||||
g.setColour (theme::outline);
|
||||
g.drawRoundedRectangle (b.expanded (4.0f).reduced (0.5f), 4.0f, 1.0f);
|
||||
|
||||
const float atkShape = 0.3f + curve * 2.7f;
|
||||
const float decShape = 3.0f - curve * 2.7f;
|
||||
|
||||
// Normalise durations for display (attack 0..1, decay 0..0.6, release 0..0.6).
|
||||
const float aSec = maps::toSeconds (attack);
|
||||
const float dSec = maps::toSeconds (decay);
|
||||
const float rSec = maps::toSeconds (release);
|
||||
const float total = aSec + dSec + rSec + 0.01f;
|
||||
const float ax = (aSec / total);
|
||||
const float dx = (dSec / total);
|
||||
const float rx = (rSec / total);
|
||||
|
||||
const float left = b.getX();
|
||||
const float right = b.getRight();
|
||||
const float bottom = b.getBottom();
|
||||
const float top = b.getY();
|
||||
const float sustainY = bottom - sustain * b.getHeight();
|
||||
|
||||
juce::Path path;
|
||||
path.startNewSubPath (left, bottom);
|
||||
path.lineTo (left, top);
|
||||
|
||||
// Attack (curve-shaped).
|
||||
const int steps = 48;
|
||||
const float peakX = left + ax * b.getWidth();
|
||||
for (int i = 0; i <= steps; ++i)
|
||||
{
|
||||
const float p = (float) i / steps;
|
||||
const float y = top + (bottom - top) * std::pow (p, atkShape);
|
||||
path.lineTo (left + p * (peakX - left), y);
|
||||
}
|
||||
|
||||
// Decay to sustain.
|
||||
const float decX = peakX + dx * b.getWidth();
|
||||
for (int i = 0; i <= steps; ++i)
|
||||
{
|
||||
const float p = (float) i / steps;
|
||||
const float y = sustainY + (bottom - sustainY) * std::pow (1.0f - p, decShape);
|
||||
path.lineTo (peakX + p * (decX - peakX), y);
|
||||
}
|
||||
path.lineTo (decX, sustainY);
|
||||
path.lineTo (right - rx * b.getWidth(), sustainY);
|
||||
|
||||
// Release.
|
||||
const float relStartX = right - rx * b.getWidth();
|
||||
for (int i = 0; i <= steps; ++i)
|
||||
{
|
||||
const float p = (float) i / steps;
|
||||
const float y = sustainY + (bottom - sustainY) * std::pow (1.0f - p, decShape);
|
||||
path.lineTo (relStartX + p * (right - relStartX), y);
|
||||
}
|
||||
|
||||
g.setColour (theme::accent2.withAlpha (0.3f));
|
||||
juce::Path fill = path;
|
||||
fill.lineTo (right, bottom);
|
||||
fill.closeSubPath();
|
||||
g.fillPath (fill);
|
||||
g.setColour (theme::accent2);
|
||||
g.strokePath (path, juce::PathStrokeType (1.5f));
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
#include "../Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// ADSR curve preview.
|
||||
// ===========================================================================
|
||||
class EnvelopeDisplay : public juce::Component
|
||||
{
|
||||
public:
|
||||
void setParams (float attack, float decay, float sustain, float release, float curve);
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
|
||||
private:
|
||||
float attack = 0.05f, decay = 0.25f, sustain = 0.7f, release = 0.3f, curve = 0.5f;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,79 @@
|
||||
#include "FilterDisplay.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int type)
|
||||
{
|
||||
const float w = (cutoffHz > 1.0f) ? freqHz / cutoffHz : 1.0f;
|
||||
const float q = 0.6f + res * 14.0f;
|
||||
const float w2 = w * w;
|
||||
const float denom = std::sqrt ((1.0f - w2) * (1.0f - w2) + (w / q) * (w / q));
|
||||
|
||||
switch ((FilterModel) type)
|
||||
{
|
||||
case FilterModel::LadderHP: return (denom > 1e-6f) ? w2 / denom : 0.0f;
|
||||
case FilterModel::LadderBP: return (denom > 1e-6f) ? (w / q) / denom : 0.0f;
|
||||
case FilterModel::Screamer: return (denom > 1e-6f) ? (w / (q * 0.7f)) / denom : 0.0f;
|
||||
case FilterModel::Comb:
|
||||
return 0.5f + 0.5f * std::cos (2.0f * juce::MathConstants<float>::pi * freqHz / cutoffHz);
|
||||
case FilterModel::Formant:
|
||||
{
|
||||
float m = 0.0f;
|
||||
const float fc[3] = { cutoffHz * 0.5f, cutoffHz * 1.6f, cutoffHz * 3.2f };
|
||||
for (int i = 0; i < 3; ++i)
|
||||
{
|
||||
const float ww = freqHz / fc[i];
|
||||
const float dd = std::sqrt ((1.0f - ww * ww) * (1.0f - ww * ww) + (ww / q) * (ww / q));
|
||||
m += (ww / q) / (dd + 1e-6f);
|
||||
}
|
||||
return m / 3.0f;
|
||||
}
|
||||
case FilterModel::Diode:
|
||||
default:
|
||||
return (denom > 1e-6f) ? 1.0f / denom : 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
void FilterDisplay::setParams (int t, float c, float r, float d, int s)
|
||||
{
|
||||
type = t; cutoff = c; res = r; drive = d; slope = s;
|
||||
}
|
||||
|
||||
void FilterDisplay::paint (juce::Graphics& g)
|
||||
{
|
||||
const auto b = getLocalBounds().toFloat();
|
||||
g.setColour (juce::Colours::black.withAlpha (0.4f));
|
||||
g.fillRoundedRectangle (b, 4.0f);
|
||||
g.setColour (theme::outline);
|
||||
g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f);
|
||||
|
||||
if (! enabled)
|
||||
return;
|
||||
|
||||
const float cutoffHz = maps::cutoffToHz (cutoff);
|
||||
const int n = 300;
|
||||
juce::Path path;
|
||||
path.startNewSubPath (b.getX() + 4.0f, b.getBottom() - 4.0f);
|
||||
|
||||
for (int i = 0; i <= n; ++i)
|
||||
{
|
||||
const float logF = std::log10 (20.0f) + ((float) i / (float) n) * (std::log10 (20000.0f) - std::log10 (20.0f));
|
||||
const float freq = std::pow (10.0f, logF);
|
||||
const float mag = magnitude (freq, cutoffHz, res, type);
|
||||
const float db = juce::Decibels::gainToDecibels (mag + 1e-5f);
|
||||
const float x = b.getX() + 4.0f + ((float) i / (float) n) * (b.getWidth() - 8.0f);
|
||||
const float y = juce::jmap (db, -30.0f, 12.0f, b.getBottom() - 4.0f, b.getY() + 4.0f);
|
||||
path.lineTo (x, y);
|
||||
}
|
||||
|
||||
path.lineTo (b.getRight() - 4.0f, b.getBottom() - 4.0f);
|
||||
path.closeSubPath();
|
||||
|
||||
g.setColour (theme::accent.withAlpha (0.25f));
|
||||
g.fillPath (path);
|
||||
g.setColour (theme::accent);
|
||||
g.strokePath (path, juce::PathStrokeType (1.5f));
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
#include "../Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Filter frequency-response view (magnitude vs log frequency).
|
||||
// ===========================================================================
|
||||
class FilterDisplay : public juce::Component
|
||||
{
|
||||
public:
|
||||
void setParams (int type, float cutoff, float res, float drive, int slope);
|
||||
void setEnabled (bool e) { enabled = e; }
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
|
||||
static float magnitude (float freqHz, float cutoffHz, float res, int type);
|
||||
|
||||
private:
|
||||
int type = 0;
|
||||
float cutoff = 0.5f, res = 0.0f, drive = 0.0f;
|
||||
int slope = 2;
|
||||
bool enabled = true;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,86 @@
|
||||
#include "Knob.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
Knob::Knob (const juce::String& name, std::function<juce::String (float)> fmt)
|
||||
: formatter (std::move (fmt))
|
||||
{
|
||||
setSliderStyle (juce::Slider::RotaryVerticalDrag);
|
||||
setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
|
||||
setRange (0.0, 1.0);
|
||||
setDoubleClickReturnValue (true, 0.5);
|
||||
setName (name);
|
||||
setComponentID (name);
|
||||
}
|
||||
|
||||
juce::String Knob::getTextFromValue (double value)
|
||||
{
|
||||
if (formatter)
|
||||
return formatter ((float) value);
|
||||
return formatPercent ((float) value);
|
||||
}
|
||||
|
||||
void Knob::mouseDown (const juce::MouseEvent& e)
|
||||
{
|
||||
juce::Slider::mouseDown (e);
|
||||
showValuePopup (e);
|
||||
}
|
||||
|
||||
void Knob::mouseDrag (const juce::MouseEvent& e)
|
||||
{
|
||||
juce::Slider::mouseDrag (e);
|
||||
showValuePopup (e);
|
||||
}
|
||||
|
||||
void Knob::mouseUp (const juce::MouseEvent& e)
|
||||
{
|
||||
juce::Slider::mouseUp (e);
|
||||
hideValuePopup();
|
||||
}
|
||||
|
||||
void Knob::mouseExit (const juce::MouseEvent& e)
|
||||
{
|
||||
juce::Slider::mouseExit (e);
|
||||
hideValuePopup();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The floating readout lives as a child of the top-level component so it can
|
||||
// follow the cursor outside this knob's own bounds without being clipped, and
|
||||
// it never intercepts mouse clicks so it can't block interaction.
|
||||
// ---------------------------------------------------------------------------
|
||||
void Knob::showValuePopup (const juce::MouseEvent& e)
|
||||
{
|
||||
auto* topLevel = getTopLevelComponent();
|
||||
if (topLevel == nullptr)
|
||||
return;
|
||||
|
||||
if (valuePopup == nullptr)
|
||||
{
|
||||
valuePopup = std::make_unique<juce::Label>();
|
||||
valuePopup->setAlwaysOnTop (true);
|
||||
valuePopup->setInterceptsMouseClicks (false, false);
|
||||
valuePopup->setColour (juce::Label::backgroundColourId, juce::Colours::transparentBlack);
|
||||
valuePopup->setColour (juce::Label::textColourId, theme::text);
|
||||
valuePopup->setFont (juce::Font (14.0f, juce::Font::bold));
|
||||
valuePopup->setJustificationType (juce::Justification::centred);
|
||||
topLevel->addAndMakeVisible (valuePopup.get());
|
||||
}
|
||||
|
||||
valuePopup->setText (getTextFromValue (getValue()), juce::dontSendNotification);
|
||||
|
||||
constexpr int w = 84;
|
||||
constexpr int h = 24;
|
||||
constexpr int gap = 10;
|
||||
|
||||
const auto cursor = topLevel->getLocalPoint (nullptr, e.getScreenPosition());
|
||||
valuePopup->setBounds (cursor.getX() - w / 2, cursor.getY() - h - gap, w, h);
|
||||
}
|
||||
|
||||
void Knob::hideValuePopup()
|
||||
{
|
||||
valuePopup.reset();
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include <memory>
|
||||
#include "../Resources.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Rotary knob — a juce::Slider styled by SerumLookAndFeel. The value readout is
|
||||
// formatted by a user-provided function via getTextFromValue().
|
||||
//
|
||||
// While the knob is being dragged it also shows a small floating label near the
|
||||
// mouse cursor with the live (formatted) value, giving immediate feedback
|
||||
// without changing the slider's normal behaviour.
|
||||
// ===========================================================================
|
||||
class Knob : public juce::Slider
|
||||
{
|
||||
public:
|
||||
explicit Knob (const juce::String& name,
|
||||
std::function<juce::String (float)> formatter = {});
|
||||
|
||||
void setFormatter (std::function<juce::String (float)> f) { formatter = std::move (f); }
|
||||
|
||||
juce::String getTextFromValue (double value) override;
|
||||
|
||||
void mouseDown (const juce::MouseEvent& e) override;
|
||||
void mouseDrag (const juce::MouseEvent& e) override;
|
||||
void mouseUp (const juce::MouseEvent& e) override;
|
||||
void mouseExit (const juce::MouseEvent& e) override;
|
||||
|
||||
private:
|
||||
void showValuePopup (const juce::MouseEvent& e);
|
||||
void hideValuePopup();
|
||||
|
||||
std::function<juce::String (float)> formatter;
|
||||
std::unique_ptr<juce::Label> valuePopup;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,128 @@
|
||||
#include "LFODisplay.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void LFODisplay::setShapeData (const std::vector<float>& data, int s)
|
||||
{
|
||||
steps = juce::jlimit (2, 64, s);
|
||||
shapeData = data;
|
||||
if ((int) shapeData.size() < 64)
|
||||
shapeData.resize (64, 0.0f);
|
||||
repaint();
|
||||
}
|
||||
|
||||
float LFODisplay::valueAt (float phase) const noexcept
|
||||
{
|
||||
switch ((LfoShape) shape)
|
||||
{
|
||||
case LfoShape::Sine: return std::sin (phase * 6.2831853f);
|
||||
case LfoShape::Triangle: return 1.0f - 4.0f * std::abs (phase - 0.5f);
|
||||
case LfoShape::Saw: return 2.0f * phase - 1.0f;
|
||||
case LfoShape::Square: return (phase < 0.5f) ? 1.0f : -1.0f;
|
||||
case LfoShape::SampleHold:
|
||||
{
|
||||
const int step = juce::jlimit (0, 15, (int) (phase * 16.0f));
|
||||
return ((step * 2654435761u) & 0xffffu) / 32768.0f - 1.0f;
|
||||
}
|
||||
case LfoShape::StepSeq:
|
||||
{
|
||||
const int idx = juce::jlimit (0, steps - 1, (int) (phase * steps));
|
||||
return shapeData.empty() ? 0.0f : shapeData[(size_t) idx];
|
||||
}
|
||||
case LfoShape::Freehand:
|
||||
{
|
||||
if (shapeData.empty()) return 0.0f;
|
||||
const float pos = phase * (float) (steps - 1);
|
||||
const int i0 = (int) pos;
|
||||
const int i1 = juce::jmin (i0 + 1, steps - 1);
|
||||
const float frac = pos - (float) i0;
|
||||
return shapeData[(size_t) i0] * (1.0f - frac) + shapeData[(size_t) i1] * frac;
|
||||
}
|
||||
default: return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
void LFODisplay::paint (juce::Graphics& g)
|
||||
{
|
||||
const auto b = getLocalBounds().toFloat();
|
||||
g.setColour (juce::Colours::black.withAlpha (0.4f));
|
||||
g.fillRoundedRectangle (b, 4.0f);
|
||||
g.setColour (theme::outline);
|
||||
g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f);
|
||||
|
||||
const float midY = b.getCentreY();
|
||||
g.setColour (theme::outline);
|
||||
g.drawLine (b.getX() + 4.0f, midY, b.getRight() - 4.0f, midY, 1.0f);
|
||||
|
||||
// Editable shapes show step markers.
|
||||
if (shape == (int) LfoShape::StepSeq)
|
||||
{
|
||||
const float stepW = (b.getWidth() - 8.0f) / (float) steps;
|
||||
for (int i = 0; i < steps; ++i)
|
||||
{
|
||||
const float v = shapeData.empty() ? 0.0f : shapeData[(size_t) i];
|
||||
const float x = b.getX() + 4.0f + stepW * i;
|
||||
const float y = midY - v * (b.getHeight() * 0.42f);
|
||||
g.setColour (theme::amber);
|
||||
g.fillRect (x + 1.0f, juce::jmin (y, midY), stepW - 2.0f, std::abs (midY - y));
|
||||
}
|
||||
}
|
||||
|
||||
juce::Path path;
|
||||
path.startNewSubPath (b.getX() + 4.0f, midY);
|
||||
const int n = 256;
|
||||
for (int i = 0; i <= n; ++i)
|
||||
{
|
||||
const float phase = (float) i / (float) n;
|
||||
const float v = valueAt (phase);
|
||||
const float x = b.getX() + 4.0f + phase * (b.getWidth() - 8.0f);
|
||||
const float y = midY - v * (b.getHeight() * 0.42f);
|
||||
if (i == 0) path.startNewSubPath (x, y);
|
||||
else path.lineTo (x, y);
|
||||
}
|
||||
g.setColour (theme::green);
|
||||
g.strokePath (path, juce::PathStrokeType (1.5f));
|
||||
}
|
||||
|
||||
void LFODisplay::editAt (float x, float y)
|
||||
{
|
||||
const auto b = getLocalBounds().toFloat();
|
||||
const float midY = b.getCentreY();
|
||||
const float normX = juce::jlimit (0.0f, 1.0f, (x - b.getX() - 4.0f) / (b.getWidth() - 8.0f));
|
||||
const float v = juce::jlimit (-1.0f, 1.0f, (midY - y) / (b.getHeight() * 0.42f));
|
||||
|
||||
if (shapeData.empty())
|
||||
shapeData.assign (64, 0.0f);
|
||||
|
||||
if (shape == (int) LfoShape::StepSeq)
|
||||
{
|
||||
const int idx = juce::jlimit (0, steps - 1, (int) (normX * steps));
|
||||
shapeData[(size_t) idx] = v;
|
||||
}
|
||||
else if (shape == (int) LfoShape::Freehand)
|
||||
{
|
||||
const int idx = juce::jlimit (0, steps - 1, (int) (normX * steps));
|
||||
shapeData[(size_t) idx] = v;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (onEdited)
|
||||
onEdited (shapeData, steps);
|
||||
repaint();
|
||||
}
|
||||
|
||||
void LFODisplay::mouseDown (const juce::MouseEvent& e)
|
||||
{
|
||||
editAt (e.x, e.y);
|
||||
}
|
||||
|
||||
void LFODisplay::mouseDrag (const juce::MouseEvent& e)
|
||||
{
|
||||
editAt (e.x, e.y);
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
#include "../Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// LFO shape view. Step-sequencer and freehand shapes are click/drag editable.
|
||||
// ===========================================================================
|
||||
class LFODisplay : public juce::Component
|
||||
{
|
||||
public:
|
||||
void setShape (int s) { shape = s; repaint(); }
|
||||
void setShapeData (const std::vector<float>& data, int steps);
|
||||
void setOnShapeEdited (std::function<void (const std::vector<float>&, int)> cb) { onEdited = std::move (cb); }
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
void mouseDown (const juce::MouseEvent& e) override;
|
||||
void mouseDrag (const juce::MouseEvent& e) override;
|
||||
|
||||
private:
|
||||
int shape = 0;
|
||||
std::vector<float> shapeData;
|
||||
int steps = 16;
|
||||
std::function<void (const std::vector<float>&, int)> onEdited;
|
||||
|
||||
float valueAt (float phase) const noexcept;
|
||||
void editAt (float x, float y);
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "Panel.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
Panel::Panel (const juce::String& t) : title (t)
|
||||
{
|
||||
}
|
||||
|
||||
void Panel::paint (juce::Graphics& g)
|
||||
{
|
||||
const auto b = getLocalBounds().toFloat();
|
||||
g.setColour (theme::panel);
|
||||
g.fillRoundedRectangle (b, 8.0f);
|
||||
g.setColour (theme::outline);
|
||||
g.drawRoundedRectangle (b.reduced (0.5f), 8.0f, 1.0f);
|
||||
|
||||
if (title.isNotEmpty())
|
||||
{
|
||||
g.setColour (theme::textDim);
|
||||
g.setFont (juce::Font (11.0f, juce::Font::bold));
|
||||
g.drawText (title, 10, 6, getWidth() - 20, 16, juce::Justification::left, false);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Rounded dark panel with an optional title bar.
|
||||
// ===========================================================================
|
||||
class Panel : public juce::Component
|
||||
{
|
||||
public:
|
||||
explicit Panel (const juce::String& title = {});
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
|
||||
private:
|
||||
juce::String title;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Dark vector look-and-feel. Rotary and linear sliders are drawn with juce::Path
|
||||
// (arcs + indicator + thumb) so they stay crisp at every UI scale.
|
||||
// ===========================================================================
|
||||
class SerumLookAndFeel : public juce::LookAndFeel_V4
|
||||
{
|
||||
public:
|
||||
SerumLookAndFeel()
|
||||
{
|
||||
setColour (juce::Slider::backgroundColourId, theme::panel);
|
||||
setColour (juce::Slider::thumbColourId, theme::accent);
|
||||
setColour (juce::Slider::trackColourId, theme::outline);
|
||||
setColour (juce::Slider::rotarySliderFillColourId, theme::accent);
|
||||
setColour (juce::Slider::rotarySliderOutlineColourId, theme::panelRaised);
|
||||
setColour (juce::Slider::textBoxTextColourId, theme::text);
|
||||
setColour (juce::Slider::textBoxBackgroundColourId, theme::panel);
|
||||
setColour (juce::Slider::textBoxHighlightColourId, theme::accent);
|
||||
setColour (juce::Slider::textBoxOutlineColourId, theme::outline);
|
||||
|
||||
setColour (juce::ComboBox::backgroundColourId, theme::panelRaised);
|
||||
setColour (juce::ComboBox::textColourId, theme::text);
|
||||
setColour (juce::ComboBox::arrowColourId, theme::textDim);
|
||||
setColour (juce::ComboBox::outlineColourId, theme::outline);
|
||||
setColour (juce::ComboBox::buttonColourId, theme::panelRaised);
|
||||
setColour (juce::ComboBox::focusedOutlineColourId, theme::accent);
|
||||
setColour (juce::PopupMenu::backgroundColourId, theme::panelRaised);
|
||||
setColour (juce::PopupMenu::textColourId, theme::text);
|
||||
setColour (juce::PopupMenu::highlightedBackgroundColourId, theme::accent);
|
||||
setColour (juce::PopupMenu::highlightedTextColourId, juce::Colours::black);
|
||||
|
||||
setColour (juce::TextButton::buttonColourId, theme::panelRaised);
|
||||
setColour (juce::TextButton::buttonOnColourId, theme::accent);
|
||||
setColour (juce::TextButton::textColourOffId, theme::textDim);
|
||||
setColour (juce::TextButton::textColourOnId, juce::Colours::black);
|
||||
|
||||
setColour (juce::TextEditor::backgroundColourId, theme::panel);
|
||||
setColour (juce::TextEditor::textColourId, theme::text);
|
||||
setColour (juce::TextEditor::outlineColourId, theme::outline);
|
||||
setColour (juce::TextEditor::focusedOutlineColourId, theme::accent);
|
||||
}
|
||||
|
||||
void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||
float sliderPos, float, float, juce::Slider& slider) override
|
||||
{
|
||||
const int labelH = 18;
|
||||
const int knobH = height - labelH;
|
||||
const int knobSize = juce::jmin (width, knobH) - 6;
|
||||
const int cx = x + width / 2;
|
||||
const int cy = y + knobH / 2;
|
||||
const float radius = knobSize * 0.5f;
|
||||
|
||||
const juce::Rectangle<float> area ((float) cx - radius, (float) cy - radius,
|
||||
radius * 2.0f, radius * 2.0f);
|
||||
|
||||
// Body.
|
||||
g.setColour (theme::panelRaised);
|
||||
g.fillEllipse (area);
|
||||
g.setColour (theme::outline);
|
||||
g.drawEllipse (area, 1.0f);
|
||||
|
||||
const float startAngle = juce::MathConstants<float>::pi * 1.25f; // 225 deg
|
||||
const float endAngle = juce::MathConstants<float>::pi * -0.25f;
|
||||
const float valueAngle = startAngle + sliderPos * (endAngle - startAngle);
|
||||
|
||||
// Track arc.
|
||||
juce::Path track;
|
||||
track.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f,
|
||||
0.0f, startAngle, endAngle, true);
|
||||
g.setColour (theme::outline);
|
||||
g.strokePath (track, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved));
|
||||
|
||||
// Value arc.
|
||||
juce::Path valueArc;
|
||||
valueArc.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f,
|
||||
0.0f, startAngle, valueAngle, true);
|
||||
g.setColour (theme::accent);
|
||||
g.strokePath (valueArc, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved));
|
||||
|
||||
// Indicator.
|
||||
const float indX = (float) cx + (radius - 9.0f) * std::cos (valueAngle);
|
||||
const float indY = (float) cy + (radius - 9.0f) * std::sin (valueAngle);
|
||||
g.setColour (theme::text);
|
||||
g.drawLine ((float) cx, (float) cy, indX, indY, 2.0f);
|
||||
g.setColour (theme::text);
|
||||
g.fillEllipse (indX - 2.0f, indY - 2.0f, 4.0f, 4.0f);
|
||||
|
||||
// Value readout.
|
||||
g.setColour (theme::text);
|
||||
g.setFont (juce::Font (10.0f, juce::Font::bold));
|
||||
g.drawText (slider.getTextFromValue (slider.getValue()),
|
||||
juce::Rectangle<int> (x, y, width, knobH - (int) radius + 8),
|
||||
juce::Justification::centred, false);
|
||||
|
||||
// Name label.
|
||||
g.setColour (theme::textDim);
|
||||
g.setFont (juce::Font (11.0f));
|
||||
g.drawText (slider.getName(), juce::Rectangle<int> (x, y + knobH, width, labelH),
|
||||
juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
void drawLinearSlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||
float sliderPos, float, float, juce::Slider::SliderStyle, juce::Slider& slider) override
|
||||
{
|
||||
const bool vertical = height > width;
|
||||
juce::Rectangle<int> track;
|
||||
|
||||
if (vertical)
|
||||
{
|
||||
track = juce::Rectangle<int> (x + width / 2 - 3, y + 8, 6, height - 16);
|
||||
}
|
||||
else
|
||||
{
|
||||
track = juce::Rectangle<int> (x + 8, y + height / 2 - 3, width - 16, 6);
|
||||
}
|
||||
|
||||
g.setColour (theme::outline);
|
||||
g.fillRoundedRectangle (track.toFloat(), 3.0f);
|
||||
|
||||
juce::Rectangle<float> fill;
|
||||
if (vertical)
|
||||
{
|
||||
const float h = track.getHeight() * sliderPos;
|
||||
fill = juce::Rectangle<float> ((float) track.getX(), (float) track.getBottom() - h,
|
||||
(float) track.getWidth(), h);
|
||||
}
|
||||
else
|
||||
{
|
||||
fill = juce::Rectangle<float> ((float) track.getX(), (float) track.getY(),
|
||||
track.getWidth() * sliderPos, (float) track.getHeight());
|
||||
}
|
||||
g.setColour (theme::accent);
|
||||
g.fillRoundedRectangle (fill, 3.0f);
|
||||
|
||||
// Thumb.
|
||||
const float thumbC = vertical ? (track.getBottom() - track.getHeight() * sliderPos)
|
||||
: (track.getX() + track.getWidth() * sliderPos);
|
||||
juce::Point<float> thumb (vertical ? (float) track.getCentreX() : thumbC,
|
||||
vertical ? thumbC : (float) track.getCentreY());
|
||||
g.setColour (theme::text);
|
||||
g.fillEllipse (thumb.x - 6.0f, thumb.y - 6.0f, 12.0f, 12.0f);
|
||||
|
||||
g.setColour (theme::textDim);
|
||||
g.setFont (juce::Font (10.0f));
|
||||
g.drawText (slider.getName(), x, y, width, height, juce::Justification::bottomLeft, false);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,13 @@
|
||||
#include "Slider.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
Slider::Slider (const juce::String& name, std::function<juce::String (float)> formatter)
|
||||
: Knob (name, std::move (formatter))
|
||||
{
|
||||
setSliderStyle (juce::Slider::LinearHorizontal);
|
||||
setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "Knob.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Horizontal slider (same formatting machinery as Knob).
|
||||
// ===========================================================================
|
||||
class Slider : public Knob
|
||||
{
|
||||
public:
|
||||
explicit Slider (const juce::String& name,
|
||||
std::function<juce::String (float)> formatter = {});
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "ToggleButton.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
ToggleButton::ToggleButton (const juce::String& lbl) : label (lbl)
|
||||
{
|
||||
setRepaintsOnMouseActivity (true);
|
||||
}
|
||||
|
||||
void ToggleButton::attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId)
|
||||
{
|
||||
param = apvts.getParameter (paramId);
|
||||
if (param != nullptr)
|
||||
{
|
||||
state = param->getValue() > 0.5f;
|
||||
attachment = std::make_unique<juce::ParameterAttachment> (*param,
|
||||
[this] (float newValue) { setToggleState (newValue > 0.5f); });
|
||||
}
|
||||
}
|
||||
|
||||
void ToggleButton::setToggleState (bool s)
|
||||
{
|
||||
if (state == s)
|
||||
return;
|
||||
state = s;
|
||||
repaint();
|
||||
}
|
||||
|
||||
void ToggleButton::paint (juce::Graphics& g)
|
||||
{
|
||||
const auto bounds = getLocalBounds().toFloat();
|
||||
const float ledSize = juce::jmin (bounds.getHeight() - 16.0f, 16.0f);
|
||||
const juce::Rectangle<float> led ((bounds.getWidth() - ledSize) * 0.5f, 4.0f, ledSize, ledSize);
|
||||
|
||||
if (state)
|
||||
{
|
||||
g.setColour (onColour.withAlpha (0.35f));
|
||||
g.fillEllipse (led.expanded (6.0f));
|
||||
g.setColour (onColour);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.setColour (theme::outline);
|
||||
}
|
||||
g.fillEllipse (led);
|
||||
g.setColour (state ? juce::Colours::black : theme::textDim);
|
||||
g.drawEllipse (led, 1.0f);
|
||||
|
||||
g.setColour (state ? theme::text : theme::textDim);
|
||||
g.setFont (juce::Font (11.0f));
|
||||
g.drawText (label, 0, (int) (led.getBottom() + 2), getWidth(), 14, juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
void ToggleButton::mouseDown (const juce::MouseEvent&)
|
||||
{
|
||||
const bool newState = ! state;
|
||||
|
||||
if (onClick)
|
||||
{
|
||||
onClick (newState);
|
||||
}
|
||||
else if (param != nullptr)
|
||||
{
|
||||
param->setValueNotifyingHost (newState ? 1.0f : 0.0f);
|
||||
}
|
||||
|
||||
setToggleState (newState);
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// LED-style toggle (power) button that drives a float parameter (0/1) or an
|
||||
// optional callback (used by RAVE).
|
||||
//
|
||||
// Inherits SettableTooltipClient so setTooltip() works exactly like it does on
|
||||
// juce::Button / juce::Slider / juce::ComboBox (juce::Component itself has no
|
||||
// tooltip support).
|
||||
// ===========================================================================
|
||||
class ToggleButton : public juce::Component,
|
||||
public juce::SettableTooltipClient
|
||||
{
|
||||
public:
|
||||
explicit ToggleButton (const juce::String& label = {});
|
||||
|
||||
void attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId);
|
||||
void setOnClick (std::function<void (bool)> cb) { onClick = std::move (cb); }
|
||||
|
||||
void setOnColour (juce::Colour c) { onColour = c; repaint(); }
|
||||
void setLabel (const juce::String& lbl) { label = lbl; repaint(); }
|
||||
|
||||
void setToggleState (bool s);
|
||||
bool getToggleState() const noexcept { return state; }
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
void mouseDown (const juce::MouseEvent&) override;
|
||||
|
||||
private:
|
||||
bool state = false;
|
||||
juce::String label;
|
||||
juce::RangedAudioParameter* param = nullptr;
|
||||
std::unique_ptr<juce::ParameterAttachment> attachment;
|
||||
std::function<void (bool)> onClick;
|
||||
juce::Colour onColour = theme::accent;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "WaveformDisplay.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void WaveformDisplay::paint (juce::Graphics& g)
|
||||
{
|
||||
const auto b = getLocalBounds().toFloat();
|
||||
g.setColour (juce::Colours::black.withAlpha (0.4f));
|
||||
g.fillRoundedRectangle (b, 4.0f);
|
||||
g.setColour (theme::outline);
|
||||
g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f);
|
||||
|
||||
const float midY = b.getCentreY();
|
||||
g.setColour (theme::outline);
|
||||
g.drawLine (b.getX() + 4.0f, midY, b.getRight() - 4.0f, midY, 1.0f);
|
||||
|
||||
if (wtLib == nullptr || ! enabled)
|
||||
return;
|
||||
|
||||
const Wavetable& wt = wtLib->getTable (wave);
|
||||
if (! wt.isValid())
|
||||
return;
|
||||
|
||||
const int n = 512;
|
||||
juce::Path path;
|
||||
path.startNewSubPath (b.getX() + 4.0f, midY);
|
||||
|
||||
for (int i = 0; i <= n; ++i)
|
||||
{
|
||||
const float phase = (float) i / (float) n;
|
||||
const float sample = wt.readSafe (wtPos * 255.0f, phase);
|
||||
const float x = b.getX() + 4.0f + ((float) i / (float) n) * (b.getWidth() - 8.0f);
|
||||
const float y = midY - sample * (b.getHeight() * 0.42f);
|
||||
if (i == 0)
|
||||
path.startNewSubPath (x, y);
|
||||
else
|
||||
path.lineTo (x, y);
|
||||
}
|
||||
|
||||
g.setColour (theme::accent);
|
||||
g.strokePath (path, juce::PathStrokeType (1.5f));
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
#include "../Wavetable.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Single-cycle wavetable view (morphs with the WT position knob).
|
||||
// ===========================================================================
|
||||
class WaveformDisplay : public juce::Component
|
||||
{
|
||||
public:
|
||||
void setWavetables (const WavetableLibrary* lib) { wtLib = lib; }
|
||||
void setWaveIndex (int index) { wave = index; }
|
||||
void setFramePosition (float pos) { wtPos = pos; }
|
||||
void setEnabled (bool e) { enabled = e; }
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
|
||||
private:
|
||||
const WavetableLibrary* wtLib = nullptr;
|
||||
int wave = 0;
|
||||
float wtPos = 0.0f;
|
||||
bool enabled = true;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
#include "LFO.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void LFO::reset()
|
||||
{
|
||||
phase = 0.0;
|
||||
value = 0.0f;
|
||||
delayCounter = 0.0;
|
||||
fadeCounter = 0.0;
|
||||
fadeVal = 1.0f;
|
||||
holdValue = 0.0f;
|
||||
prevPhase = 0.0;
|
||||
prevDelayParam = -1.0f;
|
||||
prevFadeParam = -1.0f;
|
||||
shapeBuffer.assign ((size_t) kShapePoints, 0.0f);
|
||||
// default step sequence
|
||||
for (int i = 0; i < kShapePoints; ++i)
|
||||
shapeBuffer[(size_t) i] = (i % 2 == 0) ? 1.0f : -1.0f;
|
||||
}
|
||||
|
||||
void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
|
||||
float fade, float delay)
|
||||
{
|
||||
sync = s;
|
||||
beat = b;
|
||||
shape = juce::jlimit (0, (int) LfoShape::Count - 1, shp);
|
||||
phase = juce::jlimit (0.0f, 1.0f, ph);
|
||||
|
||||
if (sync)
|
||||
rateHz = maps::beatToMultiplier (beat) * (tempo / 60.0);
|
||||
else
|
||||
rateHz = maps::rateToHz (rateNorm);
|
||||
|
||||
delaySeconds = juce::jlimit (0.0f, 1.0f, delay) * 4.0;
|
||||
fadeSeconds = juce::jlimit (0.0f, 1.0f, fade) * 8.0;
|
||||
|
||||
// Only restart the delay/fade timing when those knobs actually change,
|
||||
// so repeated block-rate setParams calls don't keep resetting the LFO.
|
||||
if (delay != prevDelayParam || fade != prevFadeParam)
|
||||
{
|
||||
delayCounter = delaySeconds * sr;
|
||||
fadeCounter = 0.0;
|
||||
fadeVal = (fadeSeconds <= 0.0) ? 1.0f : 0.0f;
|
||||
prevDelayParam = delay;
|
||||
prevFadeParam = fade;
|
||||
}
|
||||
}
|
||||
|
||||
void LFO::setShapeData (const std::vector<float>& data, int steps)
|
||||
{
|
||||
if (data.empty())
|
||||
return;
|
||||
|
||||
shapeSteps = juce::jlimit (2, kShapePoints, steps);
|
||||
shapeBuffer.assign (data.begin(), data.end());
|
||||
shapeBuffer.resize ((size_t) kShapePoints, 0.0f);
|
||||
}
|
||||
|
||||
float LFO::shapeValue() noexcept
|
||||
{
|
||||
const float p = (float) phase;
|
||||
switch ((LfoShape) shape)
|
||||
{
|
||||
case LfoShape::Sine:
|
||||
return std::sin (p * 6.28318530717958647692f);
|
||||
case LfoShape::Triangle:
|
||||
return 1.0f - 4.0f * std::abs (p - 0.5f);
|
||||
case LfoShape::Saw:
|
||||
return 2.0f * p - 1.0f;
|
||||
case LfoShape::Square:
|
||||
return (p < 0.5f) ? 1.0f : -1.0f;
|
||||
case LfoShape::SampleHold:
|
||||
{
|
||||
if (phase < prevPhase)
|
||||
holdValue = rng.nextFloat() * 2.0f - 1.0f;
|
||||
return holdValue;
|
||||
}
|
||||
case LfoShape::StepSeq:
|
||||
{
|
||||
const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps));
|
||||
return shapeBuffer[(size_t) idx];
|
||||
}
|
||||
case LfoShape::Freehand:
|
||||
{
|
||||
const float pos = p * (float) (shapeSteps - 1);
|
||||
const int i0 = (int) pos;
|
||||
const int i1 = juce::jmin (i0 + 1, shapeSteps - 1);
|
||||
const float frac = pos - (float) i0;
|
||||
return shapeBuffer[(size_t) i0] * (1.0f - frac) + shapeBuffer[(size_t) i1] * frac;
|
||||
}
|
||||
default:
|
||||
return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
float LFO::process() noexcept
|
||||
{
|
||||
// Start delay.
|
||||
if (delayCounter > 0.0)
|
||||
{
|
||||
delayCounter -= 1.0;
|
||||
value = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Fade-in ramp.
|
||||
if (fadeVal < 1.0f)
|
||||
{
|
||||
fadeCounter += 1.0;
|
||||
if (fadeSeconds > 0.0)
|
||||
fadeVal = (float) juce::jlimit (0.0, 1.0, fadeCounter / (fadeSeconds * sr));
|
||||
else
|
||||
fadeVal = 1.0f;
|
||||
}
|
||||
|
||||
prevPhase = phase;
|
||||
phase += rateHz / sr;
|
||||
phase -= std::floor (phase);
|
||||
|
||||
value = shapeValue() * fadeVal;
|
||||
return value;
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Free-running LFO with tempo sync, seven shapes (sine/tri/saw/square/S&H/
|
||||
// step-sequencer/freehand), adjustable phase, fade-in and start delay.
|
||||
// ===========================================================================
|
||||
class LFO
|
||||
{
|
||||
public:
|
||||
static constexpr int kShapePoints = 64;
|
||||
|
||||
void prepare (double sampleRate) { sr = sampleRate; reset(); }
|
||||
void reset();
|
||||
|
||||
void setParams (float rateNorm, bool sync, float beat, int shape, float phase,
|
||||
float fade, float delay);
|
||||
void setTempo (double bpm) noexcept { tempo = bpm; }
|
||||
void setShapeData (const std::vector<float>& data, int steps);
|
||||
|
||||
float process() noexcept; // advance and return current value
|
||||
float getPhase() const noexcept { return (float) phase; }
|
||||
float getValue() const noexcept { return value; }
|
||||
|
||||
const std::vector<float>& getShapeData() const noexcept { return shapeBuffer; }
|
||||
int getShapeSteps() const noexcept { return shapeSteps; }
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
double tempo = 120.0;
|
||||
|
||||
double phase = 0.0;
|
||||
double rateHz = 1.0;
|
||||
float value = 0.0f;
|
||||
|
||||
int shape = 0;
|
||||
bool sync = false;
|
||||
float beat = 0.25f;
|
||||
|
||||
double delaySeconds = 0.0;
|
||||
double fadeSeconds = 0.0;
|
||||
double delayCounter = 0.0; // samples remaining before start
|
||||
double fadeCounter = 0.0; // samples elapsed in fade
|
||||
float fadeVal = 1.0f;
|
||||
float prevDelayParam = -1.0f;
|
||||
float prevFadeParam = -1.0f;
|
||||
|
||||
std::vector<float> shapeBuffer;
|
||||
int shapeSteps = 16;
|
||||
|
||||
juce::Random rng;
|
||||
float holdValue = 0.0f;
|
||||
double prevPhase = 0.0;
|
||||
|
||||
float shapeValue() noexcept;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "MacroControls.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
bool MacroControls::addAssignment (int macro, ModTarget target, float depth)
|
||||
{
|
||||
macro = juce::jlimit (0, kNumMacros - 1, macro);
|
||||
if (target == ModTarget::None || (int) assignments[(size_t) macro].size() >= kMaxAssignments)
|
||||
return false;
|
||||
assignments[(size_t) macro].push_back ({ target, depth });
|
||||
return true;
|
||||
}
|
||||
|
||||
void MacroControls::removeAssignment (int macro, int index)
|
||||
{
|
||||
macro = juce::jlimit (0, kNumMacros - 1, macro);
|
||||
auto& vec = assignments[(size_t) macro];
|
||||
if (index >= 0 && index < (int) vec.size())
|
||||
vec.erase (vec.begin() + index);
|
||||
}
|
||||
|
||||
void MacroControls::clear()
|
||||
{
|
||||
for (auto& a : assignments)
|
||||
a.clear();
|
||||
}
|
||||
|
||||
juce::String MacroControls::macroName (int index)
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 0: return "Energy";
|
||||
case 1: return "Width";
|
||||
case 2: return "Drive";
|
||||
case 3: return "Atmosphere";
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
juce::ValueTree MacroControls::toValueTree() const
|
||||
{
|
||||
juce::ValueTree tree ("MACROS");
|
||||
for (int m = 0; m < kNumMacros; ++m)
|
||||
{
|
||||
juce::ValueTree mac ("MACRO");
|
||||
mac.setProperty ("index", m, nullptr);
|
||||
for (const auto& a : assignments[(size_t) m])
|
||||
{
|
||||
juce::ValueTree asg ("ASSIGN");
|
||||
asg.setProperty ("target", modTargetToString (a.target), nullptr);
|
||||
asg.setProperty ("depth", a.depth, nullptr);
|
||||
mac.appendChild (asg, nullptr);
|
||||
}
|
||||
tree.appendChild (mac, nullptr);
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
void MacroControls::fromValueTree (const juce::ValueTree& tree)
|
||||
{
|
||||
clear();
|
||||
if (! tree.isValid())
|
||||
return;
|
||||
|
||||
for (const auto& mac : tree)
|
||||
{
|
||||
if (! mac.hasType ("MACRO"))
|
||||
continue;
|
||||
const int index = juce::jlimit (0, kNumMacros - 1, (int) mac.getProperty ("index", 0));
|
||||
for (const auto& asg : mac)
|
||||
{
|
||||
if (! asg.hasType ("ASSIGN"))
|
||||
continue;
|
||||
MacroAssignment a;
|
||||
a.target = modTargetFromString (asg.getProperty ("target").toString());
|
||||
a.depth = (float) asg.getProperty ("depth", 0.0);
|
||||
if (a.target != ModTarget::None)
|
||||
assignments[(size_t) index].push_back (a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// One assignable destination for a macro knob.
|
||||
// ===========================================================================
|
||||
struct MacroAssignment
|
||||
{
|
||||
ModTarget target = ModTarget::None;
|
||||
float depth = 0.0f;
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Four macros (Energy, Width, Drive, Atmosphere). Macro *values* are APVTS
|
||||
// parameters; this class stores the assignable destinations and serialises
|
||||
// them with the preset.
|
||||
// ===========================================================================
|
||||
class MacroControls
|
||||
{
|
||||
public:
|
||||
static constexpr int kMaxAssignments = 8;
|
||||
|
||||
std::array<std::vector<MacroAssignment>, kNumMacros> assignments;
|
||||
|
||||
bool addAssignment (int macro, ModTarget target, float depth);
|
||||
void removeAssignment (int macro, int index);
|
||||
void clear();
|
||||
|
||||
juce::ValueTree toValueTree() const;
|
||||
void fromValueTree (const juce::ValueTree& tree);
|
||||
|
||||
static juce::String macroName (int index);
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "ModulationMatrix.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
bool ModulationMatrix::addConnection (ModSource source, ModTarget target, float depth, bool bipolar)
|
||||
{
|
||||
if (target == ModTarget::None || (int) connections.size() >= kMaxConnections)
|
||||
return false;
|
||||
connections.push_back ({ source, target, depth, bipolar });
|
||||
return true;
|
||||
}
|
||||
|
||||
void ModulationMatrix::removeConnection (int index)
|
||||
{
|
||||
if (index >= 0 && index < (int) connections.size())
|
||||
connections.erase (connections.begin() + index);
|
||||
}
|
||||
|
||||
void ModulationMatrix::removeAllWithTarget (ModTarget target)
|
||||
{
|
||||
connections.erase (std::remove_if (connections.begin(), connections.end(),
|
||||
[target] (const ModConnection& c) { return c.target == target; }),
|
||||
connections.end());
|
||||
}
|
||||
|
||||
juce::ValueTree ModulationMatrix::toValueTree() const
|
||||
{
|
||||
juce::ValueTree tree ("MODMATRIX");
|
||||
for (const auto& c : connections)
|
||||
{
|
||||
juce::ValueTree con ("CONNECTION");
|
||||
con.setProperty ("source", modSourceToString (c.source), nullptr);
|
||||
con.setProperty ("target", modTargetToString (c.target), nullptr);
|
||||
con.setProperty ("depth", c.depth, nullptr);
|
||||
con.setProperty ("bipolar", c.bipolar, nullptr);
|
||||
tree.appendChild (con, nullptr);
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
void ModulationMatrix::fromValueTree (const juce::ValueTree& tree)
|
||||
{
|
||||
connections.clear();
|
||||
if (! tree.isValid())
|
||||
return;
|
||||
|
||||
for (const auto& con : tree)
|
||||
{
|
||||
if (! con.hasType ("CONNECTION"))
|
||||
continue;
|
||||
ModConnection c;
|
||||
c.source = modSourceFromString (con.getProperty ("source").toString());
|
||||
c.target = modTargetFromString (con.getProperty ("target").toString());
|
||||
c.depth = (float) con.getProperty ("depth", 0.0);
|
||||
c.bipolar = (bool) con.getProperty ("bipolar", false);
|
||||
if (c.target != ModTarget::None && (int) connections.size() < kMaxConnections)
|
||||
connections.push_back (c);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// A single modulation connection: source -> target with a static depth and a
|
||||
// bipolar flag. One level of modulation only (no modulation of modulation).
|
||||
// ===========================================================================
|
||||
struct ModConnection
|
||||
{
|
||||
ModSource source = ModSource::Lfo1;
|
||||
ModTarget target = ModTarget::None;
|
||||
float depth = 0.0f;
|
||||
bool bipolar = false;
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Ordered collection of modulation connections. Serialisable to/from a
|
||||
// juce::ValueTree so that connections survive preset save/load.
|
||||
// ===========================================================================
|
||||
class ModulationMatrix
|
||||
{
|
||||
public:
|
||||
static constexpr int kMaxConnections = 32;
|
||||
|
||||
std::vector<ModConnection> connections;
|
||||
|
||||
bool addConnection (ModSource source, ModTarget target, float depth, bool bipolar);
|
||||
void removeConnection (int index);
|
||||
void removeAllWithTarget (ModTarget target);
|
||||
void clear() { connections.clear(); }
|
||||
|
||||
int size() const noexcept { return (int) connections.size(); }
|
||||
|
||||
juce::ValueTree toValueTree() const;
|
||||
void fromValueTree (const juce::ValueTree& tree);
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,36 @@
|
||||
#include "NoiseOscillator.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void NoiseOscillator::reset()
|
||||
{
|
||||
pinkB0 = pinkB1 = pinkB2 = 0.0f;
|
||||
}
|
||||
|
||||
void NoiseOscillator::noteOn (juce::uint32 seed)
|
||||
{
|
||||
rng.setSeed (seed);
|
||||
}
|
||||
|
||||
void NoiseOscillator::processAdd (int type, float level, float& out) noexcept
|
||||
{
|
||||
if (level <= 0.0f)
|
||||
return;
|
||||
|
||||
const float white = rng.nextFloat() * 2.0f - 1.0f;
|
||||
|
||||
float s = white;
|
||||
if (type == (int) NoiseType::Pink)
|
||||
{
|
||||
// Paul Kellet's economy pink-noise filter.
|
||||
pinkB0 = 0.99765f * pinkB0 + white * 0.0990460f;
|
||||
pinkB1 = 0.96300f * pinkB1 + white * 0.2965164f;
|
||||
pinkB2 = 0.57000f * pinkB2 + white * 1.0526913f;
|
||||
s = pinkB0 + pinkB1 + pinkB2 + white * 0.1848f;
|
||||
}
|
||||
|
||||
out += s * level;
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Per-voice white/pink noise generator.
|
||||
// ===========================================================================
|
||||
class NoiseOscillator
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate) { sr = sampleRate; reset(); }
|
||||
void reset();
|
||||
|
||||
void noteOn (juce::uint32 seed);
|
||||
|
||||
// Accumulate into out.
|
||||
void processAdd (int type, float level, float& out) noexcept;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
juce::Random rng;
|
||||
float pinkB0 = 0.0f, pinkB1 = 0.0f, pinkB2 = 0.0f;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,133 @@
|
||||
#include "Oscillator.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void Oscillator::reset()
|
||||
{
|
||||
for (auto& v : voices)
|
||||
{
|
||||
v.phase = 0.0;
|
||||
v.detuneRatio = 1.0;
|
||||
v.pan = 0.0f;
|
||||
v.level = 1.0f;
|
||||
}
|
||||
activeUnison = 1;
|
||||
}
|
||||
|
||||
void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed)
|
||||
{
|
||||
jassert (freqHz > 0.0);
|
||||
rng.setSeed (seed);
|
||||
|
||||
const int uni = juce::jlimit (1, kMaxUnison, p.unison);
|
||||
activeUnison = uni;
|
||||
|
||||
const double basePhase = (double) p.phase * kTwoPi;
|
||||
|
||||
for (int v = 0; v < uni; ++v)
|
||||
{
|
||||
// Even phase spacing prevents cancellation across unison voices.
|
||||
double offset = (uni > 1) ? ((double) v / (double) uni) * kTwoPi : 0.0;
|
||||
double random = rng.nextFloat() * (double) p.randPhase * kTwoPi;
|
||||
voices[(size_t) v].phase = basePhase + offset + random;
|
||||
|
||||
// Detune: linear spread in cents, 0..50 cents at full depth.
|
||||
double detuneCents = 0.0;
|
||||
if (uni > 1)
|
||||
detuneCents = (double) p.detune * 50.0 * ((double) (v - (uni - 1) / 2.0) / (double) ((uni - 1) / 2.0));
|
||||
voices[(size_t) v].detuneRatio = std::pow (2.0, detuneCents / 1200.0);
|
||||
|
||||
// Stereo spread.
|
||||
float panPos = (uni > 1) ? ((float) v / (float) (uni - 1) - 0.5f) * 2.0f * p.spread : 0.0f;
|
||||
voices[(size_t) v].pan = panPos;
|
||||
|
||||
// Gain scaling with a centre emphasis for odd unison counts.
|
||||
float lvl = 1.0f / std::sqrt ((float) uni);
|
||||
if ((uni & 1) && v == uni / 2)
|
||||
lvl *= 1.3f;
|
||||
voices[(size_t) v].level = lvl;
|
||||
}
|
||||
}
|
||||
|
||||
float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept
|
||||
{
|
||||
const float amt = p.warpAmt;
|
||||
switch ((WarpMode) p.warp)
|
||||
{
|
||||
case WarpMode::None: return phase;
|
||||
case WarpMode::BendPlus: return phase + amt * 0.5f * std::sin (phase * (float) kTwoPi);
|
||||
case WarpMode::BendMinus: return phase - amt * 0.5f * std::sin (phase * (float) kTwoPi);
|
||||
case WarpMode::Sync: return std::fmod (phase * (1.0f + amt * 7.0f), 1.0f);
|
||||
case WarpMode::Asym:
|
||||
if (amt < 0.001f) return phase;
|
||||
return std::pow (phase, 1.0f + amt * 3.0f);
|
||||
case WarpMode::Mirror:
|
||||
{
|
||||
const float mirror = 2.0f * std::abs (phase - 0.5f);
|
||||
return phase + (mirror - phase) * amt;
|
||||
}
|
||||
case WarpMode::PWM: return phase;
|
||||
case WarpMode::Fold: return phase;
|
||||
default: return phase;
|
||||
}
|
||||
}
|
||||
|
||||
float Oscillator::warpSample (float sample, const OscParams& p) const noexcept
|
||||
{
|
||||
const float amt = p.warpAmt;
|
||||
switch ((WarpMode) p.warp)
|
||||
{
|
||||
case WarpMode::PWM:
|
||||
{
|
||||
const float threshold = (2.0f * amt - 1.0f) * 0.9f;
|
||||
return std::tanh ((sample - threshold) * 4.0f);
|
||||
}
|
||||
case WarpMode::Fold:
|
||||
return std::sin (sample * (1.0f + amt * 5.0f) * 1.5707963267948966f);
|
||||
default:
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
|
||||
void Oscillator::processAdd (const Wavetable& wt, const OscParams& p, double freqHz,
|
||||
float& outL, float& outR) noexcept
|
||||
{
|
||||
if (! p.enabled || p.level <= 0.0f || freqHz <= 0.0)
|
||||
return;
|
||||
|
||||
const int uni = juce::jlimit (1, kMaxUnison, p.unison);
|
||||
const float framePos = p.wtPos * 255.0f;
|
||||
const double phaseInc = kTwoPi * freqHz / sr;
|
||||
|
||||
float accL = 0.0f;
|
||||
float accR = 0.0f;
|
||||
|
||||
for (int v = 0; v < uni; ++v)
|
||||
{
|
||||
auto& sv = voices[(size_t) v];
|
||||
|
||||
sv.phase += phaseInc * sv.detuneRatio;
|
||||
sv.phase -= std::floor (sv.phase * (1.0 / kTwoPi)) * kTwoPi;
|
||||
if (sv.phase >= kTwoPi) sv.phase -= kTwoPi;
|
||||
if (sv.phase < 0.0) sv.phase += kTwoPi;
|
||||
|
||||
float phase01 = (float) (sv.phase * (1.0 / kTwoPi));
|
||||
float sample = wt.readSafe (framePos, warpPhase (phase01, p));
|
||||
sample = warpSample (sample, p);
|
||||
|
||||
// Constant-power pan.
|
||||
const float panAngle = (sv.pan + 1.0f) * 0.5f * 1.5707963267948966f;
|
||||
const float panL = std::cos (panAngle);
|
||||
const float panR = std::sin (panAngle);
|
||||
|
||||
const float gain = sv.level * p.level;
|
||||
accL += sample * gain * panL;
|
||||
accR += sample * gain * panR;
|
||||
}
|
||||
|
||||
outL += accL;
|
||||
outR += accR;
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
#include "Wavetable.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Per-oscillator parameters (snapshot read from the APVTS each block).
|
||||
// ===========================================================================
|
||||
struct OscParams
|
||||
{
|
||||
bool enabled = true;
|
||||
int wave = 0;
|
||||
float wtPos = 0.0f;
|
||||
int warp = 0;
|
||||
float warpAmt = 0.0f;
|
||||
int coarse = 0;
|
||||
int fine = 0;
|
||||
float level = 1.0f;
|
||||
float pan = 0.0f;
|
||||
int unison = 1;
|
||||
float detune = 0.0f;
|
||||
float spread = 0.0f;
|
||||
float phase = 0.0f;
|
||||
float randPhase = 0.0f;
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// A polyphonic oscillator with wavetable morphing, warp modes and unison
|
||||
// (1..16 sub-voices). Per-voice state (phase) lives here; params are read each
|
||||
// block from the OscParams snapshot.
|
||||
// ===========================================================================
|
||||
class Oscillator
|
||||
{
|
||||
public:
|
||||
Oscillator() = default;
|
||||
|
||||
void prepare (double sampleRate) { sr = sampleRate; reset(); }
|
||||
void reset();
|
||||
|
||||
// (Re)configure unison sub-voices: phase offsets, detune, pan and gain.
|
||||
void noteOn (double freqHz, const OscParams& p, juce::uint32 seed);
|
||||
|
||||
// Accumulate this oscillator's contribution into outL/outR.
|
||||
void processAdd (const Wavetable& wt, const OscParams& p, double freqHz,
|
||||
float& outL, float& outR) noexcept;
|
||||
|
||||
int getActiveUnison() const noexcept { return activeUnison; }
|
||||
|
||||
private:
|
||||
struct SubVoice
|
||||
{
|
||||
double phase = 0.0;
|
||||
double detuneRatio = 1.0;
|
||||
float pan = 0.0f;
|
||||
float level = 1.0f;
|
||||
};
|
||||
|
||||
std::array<SubVoice, kMaxUnison> voices;
|
||||
int activeUnison = 1;
|
||||
double sr = 44100.0;
|
||||
juce::Random rng;
|
||||
|
||||
static constexpr double kTwoPi = 6.28318530717958647692;
|
||||
|
||||
float warpPhase (float phase, const OscParams& p) const noexcept;
|
||||
float warpSample (float sample, const OscParams& p) const noexcept;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,156 @@
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
juce::String modSourceName (ModSource s)
|
||||
{
|
||||
switch (s)
|
||||
{
|
||||
case ModSource::Lfo1: return "LFO 1";
|
||||
case ModSource::Lfo2: return "LFO 2";
|
||||
case ModSource::Lfo3: return "LFO 3";
|
||||
case ModSource::Lfo4: return "LFO 4";
|
||||
case ModSource::Env1: return "Env 1";
|
||||
case ModSource::Env2: return "Env 2";
|
||||
case ModSource::Env3: return "Env 3";
|
||||
case ModSource::Env4: return "Env 4";
|
||||
case ModSource::Velocity: return "Velocity";
|
||||
case ModSource::Note: return "Note";
|
||||
case ModSource::ModWheel: return "Mod Wheel";
|
||||
case ModSource::PitchBend: return "Pitch Bend";
|
||||
case ModSource::Macro1: return "Macro 1";
|
||||
case ModSource::Macro2: return "Macro 2";
|
||||
case ModSource::Macro3: return "Macro 3";
|
||||
case ModSource::Macro4: return "Macro 4";
|
||||
case ModSource::Random: return "Random";
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
juce::String modSourceToString (ModSource s)
|
||||
{
|
||||
switch (s)
|
||||
{
|
||||
case ModSource::Lfo1: return "lfo1"; case ModSource::Lfo2: return "lfo2";
|
||||
case ModSource::Lfo3: return "lfo3"; case ModSource::Lfo4: return "lfo4";
|
||||
case ModSource::Env1: return "env1"; case ModSource::Env2: return "env2";
|
||||
case ModSource::Env3: return "env3"; case ModSource::Env4: return "env4";
|
||||
case ModSource::Velocity: return "velocity"; case ModSource::Note: return "note";
|
||||
case ModSource::ModWheel: return "modwheel"; case ModSource::PitchBend: return "pitchbend";
|
||||
case ModSource::Macro1: return "macro1"; case ModSource::Macro2: return "macro2";
|
||||
case ModSource::Macro3: return "macro3"; case ModSource::Macro4: return "macro4";
|
||||
case ModSource::Random: return "random";
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
ModSource modSourceFromString (const juce::String& s)
|
||||
{
|
||||
for (int i = 0; i < kNumModSources; ++i)
|
||||
if (s == modSourceToString ((ModSource) i))
|
||||
return (ModSource) i;
|
||||
return ModSource::Lfo1;
|
||||
}
|
||||
|
||||
juce::String modTargetName (ModTarget t)
|
||||
{
|
||||
switch (t)
|
||||
{
|
||||
case ModTarget::Master: return "Master Volume";
|
||||
case ModTarget::Pitch: return "Pitch";
|
||||
case ModTarget::Amp: return "Amp";
|
||||
case ModTarget::OscALevel: return "Osc A Level";
|
||||
case ModTarget::OscAPan: return "Osc A Pan";
|
||||
case ModTarget::OscAWtPos: return "Osc A WT Pos";
|
||||
case ModTarget::OscAUnison: return "Osc A Unison";
|
||||
case ModTarget::OscADetune: return "Osc A Detune";
|
||||
case ModTarget::OscASpread: return "Osc A Spread";
|
||||
case ModTarget::OscAWarpAmt: return "Osc A Warp Amt";
|
||||
case ModTarget::OscBLevel: return "Osc B Level";
|
||||
case ModTarget::OscBPan: return "Osc B Pan";
|
||||
case ModTarget::OscBWtPos: return "Osc B WT Pos";
|
||||
case ModTarget::OscBUnison: return "Osc B Unison";
|
||||
case ModTarget::OscBDetune: return "Osc B Detune";
|
||||
case ModTarget::OscBSpread: return "Osc B Spread";
|
||||
case ModTarget::OscBWarpAmt: return "Osc B Warp Amt";
|
||||
case ModTarget::SubLevel: return "Sub Level";
|
||||
case ModTarget::NoiseLevel: return "Noise Level";
|
||||
case ModTarget::Filter1Cutoff: return "Filter 1 Cutoff";
|
||||
case ModTarget::Filter1Res: return "Filter 1 Res";
|
||||
case ModTarget::Filter1Drive: return "Filter 1 Drive";
|
||||
case ModTarget::Filter2Cutoff: return "Filter 2 Cutoff";
|
||||
case ModTarget::Filter2Res: return "Filter 2 Res";
|
||||
case ModTarget::Filter2Drive: return "Filter 2 Drive";
|
||||
case ModTarget::FilterMix: return "Filter Mix";
|
||||
case ModTarget::FilterOut: return "Filter Out";
|
||||
case ModTarget::Fx1Mix: return "FX 1 Mix"; case ModTarget::Fx2Mix: return "FX 2 Mix";
|
||||
case ModTarget::Fx3Mix: return "FX 3 Mix"; case ModTarget::Fx4Mix: return "FX 4 Mix";
|
||||
case ModTarget::Fx5Mix: return "FX 5 Mix"; case ModTarget::Fx6Mix: return "FX 6 Mix";
|
||||
case ModTarget::Fx7Mix: return "FX 7 Mix"; case ModTarget::Fx8Mix: return "FX 8 Mix";
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
juce::String modTargetParamId (ModTarget t)
|
||||
{
|
||||
switch (t)
|
||||
{
|
||||
case ModTarget::Master: return ids::master;
|
||||
case ModTarget::Pitch: return {};
|
||||
case ModTarget::Amp: return {};
|
||||
case ModTarget::OscALevel: return ids::oscALevel;
|
||||
case ModTarget::OscAPan: return ids::oscAPan;
|
||||
case ModTarget::OscAWtPos: return ids::oscAWtPos;
|
||||
case ModTarget::OscAUnison: return ids::oscAUnison;
|
||||
case ModTarget::OscADetune: return ids::oscADetune;
|
||||
case ModTarget::OscASpread: return ids::oscASpread;
|
||||
case ModTarget::OscAWarpAmt: return ids::oscAWarpAmt;
|
||||
case ModTarget::OscBLevel: return ids::oscBLevel;
|
||||
case ModTarget::OscBPan: return ids::oscBPan;
|
||||
case ModTarget::OscBWtPos: return ids::oscBWtPos;
|
||||
case ModTarget::OscBUnison: return ids::oscBUnison;
|
||||
case ModTarget::OscBDetune: return ids::oscBDetune;
|
||||
case ModTarget::OscBSpread: return ids::oscBSpread;
|
||||
case ModTarget::OscBWarpAmt: return ids::oscBWarpAmt;
|
||||
case ModTarget::SubLevel: return ids::subLevel;
|
||||
case ModTarget::NoiseLevel: return ids::noiseLevel;
|
||||
case ModTarget::Filter1Cutoff: return ids::f1Cutoff;
|
||||
case ModTarget::Filter1Res: return ids::f1Res;
|
||||
case ModTarget::Filter1Drive: return ids::f1Drive;
|
||||
case ModTarget::Filter2Cutoff: return ids::f2Cutoff;
|
||||
case ModTarget::Filter2Res: return ids::f2Res;
|
||||
case ModTarget::Filter2Drive: return ids::f2Drive;
|
||||
case ModTarget::FilterMix: return ids::fMix;
|
||||
case ModTarget::FilterOut: return ids::fOut;
|
||||
case ModTarget::Fx1Mix: return ids::fx1Mix; case ModTarget::Fx2Mix: return ids::fx2Mix;
|
||||
case ModTarget::Fx3Mix: return ids::fx3Mix; case ModTarget::Fx4Mix: return ids::fx4Mix;
|
||||
case ModTarget::Fx5Mix: return ids::fx5Mix; case ModTarget::Fx6Mix: return ids::fx6Mix;
|
||||
case ModTarget::Fx7Mix: return ids::fx7Mix; case ModTarget::Fx8Mix: return ids::fx8Mix;
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
ModTarget modTargetFromParamId (const juce::String& id)
|
||||
{
|
||||
for (int i = 0; i < kNumModTargets; ++i)
|
||||
if (id.isNotEmpty() && modTargetParamId ((ModTarget) i) == id)
|
||||
return (ModTarget) i;
|
||||
return ModTarget::None;
|
||||
}
|
||||
|
||||
juce::String modTargetToString (ModTarget t)
|
||||
{
|
||||
if (t == ModTarget::Pitch) return "pitch";
|
||||
if (t == ModTarget::Amp) return "amp";
|
||||
return modTargetParamId (t);
|
||||
}
|
||||
|
||||
ModTarget modTargetFromString (const juce::String& s)
|
||||
{
|
||||
if (s == "pitch") return ModTarget::Pitch;
|
||||
if (s == "amp") return ModTarget::Amp;
|
||||
return modTargetFromParamId (s);
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
|
||||
// ===========================================================================
|
||||
// Central registry of parameter IDs, enums and mapping helpers for SerumAlt.
|
||||
// Every audio parameter (APVTS) and every modulation destination is declared
|
||||
// here so that the DSP engine, the mod matrix and the GUI share one source of
|
||||
// truth.
|
||||
// ===========================================================================
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameter IDs (APVTS)
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace ids
|
||||
{
|
||||
// Global
|
||||
inline constexpr const char* master = "master";
|
||||
inline constexpr const char* uiScale = "uiScale";
|
||||
inline constexpr const char* rave = "rave";
|
||||
|
||||
// Oscillator A / B
|
||||
inline constexpr const char* oscAOn = "oscAOn";
|
||||
inline constexpr const char* oscAWave = "oscAWave";
|
||||
inline constexpr const char* oscAWtPos = "oscAWtPos";
|
||||
inline constexpr const char* oscAWarp = "oscAWarp";
|
||||
inline constexpr const char* oscAWarpAmt = "oscAWarpAmt";
|
||||
inline constexpr const char* oscACoarse = "oscACoarse";
|
||||
inline constexpr const char* oscAFine = "oscAFine";
|
||||
inline constexpr const char* oscALevel = "oscALevel";
|
||||
inline constexpr const char* oscAPan = "oscAPan";
|
||||
inline constexpr const char* oscAUnison = "oscAUnison";
|
||||
inline constexpr const char* oscADetune = "oscADetune";
|
||||
inline constexpr const char* oscASpread = "oscASpread";
|
||||
inline constexpr const char* oscAPhase = "oscAPhase";
|
||||
inline constexpr const char* oscARandPh = "oscARandPh";
|
||||
|
||||
inline constexpr const char* oscBOn = "oscBOn";
|
||||
inline constexpr const char* oscBWave = "oscBWave";
|
||||
inline constexpr const char* oscBWtPos = "oscBWtPos";
|
||||
inline constexpr const char* oscBWarp = "oscBWarp";
|
||||
inline constexpr const char* oscBWarpAmt = "oscBWarpAmt";
|
||||
inline constexpr const char* oscBCoarse = "oscBCoarse";
|
||||
inline constexpr const char* oscBFine = "oscBFine";
|
||||
inline constexpr const char* oscBLevel = "oscBLevel";
|
||||
inline constexpr const char* oscBPan = "oscBPan";
|
||||
inline constexpr const char* oscBUnison = "oscBUnison";
|
||||
inline constexpr const char* oscBDetune = "oscBDetune";
|
||||
inline constexpr const char* oscBSpread = "oscBSpread";
|
||||
inline constexpr const char* oscBPhase = "oscBPhase";
|
||||
inline constexpr const char* oscBRandPh = "oscBRandPh";
|
||||
|
||||
// Sub oscillator
|
||||
inline constexpr const char* subOn = "subOn";
|
||||
inline constexpr const char* subShape = "subShape";
|
||||
inline constexpr const char* subOct = "subOct";
|
||||
inline constexpr const char* subLevel = "subLevel";
|
||||
|
||||
// Noise
|
||||
inline constexpr const char* noiseOn = "noiseOn";
|
||||
inline constexpr const char* noiseType = "noiseType";
|
||||
inline constexpr const char* noiseLevel = "noiseLevel";
|
||||
|
||||
// Filters
|
||||
inline constexpr const char* f1On = "f1On";
|
||||
inline constexpr const char* f1Type = "f1Type";
|
||||
inline constexpr const char* f1Cutoff = "f1Cutoff";
|
||||
inline constexpr const char* f1Res = "f1Res";
|
||||
inline constexpr const char* f1Drive = "f1Drive";
|
||||
inline constexpr const char* f1Key = "f1Key";
|
||||
inline constexpr const char* f1Slope = "f1Slope";
|
||||
|
||||
inline constexpr const char* f2On = "f2On";
|
||||
inline constexpr const char* f2Type = "f2Type";
|
||||
inline constexpr const char* f2Cutoff = "f2Cutoff";
|
||||
inline constexpr const char* f2Res = "f2Res";
|
||||
inline constexpr const char* f2Drive = "f2Drive";
|
||||
inline constexpr const char* f2Key = "f2Key";
|
||||
inline constexpr const char* f2Slope = "f2Slope";
|
||||
|
||||
inline constexpr const char* fRoute = "fRoute";
|
||||
inline constexpr const char* fMix = "fMix";
|
||||
inline constexpr const char* fOut = "fOut";
|
||||
|
||||
// Envelopes 1..4
|
||||
inline constexpr const char* env1A = "env1A";
|
||||
inline constexpr const char* env1D = "env1D";
|
||||
inline constexpr const char* env1S = "env1S";
|
||||
inline constexpr const char* env1R = "env1R";
|
||||
inline constexpr const char* env1Curve = "env1Curve";
|
||||
|
||||
inline constexpr const char* env2A = "env2A";
|
||||
inline constexpr const char* env2D = "env2D";
|
||||
inline constexpr const char* env2S = "env2S";
|
||||
inline constexpr const char* env2R = "env2R";
|
||||
inline constexpr const char* env2Curve = "env2Curve";
|
||||
|
||||
inline constexpr const char* env3A = "env3A";
|
||||
inline constexpr const char* env3D = "env3D";
|
||||
inline constexpr const char* env3S = "env3S";
|
||||
inline constexpr const char* env3R = "env3R";
|
||||
inline constexpr const char* env3Curve = "env3Curve";
|
||||
|
||||
inline constexpr const char* env4A = "env4A";
|
||||
inline constexpr const char* env4D = "env4D";
|
||||
inline constexpr const char* env4S = "env4S";
|
||||
inline constexpr const char* env4R = "env4R";
|
||||
inline constexpr const char* env4Curve = "env4Curve";
|
||||
|
||||
// LFOs 1..4
|
||||
inline constexpr const char* lfo1Rate = "lfo1Rate";
|
||||
inline constexpr const char* lfo1Sync = "lfo1Sync";
|
||||
inline constexpr const char* lfo1Beat = "lfo1Beat";
|
||||
inline constexpr const char* lfo1Shape = "lfo1Shape";
|
||||
inline constexpr const char* lfo1Phase = "lfo1Phase";
|
||||
inline constexpr const char* lfo1Fade = "lfo1Fade";
|
||||
inline constexpr const char* lfo1Delay = "lfo1Delay";
|
||||
|
||||
inline constexpr const char* lfo2Rate = "lfo2Rate";
|
||||
inline constexpr const char* lfo2Sync = "lfo2Sync";
|
||||
inline constexpr const char* lfo2Beat = "lfo2Beat";
|
||||
inline constexpr const char* lfo2Shape = "lfo2Shape";
|
||||
inline constexpr const char* lfo2Phase = "lfo2Phase";
|
||||
inline constexpr const char* lfo2Fade = "lfo2Fade";
|
||||
inline constexpr const char* lfo2Delay = "lfo2Delay";
|
||||
|
||||
inline constexpr const char* lfo3Rate = "lfo3Rate";
|
||||
inline constexpr const char* lfo3Sync = "lfo3Sync";
|
||||
inline constexpr const char* lfo3Beat = "lfo3Beat";
|
||||
inline constexpr const char* lfo3Shape = "lfo3Shape";
|
||||
inline constexpr const char* lfo3Phase = "lfo3Phase";
|
||||
inline constexpr const char* lfo3Fade = "lfo3Fade";
|
||||
inline constexpr const char* lfo3Delay = "lfo3Delay";
|
||||
|
||||
inline constexpr const char* lfo4Rate = "lfo4Rate";
|
||||
inline constexpr const char* lfo4Sync = "lfo4Sync";
|
||||
inline constexpr const char* lfo4Beat = "lfo4Beat";
|
||||
inline constexpr const char* lfo4Shape = "lfo4Shape";
|
||||
inline constexpr const char* lfo4Phase = "lfo4Phase";
|
||||
inline constexpr const char* lfo4Fade = "lfo4Fade";
|
||||
inline constexpr const char* lfo4Delay = "lfo4Delay";
|
||||
|
||||
// FX slots (8)
|
||||
inline constexpr const char* fx1Type = "fx1Type";
|
||||
inline constexpr const char* fx1Mix = "fx1Mix";
|
||||
inline constexpr const char* fx1P1 = "fx1P1";
|
||||
inline constexpr const char* fx1P2 = "fx1P2";
|
||||
inline constexpr const char* fx1P3 = "fx1P3";
|
||||
inline constexpr const char* fx1P4 = "fx1P4";
|
||||
|
||||
inline constexpr const char* fx2Type = "fx2Type";
|
||||
inline constexpr const char* fx2Mix = "fx2Mix";
|
||||
inline constexpr const char* fx2P1 = "fx2P1";
|
||||
inline constexpr const char* fx2P2 = "fx2P2";
|
||||
inline constexpr const char* fx2P3 = "fx2P3";
|
||||
inline constexpr const char* fx2P4 = "fx2P4";
|
||||
|
||||
inline constexpr const char* fx3Type = "fx3Type";
|
||||
inline constexpr const char* fx3Mix = "fx3Mix";
|
||||
inline constexpr const char* fx3P1 = "fx3P1";
|
||||
inline constexpr const char* fx3P2 = "fx3P2";
|
||||
inline constexpr const char* fx3P3 = "fx3P3";
|
||||
inline constexpr const char* fx3P4 = "fx3P4";
|
||||
|
||||
inline constexpr const char* fx4Type = "fx4Type";
|
||||
inline constexpr const char* fx4Mix = "fx4Mix";
|
||||
inline constexpr const char* fx4P1 = "fx4P1";
|
||||
inline constexpr const char* fx4P2 = "fx4P2";
|
||||
inline constexpr const char* fx4P3 = "fx4P3";
|
||||
inline constexpr const char* fx4P4 = "fx4P4";
|
||||
|
||||
inline constexpr const char* fx5Type = "fx5Type";
|
||||
inline constexpr const char* fx5Mix = "fx5Mix";
|
||||
inline constexpr const char* fx5P1 = "fx5P1";
|
||||
inline constexpr const char* fx5P2 = "fx5P2";
|
||||
inline constexpr const char* fx5P3 = "fx5P3";
|
||||
inline constexpr const char* fx5P4 = "fx5P4";
|
||||
|
||||
inline constexpr const char* fx6Type = "fx6Type";
|
||||
inline constexpr const char* fx6Mix = "fx6Mix";
|
||||
inline constexpr const char* fx6P1 = "fx6P1";
|
||||
inline constexpr const char* fx6P2 = "fx6P2";
|
||||
inline constexpr const char* fx6P3 = "fx6P3";
|
||||
inline constexpr const char* fx6P4 = "fx6P4";
|
||||
|
||||
inline constexpr const char* fx7Type = "fx7Type";
|
||||
inline constexpr const char* fx7Mix = "fx7Mix";
|
||||
inline constexpr const char* fx7P1 = "fx7P1";
|
||||
inline constexpr const char* fx7P2 = "fx7P2";
|
||||
inline constexpr const char* fx7P3 = "fx7P3";
|
||||
inline constexpr const char* fx7P4 = "fx7P4";
|
||||
|
||||
inline constexpr const char* fx8Type = "fx8Type";
|
||||
inline constexpr const char* fx8Mix = "fx8Mix";
|
||||
inline constexpr const char* fx8P1 = "fx8P1";
|
||||
inline constexpr const char* fx8P2 = "fx8P2";
|
||||
inline constexpr const char* fx8P3 = "fx8P3";
|
||||
inline constexpr const char* fx8P4 = "fx8P4";
|
||||
|
||||
// Macros 1..4
|
||||
inline constexpr const char* macro1 = "macro1";
|
||||
inline constexpr const char* macro2 = "macro2";
|
||||
inline constexpr const char* macro3 = "macro3";
|
||||
inline constexpr const char* macro4 = "macro4";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums
|
||||
// ---------------------------------------------------------------------------
|
||||
enum class WarpMode : int { None = 0, BendPlus, BendMinus, Sync, PWM, Asym, Mirror, Fold, Count };
|
||||
enum class SubShape : int { Sine = 0, Triangle, Count };
|
||||
enum class NoiseType : int { White = 0, Pink, Count };
|
||||
enum class FilterModel: int { LadderLP = 0, LadderHP, LadderBP, Diode, Comb, Formant, Screamer, Count };
|
||||
enum class FilterRoute: int { Serial = 0, Parallel, Split, Count };
|
||||
enum class LfoShape : int { Sine = 0, Triangle, Saw, Square, SampleHold, StepSeq, Freehand, Count };
|
||||
enum class FxType : int { Off = 0, Hyper, Chorus, Flanger, Phaser, Distortion, EQ, Compressor, Delay, Reverb, Count };
|
||||
|
||||
inline constexpr int kNumOscillators = 2;
|
||||
inline constexpr int kNumEnvelopes = 4;
|
||||
inline constexpr int kNumLfos = 4;
|
||||
inline constexpr int kNumMacros = 4;
|
||||
inline constexpr int kNumFxSlots = 8;
|
||||
inline constexpr int kMaxUnison = 16;
|
||||
inline constexpr int kNumVoices = 32;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modulation sources / destinations
|
||||
// ---------------------------------------------------------------------------
|
||||
enum class ModSource : int
|
||||
{
|
||||
Lfo1 = 0, Lfo2, Lfo3, Lfo4,
|
||||
Env1, Env2, Env3, Env4,
|
||||
Velocity, Note, ModWheel, PitchBend,
|
||||
Macro1, Macro2, Macro3, Macro4,
|
||||
Random,
|
||||
Count
|
||||
};
|
||||
|
||||
enum class ModTarget : int
|
||||
{
|
||||
None = -1,
|
||||
Master = 0,
|
||||
Pitch,
|
||||
Amp,
|
||||
OscALevel, OscAPan, OscAWtPos, OscAUnison, OscADetune, OscASpread, OscAWarpAmt,
|
||||
OscBLevel, OscBPan, OscBWtPos, OscBUnison, OscBDetune, OscBSpread, OscBWarpAmt,
|
||||
SubLevel, NoiseLevel,
|
||||
Filter1Cutoff, Filter1Res, Filter1Drive,
|
||||
Filter2Cutoff, Filter2Res, Filter2Drive,
|
||||
FilterMix, FilterOut,
|
||||
Fx1Mix, Fx2Mix, Fx3Mix, Fx4Mix, Fx5Mix, Fx6Mix, Fx7Mix, Fx8Mix,
|
||||
Count
|
||||
};
|
||||
|
||||
inline constexpr int kNumModTargets = (int) ModTarget::Count;
|
||||
inline constexpr int kNumModSources = (int) ModSource::Count;
|
||||
|
||||
// Is a given source per-voice (i.e. needs a value for each voice)?
|
||||
inline bool isPerVoiceSource (ModSource s)
|
||||
{
|
||||
switch (s)
|
||||
{
|
||||
case ModSource::Env1: case ModSource::Env2:
|
||||
case ModSource::Env3: case ModSource::Env4:
|
||||
case ModSource::Velocity: case ModSource::Note:
|
||||
case ModSource::Random: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Does a source already span -1..1 (bipolar range)?
|
||||
inline bool isBipolarSource (ModSource s)
|
||||
{
|
||||
return (s >= ModSource::Lfo1 && s <= ModSource::Lfo4) || s == ModSource::PitchBend;
|
||||
}
|
||||
|
||||
// Is a given target a per-voice parameter?
|
||||
inline bool isPerVoiceTarget (ModTarget t)
|
||||
{
|
||||
switch (t)
|
||||
{
|
||||
case ModTarget::Master:
|
||||
case ModTarget::FilterMix: case ModTarget::FilterOut:
|
||||
case ModTarget::Fx1Mix: case ModTarget::Fx2Mix:
|
||||
case ModTarget::Fx3Mix: case ModTarget::Fx4Mix:
|
||||
case ModTarget::Fx5Mix: case ModTarget::Fx6Mix:
|
||||
case ModTarget::Fx7Mix: case ModTarget::Fx8Mix:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
juce::String modSourceName (ModSource s);
|
||||
juce::String modTargetName (ModTarget t);
|
||||
juce::String modTargetParamId(ModTarget t); // APVTS param id for the target
|
||||
ModTarget modTargetFromParamId (const juce::String& id);
|
||||
ModSource modSourceFromString (const juce::String& s);
|
||||
juce::String modSourceToString (ModSource s);
|
||||
juce::String modTargetToString (ModTarget t);
|
||||
ModTarget modTargetFromString (const juce::String& s);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping helpers (raw 0..1 <-> physical units)
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace maps
|
||||
{
|
||||
// Envelope times: 0..1 -> 0.5ms .. 12s (cubic taper for fine control at short times)
|
||||
inline float toSeconds (float x) { return 0.0005f + std::pow (juce::jlimit (0.0f, 1.0f, x), 3.0f) * 12.0f; }
|
||||
inline float fromSeconds (float s) { return std::pow (juce::jlimit (0.0005f, 12.0f, s) / 12.0f, 1.0f / 3.0f); }
|
||||
|
||||
// Cutoff: 0..1 -> 20Hz .. 20kHz (log)
|
||||
inline float cutoffToHz (float x) { return 20.0f * std::pow (1000.0f, juce::jlimit (0.0f, 1.0f, x)); }
|
||||
inline float hzToCutoff (float hz) { return std::log (juce::jlimit (20.0f, 20000.0f, hz) / 20.0f) / std::log (1000.0f); }
|
||||
|
||||
// LFO rate: 0..1 -> 0.02Hz .. 30Hz (log)
|
||||
inline float rateToHz (float x) { return 0.02f * std::pow (1500.0f, juce::jlimit (0.0f, 1.0f, x)); }
|
||||
inline float hzToRate (float hz) { return std::log (juce::jlimit (0.02f, 30.0f, hz) / 0.02f) / std::log (1500.0f); }
|
||||
|
||||
// LFO beat division: 0..1 -> 1/32 .. 4 bars (log stepped)
|
||||
inline float beatToMultiplier (float x)
|
||||
{
|
||||
// 0 -> 1/32, 0.5 -> 1/4, 1 -> 4 bars
|
||||
static constexpr float divisions[16] =
|
||||
{
|
||||
1.0f/32.0f, 1.0f/16.0f, 1.0f/8.0f, 1.0f/6.0f, 1.0f/4.0f, 1.0f/3.0f,
|
||||
1.0f/2.0f, 1.0f, 3.0f/2.0f, 2.0f, 3.0f, 4.0f, 6.0f, 8.0f, 12.0f, 16.0f
|
||||
};
|
||||
const int idx = juce::jlimit (0, 15, (int) std::llround (x * 15.0f));
|
||||
return divisions[idx];
|
||||
}
|
||||
|
||||
// FX delay time: 0..1 -> 1ms .. 2000ms (log)
|
||||
inline float delayToMs (float x) { return 1.0f * std::pow (2000.0f, juce::jlimit (0.0f, 1.0f, x)); }
|
||||
inline float msToDelay (float ms) { return std::log (juce::jlimit (1.0f, 2000.0f, ms)) / std::log (2000.0f); }
|
||||
|
||||
// Reverb size: 0..1 -> 0.2 .. 0.99
|
||||
inline float sizeToValue (float x) { return 0.2f + juce::jlimit (0.0f, 1.0f, x) * 0.79f; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory wavetable names (index == oscWave param value)
|
||||
// ---------------------------------------------------------------------------
|
||||
inline const char* const kWavetableNames[] =
|
||||
{
|
||||
"Basic Shapes",
|
||||
"Saw PWM",
|
||||
"Square Sync",
|
||||
"Triangle Fold",
|
||||
"Vowel",
|
||||
"Organ",
|
||||
"Warm Saw",
|
||||
"Digital",
|
||||
"Glass",
|
||||
"Bass"
|
||||
};
|
||||
inline constexpr int kNumWavetables = 10;
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,882 @@
|
||||
#include "PluginEditor.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Consistent spacing system. Every panel, row and control position below is
|
||||
// derived from these constants so the tabs share identical margins, padding
|
||||
// and row pitches.
|
||||
// -----------------------------------------------------------------------
|
||||
namespace layout
|
||||
{
|
||||
constexpr int margin = 10; // panels inset from the tab view edge
|
||||
constexpr int padding = 8; // controls inset inside panels + row rhythm
|
||||
constexpr int gap = 6; // horizontal spacing between standard knobs
|
||||
constexpr int knobSize = 76; // standard knob footprint (incl. label)
|
||||
constexpr int knobSmall = 44; // small knob width (env / LFO rows)
|
||||
constexpr int rowHeight = knobSize + padding; // vertical pitch of knob rows
|
||||
constexpr int panelSpacing = 10; // gap between adjacent panels
|
||||
constexpr int labelHeight = 16; // label strip height
|
||||
|
||||
// Derived from the visual style (panel title bar and control sizes) so the
|
||||
// content grid stays aligned across every tab.
|
||||
constexpr int titleHeight = 22; // panel title bar height
|
||||
constexpr int comboHeight = 24; // combo box height
|
||||
constexpr int buttonHeight = 26; // text button height
|
||||
constexpr int toggleWidth = 40; // "On"/"Sync" toggle width
|
||||
constexpr int toggleHeight = 36; // toggle height (LED + visible label)
|
||||
constexpr int headerHeight = toggleHeight; // top control row height
|
||||
constexpr int displayHeight = 66; // waveform / filter display height
|
||||
constexpr int envDisplayHeight = 56; // envelope display height
|
||||
constexpr int lfoDisplayHeight = 64; // LFO display height
|
||||
constexpr int smallKnobHeight = 60; // height of 44px-wide small knobs
|
||||
constexpr int smallGap = 3; // horizontal spacing between small knobs
|
||||
constexpr int arrowWidth = 26; // FX up/down arrow button width
|
||||
constexpr int macroKnobSize = 120; // macro knob footprint
|
||||
}
|
||||
|
||||
// Horizontal position of a grid column (0-based).
|
||||
inline int gridX (int col, int cellW, int cellGap)
|
||||
{
|
||||
return layout::margin + col * (cellW + cellGap);
|
||||
}
|
||||
|
||||
// Y position of a combo box centred on a panel's header row.
|
||||
inline int comboRowY()
|
||||
{
|
||||
return layout::titleHeight + (layout::headerHeight - layout::comboHeight) / 2;
|
||||
}
|
||||
|
||||
// Y position of the first content row below a panel's header row.
|
||||
inline int bodyTop()
|
||||
{
|
||||
return layout::titleHeight + layout::headerHeight + layout::padding;
|
||||
}
|
||||
|
||||
juce::StringArray wavetableItems()
|
||||
{
|
||||
juce::StringArray a;
|
||||
for (auto n : kWavetableNames) a.add (n);
|
||||
return a;
|
||||
}
|
||||
|
||||
juce::StringArray modSourceItems()
|
||||
{
|
||||
juce::StringArray a;
|
||||
for (int i = 0; i < kNumModSources; ++i)
|
||||
a.add (modSourceName ((ModSource) i));
|
||||
return a;
|
||||
}
|
||||
|
||||
juce::StringArray modTargetItems (std::vector<ModTarget>& enums)
|
||||
{
|
||||
juce::StringArray a;
|
||||
for (int i = 0; i < kNumModTargets; ++i)
|
||||
{
|
||||
const auto t = (ModTarget) i;
|
||||
if (t == ModTarget::None) continue;
|
||||
a.add (modTargetName (t));
|
||||
enums.push_back (t);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
juce::String fmtUnison (float v) { return juce::String (1 + (int) std::llround (v * 15.0f)); }
|
||||
juce::String fmtSlope (float v) { const int s = (int) std::llround (v * 2.0f); return juce::String (s == 0 ? 6 : (s == 1 ? 12 : 24)) + " dB"; }
|
||||
juce::String fmtDepth (float v) { return juce::String (v, 2); }
|
||||
|
||||
void placeKnobs (std::vector<Knob*>& knobs, int x, int y, int w, int h, int gap = layout::gap)
|
||||
{
|
||||
for (int i = 0; i < (int) knobs.size(); ++i)
|
||||
knobs[(size_t) i]->setBounds (x + i * (w + gap), y, w, h);
|
||||
}
|
||||
|
||||
const char* kFxType[8] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
|
||||
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
|
||||
const char* kFxMix[8] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
|
||||
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
|
||||
const char* kFxP1[8] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1,
|
||||
ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 };
|
||||
const char* kFxP2[8] = { ids::fx1P2, ids::fx2P2, ids::fx3P2, ids::fx4P2,
|
||||
ids::fx5P2, ids::fx6P2, ids::fx7P2, ids::fx8P2 };
|
||||
const char* kFxP3[8] = { ids::fx1P3, ids::fx2P3, ids::fx3P3, ids::fx4P3,
|
||||
ids::fx5P3, ids::fx6P3, ids::fx7P3, ids::fx8P3 };
|
||||
const char* kFxP4[8] = { ids::fx1P4, ids::fx2P4, ids::fx3P4, ids::fx4P4,
|
||||
ids::fx5P4, ids::fx6P4, ids::fx7P4, ids::fx8P4 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
PluginEditor::PluginEditor (SerumAltAudioProcessor& p)
|
||||
: AudioProcessorEditor (p), processor (p),
|
||||
tooltipWindow (this, 500),
|
||||
masterKnob ("Master", formatPercent)
|
||||
{
|
||||
// Tab / Shift+Tab cycling is handled by keyPressed(); this lets the editor
|
||||
// receive keyboard focus when the user clicks anywhere on it.
|
||||
setWantsKeyboardFocus (true);
|
||||
|
||||
root.setBounds (0, 0, kBaseW, kBaseH);
|
||||
addAndMakeVisible (root);
|
||||
|
||||
logo = createLogoDrawable();
|
||||
|
||||
buildTopBar();
|
||||
buildOscTab();
|
||||
buildFilterTab();
|
||||
buildModTab();
|
||||
buildFxTab();
|
||||
buildMacroTab();
|
||||
|
||||
setTab (0);
|
||||
applyUiScale();
|
||||
startTimerHz (30);
|
||||
}
|
||||
|
||||
PluginEditor::~PluginEditor()
|
||||
{
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void PluginEditor::paint (juce::Graphics& g)
|
||||
{
|
||||
g.fillAll (theme::bg);
|
||||
g.setGradientFill (juce::ColourGradient (theme::panel, 0.0f, 0.0f,
|
||||
theme::bg, 0.0f, (float) getHeight(), false));
|
||||
g.fillAll();
|
||||
|
||||
if (logo != nullptr)
|
||||
logo->drawWithin (g, juce::Rectangle<float> (10.0f, 8.0f, 180.0f, 44.0f),
|
||||
juce::RectanglePlacement::centred, 1.0f);
|
||||
}
|
||||
|
||||
void PluginEditor::resized()
|
||||
{
|
||||
root.setBounds (0, 0, kBaseW, kBaseH);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
Knob* PluginEditor::makeKnob (juce::Component* parent, const juce::String& name,
|
||||
const juce::String& paramId, std::function<juce::String (float)> fmt,
|
||||
const juce::String& tooltip)
|
||||
{
|
||||
auto* k = new Knob (name, std::move (fmt));
|
||||
parent->addAndMakeVisible (k);
|
||||
if (paramId.isNotEmpty())
|
||||
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
|
||||
processor.parameters, paramId, *k));
|
||||
k->setTooltip (tooltip.isNotEmpty() ? tooltip : name);
|
||||
return k;
|
||||
}
|
||||
|
||||
juce::ComboBox* PluginEditor::makeCombo (juce::Component* parent, const juce::String& paramId,
|
||||
const juce::StringArray& items, const juce::String& tooltip)
|
||||
{
|
||||
auto* c = new juce::ComboBox();
|
||||
c->addItemList (items, 1);
|
||||
c->setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
parent->addAndMakeVisible (c);
|
||||
if (paramId.isNotEmpty())
|
||||
comboAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::ComboBoxAttachment> (
|
||||
processor.parameters, paramId, *c));
|
||||
if (tooltip.isNotEmpty())
|
||||
c->setTooltip (tooltip);
|
||||
return c;
|
||||
}
|
||||
|
||||
ToggleButton* PluginEditor::makeToggle (juce::Component* parent, const juce::String& label,
|
||||
const juce::String& paramId, const juce::String& tooltip)
|
||||
{
|
||||
auto* t = new ToggleButton (label);
|
||||
parent->addAndMakeVisible (t);
|
||||
if (paramId.isNotEmpty())
|
||||
t->attach (processor.parameters, paramId);
|
||||
t->setTooltip (tooltip.isNotEmpty() ? tooltip : label);
|
||||
return t;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void PluginEditor::buildTopBar()
|
||||
{
|
||||
// Preset selector.
|
||||
juce::StringArray presetNames;
|
||||
for (int i = 0; i < processor.getNumPrograms(); ++i)
|
||||
presetNames.add (processor.getProgramName (i));
|
||||
presetCombo.addItemList (presetNames, 1);
|
||||
presetCombo.setSelectedItemIndex (processor.getCurrentProgram(), juce::dontSendNotification);
|
||||
presetCombo.onChange = [this] { processor.setCurrentProgram (presetCombo.getSelectedItemIndex()); };
|
||||
root.addAndMakeVisible (presetCombo);
|
||||
presetCombo.setBounds (200, 16, 220, 24);
|
||||
presetCombo.setTooltip ("Select a factory preset");
|
||||
|
||||
// UI scale.
|
||||
scaleCombo.addItemList ({ "75%", "100%", "125%", "150%", "200%" }, 1);
|
||||
scaleCombo.setSelectedItemIndex (processor.getUiScaleIndex(), juce::dontSendNotification);
|
||||
scaleCombo.onChange = [this] { processor.setUiScaleIndex (scaleCombo.getSelectedItemIndex()); };
|
||||
root.addAndMakeVisible (scaleCombo);
|
||||
scaleCombo.setBounds (980, 16, 70, 24);
|
||||
scaleCombo.setTooltip ("Interface scale (75% - 200%)");
|
||||
|
||||
// RAVE.
|
||||
raveButton.setLabel ("RAVE");
|
||||
raveButton.setOnColour (theme::raveGlow);
|
||||
raveButton.setBounds (1062, 2, 50, 56);
|
||||
root.addAndMakeVisible (raveButton);
|
||||
raveButton.setTooltip ("RAVE one-click boost (unison, width, drive, OTT, reverb)");
|
||||
// RAVE uses a callback rather than a direct parameter attachment.
|
||||
raveButton.setOnClick ([this] (bool on) { processor.setRaveEnabled (on); });
|
||||
|
||||
// Tab bar.
|
||||
juce::TextButton* tabs[5] = { &tabOsc, &tabFilter, &tabMod, &tabFx, &tabMacro };
|
||||
const char* tabNames[5] = { "OSC", "FILTER", "MOD", "FX", "MACRO" };
|
||||
const char* tabTips[5] = { "Oscillators (Tab cycles sections)",
|
||||
"Filters (Tab cycles sections)",
|
||||
"Modulation: envelopes, LFOs and matrix (Tab cycles sections)",
|
||||
"Effects rack (Tab cycles sections)",
|
||||
"Macros (Tab cycles sections)" };
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
tabs[i]->setButtonText (tabNames[i]);
|
||||
tabs[i]->setClickingTogglesState (true);
|
||||
tabs[i]->setRadioGroupId (1001);
|
||||
tabs[i]->setBounds (10 + i * 92, 36, 84, 22);
|
||||
tabs[i]->onClick = [this, i] { setTab (i); };
|
||||
tabs[i]->setTooltip (tabTips[i]);
|
||||
root.addAndMakeVisible (tabs[i]);
|
||||
}
|
||||
|
||||
// Master.
|
||||
masterKnob.setBounds (906, 0, 64, 60);
|
||||
masterKnob.setTooltip ("Master output volume (0-100%)");
|
||||
root.addAndMakeVisible (masterKnob);
|
||||
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
|
||||
processor.parameters, ids::master, masterKnob));
|
||||
|
||||
masterDisplay.setTitle ("MASTER");
|
||||
root.addAndMakeVisible (masterDisplay);
|
||||
masterDisplay.setBounds (846, 8, 56, 44);
|
||||
}
|
||||
|
||||
void PluginEditor::buildOscTab()
|
||||
{
|
||||
oscView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
root.addChildComponent (oscView);
|
||||
|
||||
constexpr int colW = 545; // two equal columns filling kBaseW
|
||||
constexpr int oscH = 308;
|
||||
constexpr int subH = 150;
|
||||
|
||||
oscAPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, oscH);
|
||||
oscBPanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, oscH);
|
||||
subPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin + oscH + layout::panelSpacing, colW, subH);
|
||||
noisePanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin + oscH + layout::panelSpacing, colW, subH);
|
||||
for (auto* panel : { &oscAPanel, &oscBPanel, &subPanel, &noisePanel })
|
||||
oscView.addAndMakeVisible (panel);
|
||||
|
||||
const int comboY = comboRowY();
|
||||
const int displayY = bodyTop(); // below header row
|
||||
const int row1Y = displayY + layout::displayHeight + layout::padding;
|
||||
const int row2Y = row1Y + layout::rowHeight;
|
||||
|
||||
// --- Oscillator A ---
|
||||
makeToggle (&oscAPanel, "On", ids::oscAOn, "Enable oscillator A")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&oscAPanel, ids::oscAWave, wavetableItems(), "Oscillator A wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
|
||||
makeCombo (&oscAPanel, ids::oscAWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator A warp mode")
|
||||
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
|
||||
|
||||
oscAWave.setWavetables (&processor.engine.getWavetables());
|
||||
oscAPanel.addAndMakeVisible (oscAWave);
|
||||
oscAWave.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
|
||||
|
||||
std::vector<Knob*> rowA1 = {
|
||||
makeKnob (&oscAPanel, "Wavetable Position", ids::oscAWtPos, formatPercent, "Wavetable position (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Warp Amount", ids::oscAWarpAmt, formatPercent, "Warp amount (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Coarse", ids::oscACoarse, formatSemis, "Pitch coarse (-24 to +24 semitones)"),
|
||||
makeKnob (&oscAPanel, "Fine", ids::oscAFine, formatCents, "Pitch fine (-100 to +100 cents)"),
|
||||
makeKnob (&oscAPanel, "Level", ids::oscALevel, formatPercent, "Oscillator A level (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Pan", ids::oscAPan, formatPan, "Oscillator A pan (L100-R100)")
|
||||
};
|
||||
placeKnobs (rowA1, layout::padding, row1Y, layout::knobSize, layout::knobSize);
|
||||
|
||||
std::vector<Knob*> rowA2 = {
|
||||
makeKnob (&oscAPanel, "Unison", ids::oscAUnison, fmtUnison, "Unison voices (1-16)"),
|
||||
makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatPercent, "Unison detune (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison spread (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Phase", ids::oscAPhase, formatPercent, "Phase (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Random Phase", ids::oscARandPh, formatPercent, "Random phase (0-100%)")
|
||||
};
|
||||
placeKnobs (rowA2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
|
||||
|
||||
// --- Oscillator B ---
|
||||
makeToggle (&oscBPanel, "On", ids::oscBOn, "Enable oscillator B")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&oscBPanel, ids::oscBWave, wavetableItems(), "Oscillator B wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
|
||||
makeCombo (&oscBPanel, ids::oscBWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator B warp mode")
|
||||
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
|
||||
|
||||
oscBWave.setWavetables (&processor.engine.getWavetables());
|
||||
oscBPanel.addAndMakeVisible (oscBWave);
|
||||
oscBWave.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
|
||||
|
||||
std::vector<Knob*> rowB1 = {
|
||||
makeKnob (&oscBPanel, "Wavetable Position", ids::oscBWtPos, formatPercent, "Wavetable position (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Warp Amount", ids::oscBWarpAmt, formatPercent, "Warp amount (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Coarse", ids::oscBCoarse, formatSemis, "Pitch coarse (-24 to +24 semitones)"),
|
||||
makeKnob (&oscBPanel, "Fine", ids::oscBFine, formatCents, "Pitch fine (-100 to +100 cents)"),
|
||||
makeKnob (&oscBPanel, "Level", ids::oscBLevel, formatPercent, "Oscillator B level (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Pan", ids::oscBPan, formatPan, "Oscillator B pan (L100-R100)")
|
||||
};
|
||||
placeKnobs (rowB1, layout::padding, row1Y, layout::knobSize, layout::knobSize);
|
||||
|
||||
std::vector<Knob*> rowB2 = {
|
||||
makeKnob (&oscBPanel, "Unison", ids::oscBUnison, fmtUnison, "Unison voices (1-16)"),
|
||||
makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatPercent, "Unison detune (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison spread (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Phase", ids::oscBPhase, formatPercent, "Phase (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Random Phase", ids::oscBRandPh, formatPercent, "Random phase (0-100%)")
|
||||
};
|
||||
placeKnobs (rowB2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
|
||||
|
||||
// --- Sub ---
|
||||
makeToggle (&subPanel, "On", ids::subOn, "Enable sub oscillator")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&subPanel, ids::subShape, { "Sine", "Triangle" }, "Sub oscillator waveform")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
|
||||
makeCombo (&subPanel, ids::subOct, { "-2 oct", "-1 oct", "0 oct" }, "Sub oscillator octave")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 130 + layout::gap, comboY, 90, layout::comboHeight);
|
||||
makeKnob (&subPanel, "Level", ids::subLevel, formatPercent, "Sub oscillator level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
|
||||
|
||||
// --- Noise ---
|
||||
makeToggle (&noisePanel, "On", ids::noiseOn, "Enable noise")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&noisePanel, ids::noiseType, { "White", "Pink" }, "Noise type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
|
||||
makeKnob (&noisePanel, "Level", ids::noiseLevel, formatPercent, "Noise level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
|
||||
}
|
||||
|
||||
void PluginEditor::buildFilterTab()
|
||||
{
|
||||
filterView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
root.addChildComponent (filterView);
|
||||
|
||||
constexpr int colW = 360;
|
||||
constexpr int panelH = 300; // retains the original panel height (content top-aligned)
|
||||
|
||||
filter1Panel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, panelH);
|
||||
filter2Panel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, panelH);
|
||||
routingPanel.setBounds (gridX (2, colW, layout::panelSpacing), layout::margin, colW, panelH);
|
||||
for (auto* panel : { &filter1Panel, &filter2Panel, &routingPanel })
|
||||
filterView.addAndMakeVisible (panel);
|
||||
|
||||
const juce::StringArray filterTypes = { "Ladder LP", "Ladder HP", "Ladder BP", "Diode", "Comb", "Formant", "Screamer" };
|
||||
const juce::StringArray slopes = { "6 dB", "12 dB", "24 dB" };
|
||||
|
||||
const int comboY = comboRowY();
|
||||
const int displayY = bodyTop();
|
||||
const int knobY = displayY + layout::displayHeight + layout::padding; // aligns with OSC row 1
|
||||
|
||||
// Filter 1
|
||||
makeToggle (&filter1Panel, "On", ids::f1On, "Enable filter 1")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&filter1Panel, ids::f1Type, filterTypes, "Filter 1 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
|
||||
makeCombo (&filter1Panel, ids::f1Slope, slopes, "Filter 1 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
|
||||
filter1Panel.addAndMakeVisible (filter1Display);
|
||||
filter1Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
|
||||
|
||||
std::vector<Knob*> f1 = {
|
||||
makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff (20 Hz - 20 kHz)"),
|
||||
makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance (0-100%)"),
|
||||
makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive (0-100%)"),
|
||||
makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 keytrack (0-100%)")
|
||||
};
|
||||
placeKnobs (f1, layout::padding, knobY, layout::knobSize, layout::knobSize);
|
||||
|
||||
// Filter 2
|
||||
makeToggle (&filter2Panel, "On", ids::f2On, "Enable filter 2")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&filter2Panel, ids::f2Type, filterTypes, "Filter 2 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
|
||||
makeCombo (&filter2Panel, ids::f2Slope, slopes, "Filter 2 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
|
||||
filter2Panel.addAndMakeVisible (filter2Display);
|
||||
filter2Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
|
||||
|
||||
std::vector<Knob*> f2 = {
|
||||
makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff (20 Hz - 20 kHz)"),
|
||||
makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance (0-100%)"),
|
||||
makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive (0-100%)"),
|
||||
makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 keytrack (0-100%)")
|
||||
};
|
||||
placeKnobs (f2, layout::padding, knobY, layout::knobSize, layout::knobSize);
|
||||
|
||||
// Routing
|
||||
makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing (serial / parallel / split)")->setBounds (layout::padding, comboY, 160, layout::comboHeight);
|
||||
makeKnob (&routingPanel, "Mix", ids::fMix, formatPercent, "Filter mix (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
|
||||
makeKnob (&routingPanel, "Output", ids::fOut, formatPercent, "Filter output (0-100%)")->setBounds (layout::padding + layout::knobSize + layout::gap, displayY, layout::knobSize, layout::knobSize);
|
||||
}
|
||||
|
||||
void PluginEditor::buildModTab()
|
||||
{
|
||||
modView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
root.addChildComponent (modView);
|
||||
|
||||
const juce::StringArray lfoShapes = { "Sine", "Triangle", "Saw", "Square", "S&H", "Step", "Freehand" };
|
||||
|
||||
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
|
||||
constexpr int colGap = 8;
|
||||
constexpr int envH = 154;
|
||||
constexpr int lfoH = 206;
|
||||
|
||||
// Envelopes (4 across).
|
||||
for (int i = 0; i < kNumEnvelopes; ++i)
|
||||
{
|
||||
envPanels[(size_t) i].setBounds (gridX (i, colW, colGap), layout::margin, colW, envH);
|
||||
modView.addAndMakeVisible (envPanels[(size_t) i]);
|
||||
|
||||
envPanels[(size_t) i].addAndMakeVisible (envDisplays[(size_t) i]);
|
||||
envDisplays[(size_t) i].setBounds (layout::padding, layout::titleHeight,
|
||||
colW - 2 * layout::padding, layout::envDisplayHeight);
|
||||
|
||||
const char* idsA[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
|
||||
const char* idsD[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
|
||||
const char* idsS[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
|
||||
const char* idsR[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
|
||||
const char* idsC[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
|
||||
|
||||
std::vector<Knob*> knobs = {
|
||||
makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time (0.5 ms - 12 s)"),
|
||||
makeKnob (&envPanels[(size_t) i], "Decay", idsD[i], formatSeconds, "Decay time (0.5 ms - 12 s)"),
|
||||
makeKnob (&envPanels[(size_t) i], "Sustain", idsS[i], formatPercent, "Sustain level (0-100%)"),
|
||||
makeKnob (&envPanels[(size_t) i], "Release", idsR[i], formatSeconds, "Release time (0.5 ms - 12 s)"),
|
||||
makeKnob (&envPanels[(size_t) i], "Curve", idsC[i], formatPercent, "Envelope curve (0-100%)")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding,
|
||||
layout::titleHeight + layout::envDisplayHeight + layout::padding,
|
||||
layout::knobSmall, layout::smallKnobHeight, layout::smallGap);
|
||||
}
|
||||
|
||||
// LFOs (4 across).
|
||||
const int lfoTop = layout::margin + envH + layout::panelSpacing;
|
||||
for (int i = 0; i < kNumLfos; ++i)
|
||||
{
|
||||
lfoPanels[(size_t) i].setBounds (gridX (i, colW, colGap), lfoTop, colW, lfoH);
|
||||
modView.addAndMakeVisible (lfoPanels[(size_t) i]);
|
||||
|
||||
const char* rate[4] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate };
|
||||
const char* sync[4] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync };
|
||||
const char* beat[4] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat };
|
||||
const char* shape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
|
||||
const char* phase[4] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase };
|
||||
const char* fade[4] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade };
|
||||
const char* delay[4] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay };
|
||||
|
||||
makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "LFO shape")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight);
|
||||
makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Tempo-sync LFO")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
|
||||
lfoPanels[(size_t) i].addAndMakeVisible (lfoDisplays[(size_t) i]);
|
||||
lfoDisplays[(size_t) i].setBounds (layout::padding, bodyTop(), colW - 2 * layout::padding, layout::lfoDisplayHeight);
|
||||
|
||||
std::vector<Knob*> knobs = {
|
||||
makeKnob (&lfoPanels[(size_t) i], "Rate", rate[i], formatPercent, "LFO rate (0.02 Hz - 30 Hz)"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], formatPercent, "Beat division (1/32 - 4 bars)"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], formatPercent, "Phase (0-100%)"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], formatPercent, "Fade in (0-100%)"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], formatPercent, "Start delay (0-100%)")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding,
|
||||
bodyTop() + layout::lfoDisplayHeight + layout::padding,
|
||||
layout::knobSmall, layout::smallKnobHeight, layout::smallGap);
|
||||
|
||||
lfoDisplays[(size_t) i].setOnShapeEdited ([this, i] (const std::vector<float>& data, int steps)
|
||||
{
|
||||
processor.engine.setLfoShapeData (i, data, steps);
|
||||
});
|
||||
}
|
||||
|
||||
// Modulation matrix.
|
||||
const int matrixTop = lfoTop + lfoH + layout::panelSpacing;
|
||||
const int matrixH = kBaseH - 60 - matrixTop - layout::margin;
|
||||
matrixPanel.setBounds (layout::margin, matrixTop, kBaseW - 2 * layout::margin, matrixH);
|
||||
modView.addAndMakeVisible (matrixPanel);
|
||||
|
||||
std::vector<ModTarget> targetEnums;
|
||||
const juce::StringArray targetItems = modTargetItems (targetEnums);
|
||||
|
||||
const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob
|
||||
|
||||
modSourceCombo.addItemList (modSourceItems(), 1);
|
||||
modSourceCombo.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
modSourceCombo.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 160, layout::comboHeight);
|
||||
modSourceCombo.setTooltip ("Modulation source");
|
||||
matrixPanel.addAndMakeVisible (modSourceCombo);
|
||||
|
||||
modTargetCombo.addItemList (targetItems, 1);
|
||||
modTargetCombo.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
modTargetCombo.setBounds (layout::padding + 160 + layout::gap, mCenter - layout::comboHeight / 2, 200, layout::comboHeight);
|
||||
modTargetCombo.setTooltip ("Modulation destination");
|
||||
matrixPanel.addAndMakeVisible (modTargetCombo);
|
||||
|
||||
const int depthX = layout::padding + 160 + layout::gap + 200 + layout::gap;
|
||||
modDepthKnob.setRange (-1.0, 1.0);
|
||||
modDepthKnob.setValue (0.5, juce::dontSendNotification);
|
||||
modDepthKnob.setFormatter (fmtDepth);
|
||||
modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60);
|
||||
modDepthKnob.setTooltip ("Modulation depth (-1 to +1)");
|
||||
matrixPanel.addAndMakeVisible (modDepthKnob);
|
||||
|
||||
modBipolarToggle.setLabel ("Bipolar");
|
||||
modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, 60, layout::toggleHeight);
|
||||
modBipolarToggle.setTooltip ("Bipolar modulation depth");
|
||||
matrixPanel.addAndMakeVisible (modBipolarToggle);
|
||||
modBipolarToggle.setToggleState (true);
|
||||
|
||||
const int addX = depthX + layout::knobSize + layout::gap + 60 + layout::gap;
|
||||
modAddButton.setButtonText ("Add");
|
||||
modAddButton.setBounds (addX, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
|
||||
modAddButton.setTooltip ("Add modulation connection");
|
||||
modAddButton.onClick = [this, targetEnums]
|
||||
{
|
||||
const ModSource src = (ModSource) modSourceCombo.getSelectedItemIndex();
|
||||
const int tid = modTargetCombo.getSelectedItemIndex();
|
||||
if (tid >= 0 && tid < (int) targetEnums.size())
|
||||
{
|
||||
processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
|
||||
(float) modDepthKnob.getValue(),
|
||||
modBipolarToggle.getToggleState());
|
||||
updateModList();
|
||||
}
|
||||
};
|
||||
matrixPanel.addAndMakeVisible (modAddButton);
|
||||
|
||||
modRemoveButton.setButtonText ("Remove Last");
|
||||
modRemoveButton.setBounds (addX + 70 + layout::gap, mCenter - layout::buttonHeight / 2, 110, layout::buttonHeight);
|
||||
modRemoveButton.setTooltip ("Remove last modulation connection");
|
||||
modRemoveButton.onClick = [this]
|
||||
{
|
||||
processor.engine.getMatrix().removeConnection (processor.engine.getMatrix().size() - 1);
|
||||
updateModList();
|
||||
};
|
||||
matrixPanel.addAndMakeVisible (modRemoveButton);
|
||||
|
||||
modClearButton.setButtonText ("Clear");
|
||||
modClearButton.setBounds (addX + 70 + layout::gap + 110 + layout::gap, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
|
||||
modClearButton.setTooltip ("Clear all modulation connections");
|
||||
modClearButton.onClick = [this]
|
||||
{
|
||||
processor.engine.getMatrix().clear();
|
||||
updateModList();
|
||||
};
|
||||
matrixPanel.addAndMakeVisible (modClearButton);
|
||||
|
||||
const int listTop = layout::titleHeight + 60 + layout::padding;
|
||||
modList.setBounds (layout::padding, listTop,
|
||||
kBaseW - 2 * layout::margin - 2 * layout::padding,
|
||||
matrixH - listTop - layout::padding);
|
||||
modList.setReadOnly (true);
|
||||
modList.setMultiLine (true, false);
|
||||
modList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
|
||||
modList.setColour (juce::TextEditor::textColourId, theme::text);
|
||||
modList.setColour (juce::TextEditor::outlineColourId, juce::Colours::transparentBlack);
|
||||
matrixPanel.addAndMakeVisible (modList);
|
||||
}
|
||||
|
||||
void PluginEditor::buildFxTab()
|
||||
{
|
||||
fxView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
root.addChildComponent (fxView);
|
||||
|
||||
const juce::StringArray fxTypes = { "Off", "Hyper", "Chorus", "Flanger", "Phaser",
|
||||
"Distortion", "EQ", "Compressor", "Delay", "Reverb" };
|
||||
|
||||
constexpr int colW = 545;
|
||||
constexpr int slotH = 150;
|
||||
|
||||
for (int i = 0; i < kNumFxSlots; ++i)
|
||||
{
|
||||
const int col = i % 2;
|
||||
const int row = i / 2;
|
||||
const int x = gridX (col, colW, layout::panelSpacing);
|
||||
const int y = layout::margin + row * (slotH + layout::panelSpacing);
|
||||
|
||||
fxPanels[(size_t) i].setBounds (x, y, colW, slotH);
|
||||
fxView.addAndMakeVisible (fxPanels[(size_t) i]);
|
||||
|
||||
fxTypeCombos[(size_t) i] = makeCombo (&fxPanels[(size_t) i], kFxType[i], fxTypes,
|
||||
"FX slot " + juce::String (i + 1) + " effect type");
|
||||
fxTypeCombos[(size_t) i]->setBounds (layout::padding, comboRowY(), 160, layout::comboHeight);
|
||||
|
||||
const int upX = layout::padding + 160 + layout::gap;
|
||||
fxUp[(size_t) i] = new juce::TextButton ("\xe2\x86\x91");
|
||||
fxUp[(size_t) i]->setBounds (upX, comboRowY(), layout::arrowWidth, layout::comboHeight);
|
||||
fxUp[(size_t) i]->setTooltip ("Move effect up");
|
||||
fxPanels[(size_t) i].addAndMakeVisible (fxUp[(size_t) i]);
|
||||
fxDown[(size_t) i] = new juce::TextButton ("\xe2\x86\x93");
|
||||
fxDown[(size_t) i]->setBounds (upX + layout::arrowWidth + layout::gap, comboRowY(), layout::arrowWidth, layout::comboHeight);
|
||||
fxDown[(size_t) i]->setTooltip ("Move effect down");
|
||||
fxPanels[(size_t) i].addAndMakeVisible (fxDown[(size_t) i]);
|
||||
|
||||
fxUp[(size_t) i]->onClick = [this, i] { if (i > 0) swapFxSlots (i, i - 1); };
|
||||
fxDown[(size_t) i]->onClick = [this, i] { if (i < kNumFxSlots - 1) swapFxSlots (i, i + 1); };
|
||||
|
||||
std::vector<Knob*> knobs = {
|
||||
makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Effect mix (0-100%)"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P1", kFxP1[i], formatPercent, "Effect parameter 1 (0-100%)"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P2", kFxP2[i], formatPercent, "Effect parameter 2 (0-100%)"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P3", kFxP3[i], formatPercent, "Effect parameter 3 (0-100%)"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P4", kFxP4[i], formatPercent, "Effect parameter 4 (0-100%)")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding, bodyTop(), layout::knobSize, layout::knobSize);
|
||||
fxKnobs[(size_t) i] = knobs;
|
||||
}
|
||||
}
|
||||
|
||||
void PluginEditor::buildMacroTab()
|
||||
{
|
||||
macroView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
root.addChildComponent (macroView);
|
||||
|
||||
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
|
||||
constexpr int colGap = 8;
|
||||
constexpr int macroH = 158;
|
||||
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
{
|
||||
macroPanels[(size_t) i].setBounds (gridX (i, colW, colGap), layout::margin, colW, macroH);
|
||||
macroView.addAndMakeVisible (macroPanels[(size_t) i]);
|
||||
|
||||
const char* ids[4] = { ids::macro1, ids::macro2, ids::macro3, ids::macro4 };
|
||||
macroKnobs[(size_t) i] = makeKnob (¯oPanels[(size_t) i], MacroControls::macroName (i), ids[i], formatPercent,
|
||||
MacroControls::macroName (i) + " macro (0-100%)");
|
||||
macroKnobs[(size_t) i]->setBounds ((colW - layout::macroKnobSize) / 2,
|
||||
layout::titleHeight + layout::padding,
|
||||
layout::macroKnobSize, layout::macroKnobSize);
|
||||
}
|
||||
|
||||
// Macro assignment editor.
|
||||
const int assignTop = layout::margin + macroH + layout::panelSpacing;
|
||||
const int assignH = kBaseH - 60 - assignTop - layout::margin;
|
||||
macroAssignPanel.setBounds (layout::margin, assignTop, kBaseW - 2 * layout::margin, assignH);
|
||||
macroView.addAndMakeVisible (macroAssignPanel);
|
||||
|
||||
std::vector<ModTarget> targetEnums;
|
||||
const juce::StringArray targetItems = modTargetItems (targetEnums);
|
||||
|
||||
const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob
|
||||
|
||||
macroAssignIndex.addItemList ({ "Macro 1", "Macro 2", "Macro 3", "Macro 4" }, 1);
|
||||
macroAssignIndex.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
macroAssignIndex.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 140, layout::comboHeight);
|
||||
macroAssignIndex.setTooltip ("Macro to assign");
|
||||
macroAssignPanel.addAndMakeVisible (macroAssignIndex);
|
||||
|
||||
macroAssignTarget.addItemList (targetItems, 1);
|
||||
macroAssignTarget.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
macroAssignTarget.setBounds (layout::padding + 140 + layout::gap, mCenter - layout::comboHeight / 2, 220, layout::comboHeight);
|
||||
macroAssignTarget.setTooltip ("Destination parameter");
|
||||
macroAssignPanel.addAndMakeVisible (macroAssignTarget);
|
||||
|
||||
const int depthX = layout::padding + 140 + layout::gap + 220 + layout::gap;
|
||||
macroDepthKnob.setFormatter (fmtDepth);
|
||||
macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60);
|
||||
macroDepthKnob.setTooltip ("Macro assignment depth (0 to 1)");
|
||||
macroAssignPanel.addAndMakeVisible (macroDepthKnob);
|
||||
|
||||
const int assignBtnX = depthX + layout::knobSize + layout::gap;
|
||||
macroAssignButton.setButtonText ("Assign");
|
||||
macroAssignButton.setBounds (assignBtnX, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight);
|
||||
macroAssignButton.setTooltip ("Assign destination to the selected macro");
|
||||
macroAssignButton.onClick = [this, targetEnums]
|
||||
{
|
||||
const int macro = macroAssignIndex.getSelectedItemIndex();
|
||||
const int tid = macroAssignTarget.getSelectedItemIndex();
|
||||
if (tid >= 0 && tid < (int) targetEnums.size())
|
||||
{
|
||||
processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
|
||||
(float) macroDepthKnob.getValue());
|
||||
updateMacroList();
|
||||
}
|
||||
};
|
||||
macroAssignPanel.addAndMakeVisible (macroAssignButton);
|
||||
|
||||
macroClearButton.setButtonText ("Clear All");
|
||||
macroClearButton.setBounds (assignBtnX + 80 + layout::gap, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight);
|
||||
macroClearButton.setTooltip ("Clear all macro assignments");
|
||||
macroClearButton.onClick = [this]
|
||||
{
|
||||
processor.engine.getMacros().clear();
|
||||
updateMacroList();
|
||||
};
|
||||
macroAssignPanel.addAndMakeVisible (macroClearButton);
|
||||
|
||||
const int listTop = layout::titleHeight + 60 + layout::padding;
|
||||
macroList.setBounds (layout::padding, listTop,
|
||||
kBaseW - 2 * layout::margin - 2 * layout::padding,
|
||||
assignH - listTop - layout::padding);
|
||||
macroList.setReadOnly (true);
|
||||
macroList.setMultiLine (true, false);
|
||||
macroList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
|
||||
macroList.setColour (juce::TextEditor::textColourId, theme::text);
|
||||
macroList.setColour (juce::TextEditor::outlineColourId, juce::Colours::transparentBlack);
|
||||
macroAssignPanel.addAndMakeVisible (macroList);
|
||||
}
|
||||
|
||||
void PluginEditor::swapFxSlots (int a, int b)
|
||||
{
|
||||
auto swapParam = [this] (const char* pa, const char* pb)
|
||||
{
|
||||
auto* p1 = processor.parameters.getParameter (pa);
|
||||
auto* p2 = processor.parameters.getParameter (pb);
|
||||
const float v1 = p1->getValue();
|
||||
const float v2 = p2->getValue();
|
||||
p1->setValueNotifyingHost (v2);
|
||||
p2->setValueNotifyingHost (v1);
|
||||
};
|
||||
|
||||
swapParam (kFxType[a], kFxType[b]);
|
||||
swapParam (kFxMix[a], kFxMix[b]);
|
||||
swapParam (kFxP1[a], kFxP1[b]);
|
||||
swapParam (kFxP2[a], kFxP2[b]);
|
||||
swapParam (kFxP3[a], kFxP3[b]);
|
||||
swapParam (kFxP4[a], kFxP4[b]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void PluginEditor::setTab (int index)
|
||||
{
|
||||
currentTab = juce::jlimit (0, 4, index);
|
||||
oscView.setVisible (currentTab == 0);
|
||||
filterView.setVisible(currentTab == 1);
|
||||
modView.setVisible (currentTab == 2);
|
||||
fxView.setVisible (currentTab == 3);
|
||||
macroView.setVisible (currentTab == 4);
|
||||
|
||||
tabOsc.setToggleState (currentTab == 0, juce::dontSendNotification);
|
||||
tabFilter.setToggleState(currentTab == 1, juce::dontSendNotification);
|
||||
tabMod.setToggleState (currentTab == 2, juce::dontSendNotification);
|
||||
tabFx.setToggleState (currentTab == 3, juce::dontSendNotification);
|
||||
tabMacro.setToggleState (currentTab == 4, juce::dontSendNotification);
|
||||
}
|
||||
|
||||
void PluginEditor::cycleTab (int delta)
|
||||
{
|
||||
setTab ((currentTab + delta + 5) % 5);
|
||||
}
|
||||
|
||||
bool PluginEditor::keyPressed (const juce::KeyPress& key)
|
||||
{
|
||||
// Only handle plain Tab / Shift+Tab (never Ctrl/Cmd/Alt+Tab).
|
||||
if (key.getKeyCode() == juce::KeyPress::tabKey
|
||||
&& ! key.getModifiers().isCtrlDown()
|
||||
&& ! key.getModifiers().isAltDown()
|
||||
&& ! key.getModifiers().isCommandDown())
|
||||
{
|
||||
// Leave Tab alone while a text editor or combo box is being edited so we
|
||||
// don't break typing / focus traversal.
|
||||
for (auto* c = juce::Component::getCurrentlyFocusedComponent(); c != nullptr; c = c->getParentComponent())
|
||||
{
|
||||
if (dynamic_cast<juce::TextEditor*> (c) != nullptr
|
||||
|| dynamic_cast<juce::ComboBox*> (c) != nullptr)
|
||||
return false;
|
||||
}
|
||||
|
||||
cycleTab (key.getModifiers().isShiftDown() ? -1 : +1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void PluginEditor::applyUiScale()
|
||||
{
|
||||
const float scale = processor.getUiScale();
|
||||
currentScaleIndex = processor.getUiScaleIndex();
|
||||
root.setTransform (juce::AffineTransform::scale (scale));
|
||||
setSize ((int) (kBaseW * scale), (int) (kBaseH * scale));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void PluginEditor::updateVisuals()
|
||||
{
|
||||
const auto& apvts = processor.parameters;
|
||||
auto gv = [&] (const char* id) { return apvts.getRawParameterValue (id)->load(); };
|
||||
|
||||
masterDisplay.setValue (formatPercent (gv (ids::master)));
|
||||
|
||||
// Waveforms.
|
||||
const int waveA = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (gv (ids::oscAWave) * (kNumWavetables - 1)));
|
||||
const int waveB = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (gv (ids::oscBWave) * (kNumWavetables - 1)));
|
||||
oscAWave.setWaveIndex (waveA);
|
||||
oscAWave.setFramePosition (gv (ids::oscAWtPos));
|
||||
oscAWave.setEnabled (gv (ids::oscAOn) > 0.5f);
|
||||
oscBWave.setWaveIndex (waveB);
|
||||
oscBWave.setFramePosition (gv (ids::oscBWtPos));
|
||||
oscBWave.setEnabled (gv (ids::oscBOn) > 0.5f);
|
||||
oscAWave.repaint();
|
||||
oscBWave.repaint();
|
||||
|
||||
// Filters.
|
||||
filter1Display.setParams ((int) std::llround (gv (ids::f1Type) * 6.0f), gv (ids::f1Cutoff), gv (ids::f1Res),
|
||||
gv (ids::f1Drive), (int) std::llround (gv (ids::f1Slope) * 2.0f));
|
||||
filter1Display.setEnabled (gv (ids::f1On) > 0.5f);
|
||||
filter2Display.setParams ((int) std::llround (gv (ids::f2Type) * 6.0f), gv (ids::f2Cutoff), gv (ids::f2Res),
|
||||
gv (ids::f2Drive), (int) std::llround (gv (ids::f2Slope) * 2.0f));
|
||||
filter2Display.setEnabled (gv (ids::f2On) > 0.5f);
|
||||
filter1Display.repaint();
|
||||
filter2Display.repaint();
|
||||
|
||||
// Envelopes.
|
||||
const char* a[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
|
||||
const char* d[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
|
||||
const char* s[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
|
||||
const char* r[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
|
||||
const char* c[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
|
||||
for (int i = 0; i < kNumEnvelopes; ++i)
|
||||
{
|
||||
envDisplays[(size_t) i].setParams (gv (a[i]), gv (d[i]), gv (s[i]), gv (r[i]), gv (c[i]));
|
||||
envDisplays[(size_t) i].repaint();
|
||||
}
|
||||
|
||||
// LFOs.
|
||||
const char* lshape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
|
||||
for (int i = 0; i < kNumLfos; ++i)
|
||||
{
|
||||
lfoDisplays[(size_t) i].setShape ((int) std::llround (gv (lshape[i]) * 6.0f));
|
||||
lfoDisplays[(size_t) i].setShapeData (processor.engine.getLfos()[(size_t) i].getShapeData(),
|
||||
processor.engine.getLfos()[(size_t) i].getShapeSteps());
|
||||
lfoDisplays[(size_t) i].repaint();
|
||||
}
|
||||
|
||||
// RAVE state.
|
||||
raveButton.setToggleState (processor.isRaveEnabled());
|
||||
|
||||
// Scale change.
|
||||
if (processor.getUiScaleIndex() != currentScaleIndex)
|
||||
applyUiScale();
|
||||
}
|
||||
|
||||
void PluginEditor::updateModList()
|
||||
{
|
||||
juce::String text;
|
||||
const auto& cons = processor.engine.getMatrix().connections;
|
||||
for (const auto& c : cons)
|
||||
text += modSourceName (c.source) + " -> " + modTargetName (c.target)
|
||||
+ " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n";
|
||||
modList.setText (text, false);
|
||||
}
|
||||
|
||||
void PluginEditor::updateMacroList()
|
||||
{
|
||||
juce::String text;
|
||||
for (int m = 0; m < kNumMacros; ++m)
|
||||
{
|
||||
text += MacroControls::macroName (m) + ":\n";
|
||||
const auto& assigns = processor.engine.getMacros().assignments[(size_t) m];
|
||||
if (assigns.empty())
|
||||
text += " (none)\n";
|
||||
for (const auto& a : assigns)
|
||||
text += " " + modTargetName (a.target) + " [" + juce::String (a.depth, 2) + "]\n";
|
||||
}
|
||||
macroList.setText (text, false);
|
||||
}
|
||||
|
||||
void PluginEditor::timerCallback()
|
||||
{
|
||||
updateVisuals();
|
||||
updateModList();
|
||||
updateMacroList();
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,132 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "PluginProcessor.h"
|
||||
#include "GUI/SerumLookAndFeel.h"
|
||||
#include "GUI/Knob.h"
|
||||
#include "GUI/Slider.h"
|
||||
#include "GUI/ToggleButton.h"
|
||||
#include "GUI/Display.h"
|
||||
#include "GUI/Panel.h"
|
||||
#include "GUI/WaveformDisplay.h"
|
||||
#include "GUI/FilterDisplay.h"
|
||||
#include "GUI/EnvelopeDisplay.h"
|
||||
#include "GUI/LFODisplay.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// SerumAlt editor: dark vector GUI with a tabbed layout, real-time
|
||||
// visualisations, RAVE, macro controls and preset-scale (75%..200%) scaling.
|
||||
// ===========================================================================
|
||||
class PluginEditor : public juce::AudioProcessorEditor,
|
||||
public juce::Timer
|
||||
{
|
||||
public:
|
||||
explicit PluginEditor (SerumAltAudioProcessor& p);
|
||||
~PluginEditor() override;
|
||||
|
||||
void paint (juce::Graphics&) override;
|
||||
void resized() override;
|
||||
void timerCallback() override;
|
||||
bool keyPressed (const juce::KeyPress& key) override;
|
||||
|
||||
void setTab (int index);
|
||||
|
||||
private:
|
||||
SerumAltAudioProcessor& processor;
|
||||
SerumLookAndFeel laf;
|
||||
|
||||
static constexpr int kBaseW = 1120;
|
||||
static constexpr int kBaseH = 740;
|
||||
|
||||
juce::Component root;
|
||||
|
||||
// --- tooltips ---
|
||||
juce::TooltipWindow tooltipWindow;
|
||||
|
||||
// --- top bar ---
|
||||
std::unique_ptr<juce::Drawable> logo;
|
||||
juce::ComboBox presetCombo;
|
||||
juce::ComboBox scaleCombo;
|
||||
ToggleButton raveButton;
|
||||
Knob masterKnob;
|
||||
Display masterDisplay;
|
||||
|
||||
// --- tabs ---
|
||||
juce::TextButton tabOsc, tabFilter, tabMod, tabFx, tabMacro;
|
||||
juce::Component oscView, filterView, modView, fxView, macroView;
|
||||
int currentTab = 0;
|
||||
|
||||
// --- OSC ---
|
||||
Panel oscAPanel { "Oscillator A" }, oscBPanel { "Oscillator B" };
|
||||
Panel subPanel { "Sub" }, noisePanel { "Noise" };
|
||||
WaveformDisplay oscAWave, oscBWave;
|
||||
std::vector<juce::Component*> oscAComponents, oscBComponents;
|
||||
|
||||
// --- FILTER ---
|
||||
Panel filter1Panel { "Filter 1" }, filter2Panel { "Filter 2" }, routingPanel { "Routing" };
|
||||
FilterDisplay filter1Display, filter2Display;
|
||||
|
||||
// --- MOD ---
|
||||
std::array<Panel, kNumEnvelopes> envPanels { { Panel ("Env 1 (Amp)"), Panel ("Env 2 (Filter)"),
|
||||
Panel ("Env 3"), Panel ("Env 4") } };
|
||||
std::array<EnvelopeDisplay, kNumEnvelopes> envDisplays;
|
||||
std::array<Panel, kNumLfos> lfoPanels { { Panel ("LFO 1"), Panel ("LFO 2"), Panel ("LFO 3"), Panel ("LFO 4") } };
|
||||
std::array<LFODisplay, kNumLfos> lfoDisplays;
|
||||
|
||||
Panel matrixPanel { "Modulation Matrix" };
|
||||
juce::ComboBox modSourceCombo, modTargetCombo;
|
||||
Knob modDepthKnob { "Depth" };
|
||||
ToggleButton modBipolarToggle;
|
||||
juce::TextButton modAddButton, modRemoveButton, modClearButton;
|
||||
juce::TextEditor modList;
|
||||
|
||||
// --- FX ---
|
||||
std::array<Panel, kNumFxSlots> fxPanels;
|
||||
std::array<juce::ComboBox*, kNumFxSlots> fxTypeCombos;
|
||||
std::array<std::vector<Knob*>, kNumFxSlots> fxKnobs;
|
||||
std::array<juce::TextButton*, kNumFxSlots> fxUp, fxDown;
|
||||
|
||||
// --- MACRO ---
|
||||
std::array<Panel, kNumMacros> macroPanels;
|
||||
std::array<Knob*, kNumMacros> macroKnobs;
|
||||
Panel macroAssignPanel { "Macro Assignments" };
|
||||
juce::ComboBox macroAssignTarget;
|
||||
juce::ComboBox macroAssignIndex;
|
||||
Knob macroDepthKnob { "Depth" };
|
||||
juce::TextButton macroAssignButton, macroClearButton;
|
||||
juce::TextEditor macroList;
|
||||
|
||||
// --- attachments ---
|
||||
std::vector<std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment>> sliderAttachments;
|
||||
std::vector<std::unique_ptr<juce::AudioProcessorValueTreeState::ComboBoxAttachment>> comboAttachments;
|
||||
|
||||
int currentScaleIndex = -1;
|
||||
|
||||
// --- helpers ---
|
||||
Knob* makeKnob (juce::Component* parent, const juce::String& name, const juce::String& paramId,
|
||||
std::function<juce::String (float)> fmt = {}, const juce::String& tooltip = {});
|
||||
juce::ComboBox* makeCombo (juce::Component* parent, const juce::String& paramId, const juce::StringArray& items,
|
||||
const juce::String& tooltip = {});
|
||||
ToggleButton* makeToggle (juce::Component* parent, const juce::String& label, const juce::String& paramId,
|
||||
const juce::String& tooltip = {});
|
||||
void cycleTab (int delta);
|
||||
|
||||
void buildTopBar();
|
||||
void buildOscTab();
|
||||
void buildFilterTab();
|
||||
void buildModTab();
|
||||
void buildFxTab();
|
||||
void buildMacroTab();
|
||||
void swapFxSlots (int a, int b);
|
||||
void applyUiScale();
|
||||
void updateVisuals();
|
||||
void updateModList();
|
||||
void updateMacroList();
|
||||
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PluginEditor)
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,381 @@
|
||||
#include "PluginProcessor.h"
|
||||
#include "PluginEditor.h"
|
||||
#include "Presets/FactoryPresets.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
std::unique_ptr<juce::AudioParameterFloat> f (const char* id, const juce::String& name, float def)
|
||||
{
|
||||
return std::make_unique<juce::AudioParameterFloat> (juce::ParameterID { id, 1 }, name,
|
||||
juce::NormalisableRange<float> (0.0f, 1.0f), def);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
SerumAltAudioProcessor::SerumAltAudioProcessor()
|
||||
: AudioProcessor (BusesProperties().withInput ("Input", juce::AudioChannelSet::stereo(), false)
|
||||
.withOutput ("Output", juce::AudioChannelSet::stereo(), true)),
|
||||
parameters (*this, nullptr, juce::Identifier ("SerumAlt"), createParameterLayout())
|
||||
{
|
||||
}
|
||||
|
||||
SerumAltAudioProcessor::~SerumAltAudioProcessor() = default;
|
||||
|
||||
juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::createParameterLayout()
|
||||
{
|
||||
std::vector<std::unique_ptr<juce::RangedAudioParameter>> params;
|
||||
|
||||
// Global
|
||||
params.push_back (f (ids::master, "Master", 0.8f));
|
||||
params.push_back (f (ids::uiScale, "UI Scale", 0.25f));
|
||||
params.push_back (f (ids::rave, "RAVE", 0.0f));
|
||||
|
||||
// Oscillator A
|
||||
params.push_back (f (ids::oscAOn, "Osc A On", 1.0f));
|
||||
params.push_back (f (ids::oscAWave, "Osc A Wave", 0.0f));
|
||||
params.push_back (f (ids::oscAWtPos, "Osc A WT Pos", 0.0f));
|
||||
params.push_back (f (ids::oscAWarp, "Osc A Warp", 0.0f));
|
||||
params.push_back (f (ids::oscAWarpAmt, "Osc A Warp Amt", 0.0f));
|
||||
params.push_back (f (ids::oscACoarse, "Osc A Coarse", 0.5f));
|
||||
params.push_back (f (ids::oscAFine, "Osc A Fine", 0.5f));
|
||||
params.push_back (f (ids::oscALevel, "Osc A Level", 0.8f));
|
||||
params.push_back (f (ids::oscAPan, "Osc A Pan", 0.5f));
|
||||
params.push_back (f (ids::oscAUnison, "Osc A Unison", 0.0f));
|
||||
params.push_back (f (ids::oscADetune, "Osc A Detune", 0.0f));
|
||||
params.push_back (f (ids::oscASpread, "Osc A Spread", 0.0f));
|
||||
params.push_back (f (ids::oscAPhase, "Osc A Phase", 0.0f));
|
||||
params.push_back (f (ids::oscARandPh, "Osc A Rand Phase", 0.0f));
|
||||
|
||||
// Oscillator B
|
||||
params.push_back (f (ids::oscBOn, "Osc B On", 0.0f));
|
||||
params.push_back (f (ids::oscBWave, "Osc B Wave", 1.0f / 9.0f));
|
||||
params.push_back (f (ids::oscBWtPos, "Osc B WT Pos", 0.0f));
|
||||
params.push_back (f (ids::oscBWarp, "Osc B Warp", 0.0f));
|
||||
params.push_back (f (ids::oscBWarpAmt, "Osc B Warp Amt", 0.0f));
|
||||
params.push_back (f (ids::oscBCoarse, "Osc B Coarse", 0.5f));
|
||||
params.push_back (f (ids::oscBFine, "Osc B Fine", 0.5f));
|
||||
params.push_back (f (ids::oscBLevel, "Osc B Level", 0.5f));
|
||||
params.push_back (f (ids::oscBPan, "Osc B Pan", 0.5f));
|
||||
params.push_back (f (ids::oscBUnison, "Osc B Unison", 0.0f));
|
||||
params.push_back (f (ids::oscBDetune, "Osc B Detune", 0.0f));
|
||||
params.push_back (f (ids::oscBSpread, "Osc B Spread", 0.0f));
|
||||
params.push_back (f (ids::oscBPhase, "Osc B Phase", 0.0f));
|
||||
params.push_back (f (ids::oscBRandPh, "Osc B Rand Phase", 0.0f));
|
||||
|
||||
// Sub
|
||||
params.push_back (f (ids::subOn, "Sub On", 0.0f));
|
||||
params.push_back (f (ids::subShape, "Sub Shape", 0.0f));
|
||||
params.push_back (f (ids::subOct, "Sub Octave", 1.0f / 2.0f));
|
||||
params.push_back (f (ids::subLevel, "Sub Level", 0.5f));
|
||||
|
||||
// Noise
|
||||
params.push_back (f (ids::noiseOn, "Noise On", 0.0f));
|
||||
params.push_back (f (ids::noiseType, "Noise Type", 0.0f));
|
||||
params.push_back (f (ids::noiseLevel, "Noise Level", 0.5f));
|
||||
|
||||
// Filter 1
|
||||
params.push_back (f (ids::f1On, "Filter 1 On", 1.0f));
|
||||
params.push_back (f (ids::f1Type, "Filter 1 Type", 0.0f));
|
||||
params.push_back (f (ids::f1Cutoff, "Filter 1 Cutoff", 0.65f));
|
||||
params.push_back (f (ids::f1Res, "Filter 1 Res", 0.05f));
|
||||
params.push_back (f (ids::f1Drive, "Filter 1 Drive", 0.0f));
|
||||
params.push_back (f (ids::f1Key, "Filter 1 Keytrack", 0.0f));
|
||||
params.push_back (f (ids::f1Slope, "Filter 1 Slope", 1.0f));
|
||||
|
||||
// Filter 2
|
||||
params.push_back (f (ids::f2On, "Filter 2 On", 0.0f));
|
||||
params.push_back (f (ids::f2Type, "Filter 2 Type", 0.0f));
|
||||
params.push_back (f (ids::f2Cutoff, "Filter 2 Cutoff", 0.5f));
|
||||
params.push_back (f (ids::f2Res, "Filter 2 Res", 0.0f));
|
||||
params.push_back (f (ids::f2Drive, "Filter 2 Drive", 0.0f));
|
||||
params.push_back (f (ids::f2Key, "Filter 2 Keytrack", 0.0f));
|
||||
params.push_back (f (ids::f2Slope, "Filter 2 Slope", 1.0f));
|
||||
|
||||
params.push_back (f (ids::fRoute, "Filter Route", 0.0f));
|
||||
params.push_back (f (ids::fMix, "Filter Mix", 0.5f));
|
||||
params.push_back (f (ids::fOut, "Filter Out", 0.667f));
|
||||
|
||||
// Envelopes 1..4
|
||||
const char* envA[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
|
||||
const char* envD[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
|
||||
const char* envS[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
|
||||
const char* envR[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
|
||||
const char* envC[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
|
||||
const float envADef[4] = { 0.05f, 0.1f, 0.2f, 0.2f };
|
||||
const float envDDef[4] = { 0.25f, 0.3f, 0.3f, 0.3f };
|
||||
const float envSDef[4] = { 0.8f, 0.5f, 0.5f, 0.5f };
|
||||
const float envRDef[4] = { 0.3f, 0.3f, 0.4f, 0.4f };
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
params.push_back (f (envA[i], juce::String ("Env ") + juce::String (i + 1) + " Attack", envADef[i]));
|
||||
params.push_back (f (envD[i], juce::String ("Env ") + juce::String (i + 1) + " Decay", envDDef[i]));
|
||||
params.push_back (f (envS[i], juce::String ("Env ") + juce::String (i + 1) + " Sustain", envSDef[i]));
|
||||
params.push_back (f (envR[i], juce::String ("Env ") + juce::String (i + 1) + " Release", envRDef[i]));
|
||||
params.push_back (f (envC[i], juce::String ("Env ") + juce::String (i + 1) + " Curve", 0.5f));
|
||||
}
|
||||
|
||||
// LFOs 1..4
|
||||
const char* lfoRate[4] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate };
|
||||
const char* lfoSync[4] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync };
|
||||
const char* lfoBeat[4] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat };
|
||||
const char* lfoShape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
|
||||
const char* lfoPhase[4] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase };
|
||||
const char* lfoFade[4] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade };
|
||||
const char* lfoDelay[4] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay };
|
||||
const float lfoShapeDef[4] = { 0.0f, 1.0f / 6.0f, 2.0f / 6.0f, 3.0f / 6.0f };
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
const juce::String n = juce::String ("LFO ") + juce::String (i + 1);
|
||||
params.push_back (f (lfoRate[i], n + " Rate", 0.5f));
|
||||
params.push_back (f (lfoSync[i], n + " Sync", 0.0f));
|
||||
params.push_back (f (lfoBeat[i], n + " Beat", 0.5f));
|
||||
params.push_back (f (lfoShape[i], n + " Shape", lfoShapeDef[i]));
|
||||
params.push_back (f (lfoPhase[i], n + " Phase", 0.0f));
|
||||
params.push_back (f (lfoFade[i], n + " Fade", 0.0f));
|
||||
params.push_back (f (lfoDelay[i], n + " Delay", 0.0f));
|
||||
}
|
||||
|
||||
// FX slots 1..8
|
||||
const char* fxType[8] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
|
||||
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
|
||||
const char* fxMix[8] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
|
||||
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
|
||||
const char* fxP[8][4] = {
|
||||
{ ids::fx1P1, ids::fx1P2, ids::fx1P3, ids::fx1P4 },
|
||||
{ ids::fx2P1, ids::fx2P2, ids::fx2P3, ids::fx2P4 },
|
||||
{ ids::fx3P1, ids::fx3P2, ids::fx3P3, ids::fx3P4 },
|
||||
{ ids::fx4P1, ids::fx4P2, ids::fx4P3, ids::fx4P4 },
|
||||
{ ids::fx5P1, ids::fx5P2, ids::fx5P3, ids::fx5P4 },
|
||||
{ ids::fx6P1, ids::fx6P2, ids::fx6P3, ids::fx6P4 },
|
||||
{ ids::fx7P1, ids::fx7P2, ids::fx7P3, ids::fx7P4 },
|
||||
{ ids::fx8P1, ids::fx8P2, ids::fx8P3, ids::fx8P4 }
|
||||
};
|
||||
for (int i = 0; i < 8; ++i)
|
||||
{
|
||||
const juce::String n = juce::String ("FX ") + juce::String (i + 1);
|
||||
params.push_back (f (fxType[i], n + " Type", 0.0f));
|
||||
params.push_back (f (fxMix[i], n + " Mix", 0.5f));
|
||||
params.push_back (f (fxP[i][0], n + " P1", 0.5f));
|
||||
params.push_back (f (fxP[i][1], n + " P2", 0.5f));
|
||||
params.push_back (f (fxP[i][2], n + " P3", 0.5f));
|
||||
params.push_back (f (fxP[i][3], n + " P4", 0.5f));
|
||||
}
|
||||
|
||||
// Macros
|
||||
params.push_back (f (ids::macro1, "Macro 1", 0.0f));
|
||||
params.push_back (f (ids::macro2, "Macro 2", 0.0f));
|
||||
params.push_back (f (ids::macro3, "Macro 3", 0.0f));
|
||||
params.push_back (f (ids::macro4, "Macro 4", 0.0f));
|
||||
|
||||
return { params.begin(), params.end() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void SerumAltAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
|
||||
{
|
||||
engine.prepare (sampleRate, samplesPerBlock);
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::releaseResources()
|
||||
{
|
||||
engine.reset();
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi)
|
||||
{
|
||||
juce::ScopedNoDenormals noDenormals;
|
||||
engine.processBlock (buffer, midi, parameters, getPlayHead());
|
||||
}
|
||||
|
||||
juce::AudioProcessorEditor* SerumAltAudioProcessor::createEditor()
|
||||
{
|
||||
return new PluginEditor (*this);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Programs / presets
|
||||
// ---------------------------------------------------------------------------
|
||||
int SerumAltAudioProcessor::getNumPrograms()
|
||||
{
|
||||
return (int) getFactoryPresets().size();
|
||||
}
|
||||
|
||||
int SerumAltAudioProcessor::getCurrentProgram()
|
||||
{
|
||||
return currentProgram;
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::setCurrentProgram (int index)
|
||||
{
|
||||
index = juce::jlimit (0, getNumPrograms() - 1, index);
|
||||
loadFactoryPreset (index);
|
||||
currentProgram = index;
|
||||
}
|
||||
|
||||
const juce::String SerumAltAudioProcessor::getProgramName (int index)
|
||||
{
|
||||
const auto& presets = getFactoryPresets();
|
||||
if (index >= 0 && index < (int) presets.size())
|
||||
return presets[(size_t) index].name;
|
||||
return {};
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::changeProgramName (int, const juce::String&)
|
||||
{
|
||||
}
|
||||
|
||||
int SerumAltAudioProcessor::getNumFactoryPresets() const
|
||||
{
|
||||
return (int) getFactoryPresets().size();
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::loadFactoryPreset (int index)
|
||||
{
|
||||
const auto& presets = getFactoryPresets();
|
||||
if (index < 0 || index >= (int) presets.size())
|
||||
return;
|
||||
|
||||
const FactoryPreset& preset = presets[(size_t) index];
|
||||
|
||||
// RAVE should start off for a freshly loaded preset.
|
||||
rave.resetSnapshot();
|
||||
if (auto* raveParam = parameters.getParameter (ids::rave))
|
||||
raveParam->setValueNotifyingHost (0.0f);
|
||||
|
||||
for (const auto& kv : preset.params)
|
||||
if (auto* param = parameters.getParameter (kv.first))
|
||||
param->setValueNotifyingHost (kv.second);
|
||||
|
||||
engine.getMatrix().clear();
|
||||
for (const auto& mod : preset.mods)
|
||||
engine.getMatrix().addConnection (mod.source, mod.target, mod.depth, mod.bipolar);
|
||||
|
||||
engine.getMacros().clear();
|
||||
for (const auto& ma : preset.macroAssigns)
|
||||
engine.getMacros().addAssignment (ma.macro, ma.target, ma.depth);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RAVE / UI scale
|
||||
// ---------------------------------------------------------------------------
|
||||
void SerumAltAudioProcessor::setRaveEnabled (bool enabled)
|
||||
{
|
||||
rave.setEnabled (enabled, parameters);
|
||||
if (auto* raveParam = parameters.getParameter (ids::rave))
|
||||
raveParam->setValueNotifyingHost (enabled ? 1.0f : 0.0f);
|
||||
}
|
||||
|
||||
bool SerumAltAudioProcessor::isRaveEnabled() const
|
||||
{
|
||||
return rave.isEnabled();
|
||||
}
|
||||
|
||||
int SerumAltAudioProcessor::getUiScaleIndex() const
|
||||
{
|
||||
if (auto* p = parameters.getRawParameterValue (ids::uiScale))
|
||||
return juce::jlimit (0, 4, (int) std::llround (p->load() * 4.0f));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::setUiScaleIndex (int index)
|
||||
{
|
||||
index = juce::jlimit (0, 4, index);
|
||||
if (auto* p = parameters.getParameter (ids::uiScale))
|
||||
p->setValueNotifyingHost ((float) index / 4.0f);
|
||||
}
|
||||
|
||||
float SerumAltAudioProcessor::getUiScale() const
|
||||
{
|
||||
static constexpr float scales[5] = { 0.75f, 1.0f, 1.25f, 1.5f, 2.0f };
|
||||
return scales[getUiScaleIndex()];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
void SerumAltAudioProcessor::getStateInformation (juce::MemoryBlock& destData)
|
||||
{
|
||||
auto state = parameters.copyState();
|
||||
state.appendChild (engine.getMatrix().toValueTree(), nullptr);
|
||||
state.appendChild (engine.getMacros().toValueTree(), nullptr);
|
||||
saveLfoShapesToState (state);
|
||||
|
||||
std::unique_ptr<juce::XmlElement> xml (state.createXml());
|
||||
if (xml != nullptr)
|
||||
copyXmlToBinary (*xml, destData);
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBytes)
|
||||
{
|
||||
std::unique_ptr<juce::XmlElement> xml (getXmlFromBinary (data, sizeInBytes));
|
||||
if (xml == nullptr)
|
||||
return;
|
||||
|
||||
juce::ValueTree state = juce::ValueTree::fromXml (*xml);
|
||||
if (! state.isValid())
|
||||
return;
|
||||
|
||||
parameters.replaceState (state);
|
||||
engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX"));
|
||||
engine.getMacros().fromValueTree (state.getChildWithName ("MACROS"));
|
||||
restoreLfoShapesFromState (state);
|
||||
|
||||
// A persisted RAVE toggle has no live snapshot, so start it off.
|
||||
rave.resetSnapshot();
|
||||
if (auto* raveParam = parameters.getParameter (ids::rave))
|
||||
raveParam->setValueNotifyingHost (0.0f);
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::saveLfoShapesToState (juce::ValueTree& state) const
|
||||
{
|
||||
juce::ValueTree tree ("LFOSHAPES");
|
||||
for (int i = 0; i < kNumLfos; ++i)
|
||||
{
|
||||
const auto& data = engine.getLfos()[(size_t) i].getShapeData();
|
||||
juce::ValueTree lfo ("LFO");
|
||||
lfo.setProperty ("index", i, nullptr);
|
||||
lfo.setProperty ("steps", engine.getLfos()[(size_t) i].getShapeSteps(), nullptr);
|
||||
|
||||
juce::Array<juce::var> arr;
|
||||
for (float vv : data)
|
||||
arr.add (vv);
|
||||
lfo.setProperty ("data", juce::var (arr), nullptr);
|
||||
tree.appendChild (lfo, nullptr);
|
||||
}
|
||||
state.appendChild (tree, nullptr);
|
||||
}
|
||||
|
||||
void SerumAltAudioProcessor::restoreLfoShapesFromState (const juce::ValueTree& state)
|
||||
{
|
||||
const juce::ValueTree tree = state.getChildWithName ("LFOSHAPES");
|
||||
if (! tree.isValid())
|
||||
return;
|
||||
|
||||
for (const auto& lfo : tree)
|
||||
{
|
||||
if (! lfo.hasType ("LFO"))
|
||||
continue;
|
||||
const int index = juce::jlimit (0, kNumLfos - 1, (int) lfo.getProperty ("index", 0));
|
||||
const int steps = (int) lfo.getProperty ("steps", 16);
|
||||
|
||||
std::vector<float> data;
|
||||
if (auto* arr = lfo.getProperty ("data").getArray())
|
||||
for (const auto& vv : *arr)
|
||||
data.push_back ((float) vv);
|
||||
|
||||
engine.setLfoShapeData (index, data, steps);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
|
||||
// ===========================================================================
|
||||
// Plugin entry point (required by the JUCE plugin clients).
|
||||
// ===========================================================================
|
||||
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()
|
||||
{
|
||||
return new serum::SerumAltAudioProcessor();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
#include "Engine.h"
|
||||
#include "RAVEButton.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// SerumAlt — a wavetable synthesiser (VST3 / AU). Hosts the APVTS, the audio
|
||||
// engine, preset management and the RAVE controller.
|
||||
// ===========================================================================
|
||||
class SerumAltAudioProcessor : public juce::AudioProcessor
|
||||
{
|
||||
public:
|
||||
SerumAltAudioProcessor();
|
||||
~SerumAltAudioProcessor() override;
|
||||
|
||||
// --- AudioProcessor ---
|
||||
void prepareToPlay (double sampleRate, int samplesPerBlock) override;
|
||||
void releaseResources() override;
|
||||
void processBlock (juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
|
||||
|
||||
juce::AudioProcessorEditor* createEditor() override;
|
||||
bool hasEditor() const override { return true; }
|
||||
|
||||
const juce::String getName() const override { return "SerumAlt"; }
|
||||
bool acceptsMidi() const override { return true; }
|
||||
bool producesMidi() const override { return false; }
|
||||
bool isMidiEffect() const override { return false; }
|
||||
double getTailLengthSeconds() const override { return 2.0; }
|
||||
|
||||
int getNumPrograms() override;
|
||||
int getCurrentProgram() override;
|
||||
void setCurrentProgram (int index) override;
|
||||
const juce::String getProgramName (int index) override;
|
||||
void changeProgramName (int index, const juce::String& newName) override;
|
||||
|
||||
void getStateInformation (juce::MemoryBlock& destData) override;
|
||||
void setStateInformation (const void* data, int sizeInBytes) override;
|
||||
|
||||
// --- SerumAlt ---
|
||||
void setRaveEnabled (bool enabled);
|
||||
bool isRaveEnabled() const;
|
||||
|
||||
int getUiScaleIndex() const;
|
||||
void setUiScaleIndex (int index);
|
||||
float getUiScale() const;
|
||||
|
||||
void loadFactoryPreset (int index);
|
||||
int getNumFactoryPresets() const;
|
||||
|
||||
// Public DSP state (read/write from the GUI thread).
|
||||
juce::AudioProcessorValueTreeState parameters;
|
||||
Engine engine;
|
||||
RaveController rave;
|
||||
|
||||
static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout();
|
||||
|
||||
private:
|
||||
int currentProgram = 0;
|
||||
|
||||
void restoreLfoShapesFromState (const juce::ValueTree& state);
|
||||
void saveLfoShapesToState (juce::ValueTree& state) const;
|
||||
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SerumAltAudioProcessor)
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,33 @@
|
||||
# presets module
|
||||
|
||||
## Owned files
|
||||
|
||||
- FactoryPresets.h
|
||||
- FactoryPresets.cpp
|
||||
|
||||
## Rules
|
||||
|
||||
- Build presets as sparse maps; unset parameters keep their APVTS defaults.
|
||||
- Use `ids::` for every parameter ID, never raw strings.
|
||||
- Use named helpers (coarse, unison, slope, fx, lfoShape, mod) to build normalized values.
|
||||
- Return the preset list as `const std::vector<FactoryPreset>&` from a static local.
|
||||
- Reference `ModConnection` and macro assignment structs instead of ad hoc tuples.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF a preset omits a parameter THEN leave it at its default; do not write an explicit zero.
|
||||
- IF a preset sets a physical value THEN encode it through the same `maps::` helpers the DSP uses.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: raw string ID and an opaque normalized number
|
||||
pr.params = { { "oscAUnison", 0.4f } };
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: ids:: and a named helper
|
||||
pr.params = { p (ids::oscAUnison, unison (7)) };
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
@@ -0,0 +1,250 @@
|
||||
#include "FactoryPresets.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
using P = std::pair<const char*, float>;
|
||||
inline P p (const char* id, float v) { return { id, v }; }
|
||||
|
||||
// Semitones -> normalised coarse value (param range -24..+24).
|
||||
inline float coarse (int semitones) { return (float) (semitones + 24) / 48.0f; }
|
||||
// Unison count -> normalised value.
|
||||
inline float unison (int n) { return (float) (n - 1) / 15.0f; }
|
||||
// 6/12/24 dB slope -> normalised value.
|
||||
inline float slope (int db) { return db == 6 ? 0.0f : (db == 12 ? 0.5f : 1.0f); }
|
||||
// FxType -> normalised value.
|
||||
inline float fx (FxType t) { return (float) (int) t / 9.0f; }
|
||||
// LFO shape -> normalised value.
|
||||
inline float lfoShape (LfoShape s) { return (float) (int) s / 6.0f; }
|
||||
|
||||
inline ModConnection mod (ModSource s, ModTarget t, float d, bool b = false)
|
||||
{
|
||||
return { s, t, d, b };
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<FactoryPreset>& getFactoryPresets()
|
||||
{
|
||||
static const std::vector<FactoryPreset> presets = []
|
||||
{
|
||||
std::vector<FactoryPreset> v;
|
||||
FactoryPreset pr;
|
||||
|
||||
// 1. Init Saw
|
||||
pr = {};
|
||||
pr.name = "Init Saw";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 1.0f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.75f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 1.0f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 2. Supersaw Stack
|
||||
pr = {};
|
||||
pr.name = "Supersaw Stack";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 1.0f),
|
||||
p (ids::oscAUnison, unison (7)), p (ids::oscADetune, 0.55f), p (ids::oscASpread, 0.75f), p (ids::oscALevel, 0.85f),
|
||||
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 0.0f), p (ids::oscBWtPos, 1.0f),
|
||||
p (ids::oscBUnison, unison (5)), p (ids::oscBDetune, 0.45f), p (ids::oscBSpread, 0.6f), p (ids::oscBLevel, 0.55f),
|
||||
p (ids::oscBCoarse, coarse (12)),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.68f), p (ids::f1Res, 0.12f), p (ids::f1Slope, 1.0f),
|
||||
p (ids::fx1Type, fx (FxType::Chorus)), p (ids::fx1Mix, 0.35f), p (ids::fx1P1, 0.4f), p (ids::fx1P2, 0.4f),
|
||||
p (ids::fx2Type, fx (FxType::Reverb)), p (ids::fx2Mix, 0.25f), p (ids::fx2P1, 0.55f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 3. Wobble Bass
|
||||
pr = {};
|
||||
pr.name = "Wobble Bass";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 9.0f / 9.0f), p (ids::oscAWtPos, 0.6f),
|
||||
p (ids::subOn, 1.0f), p (ids::subShape, 0.0f), p (ids::subOct, 0.5f), p (ids::subLevel, 0.6f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.35f), p (ids::f1Res, 0.35f), p (ids::f1Slope, 1.0f),
|
||||
p (ids::lfo1Shape, lfoShape (LfoShape::Square)), p (ids::lfo1Rate, 0.55f),
|
||||
p (ids::env1D, 0.35f), p (ids::env1S, 0.8f)
|
||||
};
|
||||
pr.mods = {
|
||||
mod (ModSource::Lfo1, ModTarget::Filter1Cutoff, 0.5f, false),
|
||||
mod (ModSource::Env2, ModTarget::Filter1Cutoff, 0.3f, false)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 4. Pluck
|
||||
pr = {};
|
||||
pr.name = "Pluck";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 0.8f),
|
||||
p (ids::env1A, 0.02f), p (ids::env1D, 0.18f), p (ids::env1S, 0.0f), p (ids::env1R, 0.2f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.7f), p (ids::f1Res, 0.15f), p (ids::f1Slope, 1.0f),
|
||||
p (ids::fx1Type, fx (FxType::Delay)), p (ids::fx1Mix, 0.3f), p (ids::fx1P1, 0.45f), p (ids::fx1P2, 0.45f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 5. Electric Keys
|
||||
pr = {};
|
||||
pr.name = "Electric Keys";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 5.0f / 9.0f), p (ids::oscAWtPos, 0.5f),
|
||||
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 5.0f / 9.0f), p (ids::oscBWtPos, 0.4f), p (ids::oscBLevel, 0.4f),
|
||||
p (ids::env1D, 0.4f), p (ids::env1S, 0.7f), p (ids::env1R, 0.35f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.6f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 0.5f),
|
||||
p (ids::lfo1Shape, lfoShape (LfoShape::Sine)), p (ids::lfo1Rate, 0.7f)
|
||||
};
|
||||
pr.mods = { mod (ModSource::Lfo1, ModTarget::Amp, 0.15f, true) };
|
||||
v.push_back (pr);
|
||||
|
||||
// 6. Pad Dreams
|
||||
pr = {};
|
||||
pr.name = "Pad Dreams";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 8.0f / 9.0f), p (ids::oscAWtPos, 0.7f),
|
||||
p (ids::oscAUnison, unison (5)), p (ids::oscADetune, 0.5f), p (ids::oscASpread, 0.8f),
|
||||
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 4.0f / 9.0f), p (ids::oscBWtPos, 0.4f), p (ids::oscBLevel, 0.5f),
|
||||
p (ids::env1A, 0.5f), p (ids::env1D, 0.5f), p (ids::env1S, 0.9f), p (ids::env1R, 0.7f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.5f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 0.5f),
|
||||
p (ids::fx1Type, fx (FxType::Chorus)), p (ids::fx1Mix, 0.5f), p (ids::fx1P1, 0.3f), p (ids::fx1P2, 0.6f),
|
||||
p (ids::fx2Type, fx (FxType::Reverb)), p (ids::fx2Mix, 0.5f), p (ids::fx2P1, 0.75f), p (ids::fx2P2, 0.5f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 7. Lead
|
||||
pr = {};
|
||||
pr.name = "Lead";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 6.0f / 9.0f), p (ids::oscAWtPos, 1.0f),
|
||||
p (ids::oscAUnison, unison (3)), p (ids::oscADetune, 0.3f), p (ids::oscASpread, 0.5f),
|
||||
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 1.0f / 9.0f), p (ids::oscBWtPos, 0.5f), p (ids::oscBLevel, 0.4f), p (ids::oscBCoarse, coarse (12)),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.6f), p (ids::f1Res, 0.2f), p (ids::f1Slope, 1.0f),
|
||||
p (ids::env2D, 0.4f), p (ids::env2S, 0.4f),
|
||||
p (ids::fx1Type, fx (FxType::Distortion)), p (ids::fx1Mix, 0.2f), p (ids::fx1P1, 0.25f),
|
||||
p (ids::fx2Type, fx (FxType::Delay)), p (ids::fx2Mix, 0.3f), p (ids::fx2P1, 0.4f), p (ids::fx2P2, 0.4f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 8. Sub Bass
|
||||
pr = {};
|
||||
pr.name = "Sub Bass";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 9.0f / 9.0f), p (ids::oscAWtPos, 0.0f),
|
||||
p (ids::subOn, 1.0f), p (ids::subShape, 0.0f), p (ids::subOct, 0.0f), p (ids::subLevel, 0.9f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.4f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 1.0f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 9. Vowel Morph
|
||||
pr = {};
|
||||
pr.name = "Vowel Morph";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 4.0f / 9.0f), p (ids::oscAWtPos, 0.3f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 5.0f / 6.0f), p (ids::f1Cutoff, 0.5f), p (ids::f1Res, 0.2f),
|
||||
p (ids::lfo1Shape, lfoShape (LfoShape::Sine)), p (ids::lfo1Rate, 0.3f)
|
||||
};
|
||||
pr.mods = { mod (ModSource::Lfo1, ModTarget::OscAWtPos, 0.6f, false) };
|
||||
v.push_back (pr);
|
||||
|
||||
// 10. Glass Bell
|
||||
pr = {};
|
||||
pr.name = "Glass Bell";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 8.0f / 9.0f), p (ids::oscAWtPos, 0.6f),
|
||||
p (ids::env1A, 0.01f), p (ids::env1D, 0.5f), p (ids::env1S, 0.0f), p (ids::env1R, 0.8f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.8f), p (ids::f1Res, 0.1f), p (ids::f1Slope, 0.5f),
|
||||
p (ids::fx1Type, fx (FxType::Reverb)), p (ids::fx1Mix, 0.6f), p (ids::fx1P1, 0.7f), p (ids::fx1P2, 0.35f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 11. Sync Riser
|
||||
pr = {};
|
||||
pr.name = "Sync Riser";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 2.0f / 9.0f), p (ids::oscAWtPos, 0.2f),
|
||||
p (ids::oscAWarp, 3.0f / 7.0f), p (ids::oscAWarpAmt, 0.6f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.3f), p (ids::f1Res, 0.2f), p (ids::f1Slope, 1.0f),
|
||||
p (ids::lfo1Shape, lfoShape (LfoShape::Saw)), p (ids::lfo1Rate, 0.2f)
|
||||
};
|
||||
pr.mods = { mod (ModSource::Lfo1, ModTarget::OscAWtPos, 0.8f, false) };
|
||||
v.push_back (pr);
|
||||
|
||||
// 12. 8-Bit Blast
|
||||
pr = {};
|
||||
pr.name = "8-Bit Blast";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 7.0f / 9.0f), p (ids::oscAWtPos, 0.9f),
|
||||
p (ids::oscAWarp, 4.0f / 7.0f), p (ids::oscAWarpAmt, 0.5f),
|
||||
p (ids::env1D, 0.15f), p (ids::env1S, 0.0f), p (ids::env1R, 0.1f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.7f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 1.0f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 13. Organ
|
||||
pr = {};
|
||||
pr.name = "Cathedral Organ";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 5.0f / 9.0f), p (ids::oscAWtPos, 0.8f),
|
||||
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 5.0f / 9.0f), p (ids::oscBWtPos, 0.3f), p (ids::oscBLevel, 0.5f), p (ids::oscBCoarse, coarse (12)),
|
||||
p (ids::env1A, 0.05f), p (ids::env1S, 1.0f), p (ids::env1R, 0.3f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.7f), p (ids::f1Res, 0.0f), p (ids::f1Slope, 0.5f),
|
||||
p (ids::fx1Type, fx (FxType::Reverb)), p (ids::fx1Mix, 0.35f), p (ids::fx1P1, 0.6f), p (ids::fx1P2, 0.4f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 14. Choir
|
||||
pr = {};
|
||||
pr.name = "Choir";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 4.0f / 9.0f), p (ids::oscAWtPos, 0.5f),
|
||||
p (ids::oscAUnison, unison (9)), p (ids::oscADetune, 0.4f), p (ids::oscASpread, 0.7f),
|
||||
p (ids::env1A, 0.45f), p (ids::env1S, 0.85f), p (ids::env1R, 0.6f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 5.0f / 6.0f), p (ids::f1Cutoff, 0.45f), p (ids::f1Res, 0.15f),
|
||||
p (ids::fx1Type, fx (FxType::Chorus)), p (ids::fx1Mix, 0.5f), p (ids::fx1P1, 0.3f), p (ids::fx1P2, 0.7f),
|
||||
p (ids::fx2Type, fx (FxType::Reverb)), p (ids::fx2Mix, 0.55f), p (ids::fx2P1, 0.75f), p (ids::fx2P2, 0.5f)
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
// 15. Acid
|
||||
pr = {};
|
||||
pr.name = "Acid";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 1.0f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.3f), p (ids::f1Res, 0.6f), p (ids::f1Slope, 1.0f),
|
||||
p (ids::env2A, 0.02f), p (ids::env2D, 0.25f), p (ids::env2S, 0.1f)
|
||||
};
|
||||
pr.mods = { mod (ModSource::Env2, ModTarget::Filter1Cutoff, 0.7f, false) };
|
||||
v.push_back (pr);
|
||||
|
||||
// 16. RAVE Anthem Lead
|
||||
pr = {};
|
||||
pr.name = "RAVE Anthem Lead";
|
||||
pr.params = {
|
||||
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 1.0f),
|
||||
p (ids::oscAUnison, unison (4)), p (ids::oscADetune, 0.4f), p (ids::oscASpread, 0.6f), p (ids::oscALevel, 0.85f),
|
||||
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 0.0f), p (ids::oscBWtPos, 1.0f),
|
||||
p (ids::oscBUnison, unison (4)), p (ids::oscBDetune, 0.35f), p (ids::oscBSpread, 0.5f), p (ids::oscBLevel, 0.5f), p (ids::oscBCoarse, coarse (12)),
|
||||
p (ids::env1D, 0.4f), p (ids::env1S, 0.8f), p (ids::env1R, 0.3f),
|
||||
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.6f), p (ids::f1Res, 0.15f), p (ids::f1Drive, 0.15f), p (ids::f1Slope, 1.0f),
|
||||
p (ids::fx1Type, fx (FxType::Hyper)), p (ids::fx1Mix, 0.35f), p (ids::fx1P1, 0.5f),
|
||||
p (ids::fx2Type, fx (FxType::Delay)), p (ids::fx2Mix, 0.28f), p (ids::fx2P1, 0.42f), p (ids::fx2P2, 0.4f),
|
||||
p (ids::fx3Type, fx (FxType::Reverb)), p (ids::fx3Mix, 0.22f), p (ids::fx3P1, 0.55f)
|
||||
};
|
||||
pr.macroAssigns = {
|
||||
{ 0, ModTarget::OscADetune, 0.5f },
|
||||
{ 0, ModTarget::OscBDetune, 0.4f },
|
||||
{ 0, ModTarget::Filter1Drive, 0.4f },
|
||||
{ 1, ModTarget::OscASpread, 0.6f },
|
||||
{ 1, ModTarget::OscBSpread, 0.5f },
|
||||
{ 2, ModTarget::Filter1Drive, 0.5f },
|
||||
{ 2, ModTarget::Filter2Drive, 0.4f },
|
||||
{ 3, ModTarget::Fx3Mix, 0.4f }
|
||||
};
|
||||
v.push_back (pr);
|
||||
|
||||
return v;
|
||||
}();
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Params.h"
|
||||
#include "../ModulationMatrix.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// A factory preset: a name plus a sparse map of parameter values, modulation
|
||||
// connections and macro assignments. Unset parameters keep their defaults.
|
||||
// ===========================================================================
|
||||
struct FactoryPreset
|
||||
{
|
||||
juce::String name;
|
||||
std::vector<std::pair<const char*, float>> params;
|
||||
std::vector<ModConnection> mods;
|
||||
|
||||
struct MacroAssign
|
||||
{
|
||||
int macro = 0;
|
||||
ModTarget target = ModTarget::None;
|
||||
float depth = 0.0f;
|
||||
};
|
||||
std::vector<MacroAssign> macroAssigns;
|
||||
};
|
||||
|
||||
// All 16 factory presets, in program order.
|
||||
const std::vector<FactoryPreset>& getFactoryPresets();
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,91 @@
|
||||
#include "RAVEButton.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void RaveController::resetSnapshot()
|
||||
{
|
||||
snapshot.clear();
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
void RaveController::snapshotParam (const juce::String& id, juce::AudioProcessorValueTreeState& apvts)
|
||||
{
|
||||
if (auto* p = apvts.getParameter (id))
|
||||
snapshot.emplace_back (id, p->getValue());
|
||||
}
|
||||
|
||||
void RaveController::setParam (const juce::String& id, float value, juce::AudioProcessorValueTreeState& apvts)
|
||||
{
|
||||
if (auto* p = apvts.getParameter (id))
|
||||
p->setValueNotifyingHost (juce::jlimit (0.0f, 1.0f, value));
|
||||
}
|
||||
|
||||
void RaveController::setEnabled (bool shouldEnable, juce::AudioProcessorValueTreeState& apvts)
|
||||
{
|
||||
if (shouldEnable == enabled)
|
||||
return;
|
||||
|
||||
if (shouldEnable)
|
||||
{
|
||||
snapshot.clear();
|
||||
|
||||
// Snapshot the static "boost" parameters.
|
||||
const char* staticParams[] =
|
||||
{
|
||||
ids::oscAUnison, ids::oscBUnison,
|
||||
ids::oscASpread, ids::oscBSpread,
|
||||
ids::oscADetune, ids::oscBDetune,
|
||||
ids::f1Drive, ids::f2Drive
|
||||
};
|
||||
for (auto id : staticParams)
|
||||
snapshotParam (id, apvts);
|
||||
|
||||
// Snapshot + boost FX-specific params (Hyper intensity / Reverb mix).
|
||||
const char* fxMixIds[kNumFxSlots] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
|
||||
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
|
||||
const char* fxP1Ids[kNumFxSlots] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1,
|
||||
ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 };
|
||||
const char* fxTypeIds[kNumFxSlots] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
|
||||
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
|
||||
|
||||
for (int i = 0; i < kNumFxSlots; ++i)
|
||||
{
|
||||
const auto* typeParam = apvts.getParameter (fxTypeIds[i]);
|
||||
const int type = typeParam ? (int) (typeParam->getValue() * (int) FxType::Count) : 0;
|
||||
|
||||
if (type == (int) FxType::Hyper)
|
||||
{
|
||||
snapshotParam (fxP1Ids[i], apvts);
|
||||
setParam (fxP1Ids[i], 1.0f, apvts); // OTT intensity 100%
|
||||
}
|
||||
else if (type == (int) FxType::Reverb)
|
||||
{
|
||||
snapshotParam (fxMixIds[i], apvts);
|
||||
const float current = apvts.getParameter (fxMixIds[i])->getValue();
|
||||
setParam (fxMixIds[i], current + 0.4f, apvts); // reverb send +6dB-ish
|
||||
}
|
||||
}
|
||||
|
||||
// Apply boosts.
|
||||
setParam (ids::oscAUnison, 8.0f / 16.0f, apvts);
|
||||
setParam (ids::oscBUnison, 8.0f / 16.0f, apvts);
|
||||
setParam (ids::oscASpread, 1.0f, apvts);
|
||||
setParam (ids::oscBSpread, 1.0f, apvts);
|
||||
setParam (ids::oscADetune, 1.0f, apvts);
|
||||
setParam (ids::oscBDetune, 0.7f, apvts);
|
||||
setParam (ids::f1Drive, 0.6f, apvts);
|
||||
setParam (ids::f2Drive, 0.6f, apvts);
|
||||
|
||||
enabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const auto& entry : snapshot)
|
||||
setParam (entry.first, entry.second, apvts);
|
||||
snapshot.clear();
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// RAVE — one-shot "make it huge" control. Toggling on snapshots the current
|
||||
// values of the affected parameters and pushes unison, width, drive, OTT and
|
||||
// reverb to their boosted settings; toggling off restores the snapshot.
|
||||
// ===========================================================================
|
||||
class RaveController
|
||||
{
|
||||
public:
|
||||
void setEnabled (bool enabled, juce::AudioProcessorValueTreeState& apvts);
|
||||
bool isEnabled() const noexcept { return enabled; }
|
||||
void resetSnapshot();
|
||||
|
||||
private:
|
||||
bool enabled = false;
|
||||
std::vector<std::pair<juce::String, float>> snapshot;
|
||||
|
||||
void snapshotParam (const juce::String& id, juce::AudioProcessorValueTreeState& apvts);
|
||||
void setParam (const juce::String& id, float value, juce::AudioProcessorValueTreeState& apvts);
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,94 @@
|
||||
#include "Resources.h"
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
juce::String getLogoSvg()
|
||||
{
|
||||
return R"svg(
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="40" viewBox="0 0 180 40">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#4fc3f7"/>
|
||||
<stop offset="1" stop-color="#ff6e9c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#g)" d="M20 6 L34 6 L22 34 L8 34 Z"/>
|
||||
<text x="42" y="27" font-family="Verdana, sans-serif" font-size="22" font-weight="bold" fill="#e8eaf0">SerumAlt</text>
|
||||
</svg>
|
||||
)svg";
|
||||
}
|
||||
|
||||
juce::String getBackgroundSvg()
|
||||
{
|
||||
return R"svg(
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720">
|
||||
<rect width="1200" height="720" fill="#101117"/>
|
||||
<radialGradient id="r" cx="0.5" cy="0" r="1.2">
|
||||
<stop offset="0" stop-color="#1a1c24" stop-opacity="1"/>
|
||||
<stop offset="1" stop-color="#101117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<rect width="1200" height="720" fill="url(#r)"/>
|
||||
</svg>
|
||||
)svg";
|
||||
}
|
||||
|
||||
std::unique_ptr<juce::Drawable> createLogoDrawable()
|
||||
{
|
||||
auto xml = juce::parseXML (getLogoSvg());
|
||||
return xml != nullptr ? juce::Drawable::createFromSVG (*xml) : nullptr;
|
||||
}
|
||||
|
||||
std::unique_ptr<juce::Drawable> createBackgroundDrawable()
|
||||
{
|
||||
auto xml = juce::parseXML (getBackgroundSvg());
|
||||
return xml != nullptr ? juce::Drawable::createFromSVG (*xml) : nullptr;
|
||||
}
|
||||
|
||||
juce::String formatPercent (float v)
|
||||
{
|
||||
return juce::String (juce::roundToInt (v * 100.0f)) + " %";
|
||||
}
|
||||
|
||||
juce::String formatHz (float v)
|
||||
{
|
||||
const float hz = maps::cutoffToHz (v);
|
||||
if (hz >= 1000.0f)
|
||||
return juce::String (hz / 1000.0f, 2) + " kHz";
|
||||
return juce::String (juce::roundToInt (hz)) + " Hz";
|
||||
}
|
||||
|
||||
juce::String formatSeconds (float v)
|
||||
{
|
||||
const float s = maps::toSeconds (v);
|
||||
if (s < 1.0f)
|
||||
return juce::String (juce::roundToInt (s * 1000.0f)) + " ms";
|
||||
return juce::String (s, 2) + " s";
|
||||
}
|
||||
|
||||
juce::String formatSemis (float v)
|
||||
{
|
||||
const int semi = (int) std::llround (v * 48.0f) - 24;
|
||||
return juce::String (semi > 0 ? "+" : "") + juce::String (semi) + " st";
|
||||
}
|
||||
|
||||
juce::String formatCents (float v)
|
||||
{
|
||||
const int ct = (int) std::llround (v * 200.0f) - 100;
|
||||
return juce::String (ct > 0 ? "+" : "") + juce::String (ct) + " ct";
|
||||
}
|
||||
|
||||
juce::String formatPan (float v)
|
||||
{
|
||||
const int p = (int) std::llround ((v - 0.5f) * 200.0f);
|
||||
if (p == 0) return "C";
|
||||
return (p < 0 ? "L" : "R") + juce::String (std::abs (p));
|
||||
}
|
||||
|
||||
juce::String formatInt (float v, int maxValue)
|
||||
{
|
||||
return juce::String (juce::jlimit (0, maxValue, (int) std::llround (v * maxValue)));
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Central dark theme + static SVG assets (embedded as strings so they need no
|
||||
// binary-data build step; the same assets also ship under Source/Resources/).
|
||||
// ===========================================================================
|
||||
namespace theme
|
||||
{
|
||||
inline const juce::Colour bg { 0xff101117 };
|
||||
inline const juce::Colour panel { 0xff1a1c24 };
|
||||
inline const juce::Colour panelRaised { 0xff23262f };
|
||||
inline const juce::Colour outline { 0xff2c2f39 };
|
||||
inline const juce::Colour text { 0xffe8eaf0 };
|
||||
inline const juce::Colour textDim { 0xff9095a2 };
|
||||
inline const juce::Colour accent { 0xff4fc3f7 }; // cyan
|
||||
inline const juce::Colour accent2 { 0xffff6e9c }; // pink
|
||||
inline const juce::Colour amber { 0xffffb74d };
|
||||
inline const juce::Colour green { 0xff7ce38b };
|
||||
inline const juce::Colour raveGlow { 0xffff3d71 };
|
||||
}
|
||||
|
||||
// SVG assets (logo + subtle background).
|
||||
juce::String getLogoSvg();
|
||||
juce::String getBackgroundSvg();
|
||||
std::unique_ptr<juce::Drawable> createLogoDrawable();
|
||||
std::unique_ptr<juce::Drawable> createBackgroundDrawable();
|
||||
|
||||
// Small value-formatting helpers used by the GUI controls.
|
||||
juce::String formatPercent (float v); // 0..1 -> "42 %"
|
||||
juce::String formatHz (float v); // 0..1 (cutoff) -> "1.2 kHz"
|
||||
juce::String formatSeconds (float v); // 0..1 (env) -> "320 ms"
|
||||
juce::String formatSemis (float v); // 0..1 -> "-12 st"
|
||||
juce::String formatCents (float v); // 0..1 -> "+5 ct"
|
||||
juce::String formatPan (float v); // 0..1 -> "L50"
|
||||
juce::String formatInt (float v, int maxValue);
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,34 @@
|
||||
# resources module
|
||||
|
||||
## Owned files
|
||||
|
||||
- Related embedded assets and helpers: `../Resources.h`, `../Resources.cpp`
|
||||
- logo.svg
|
||||
- background.svg
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep every theme colour in `namespace theme` in Resources.h.
|
||||
- Embed SVG assets as raw string literals.
|
||||
- Keep formatting helpers (formatPercent, formatHz, formatSeconds, formatSemis, formatCents, formatPan, formatInt) here.
|
||||
- Use `maps::` from Params for unit conversions in formatters.
|
||||
- Never hardcode a theme colour in a widget; reference `theme::`.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF a widget needs a colour THEN reference a `theme::` entry rather than a literal Colour.
|
||||
- IF an SVG asset ships under Source/Resources THEN keep its embedded string in Resources.cpp consistent with the file.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: hardcoded colour in a widget
|
||||
g.setColour (juce::Colour (0xff4fc3f7));
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: theme is the single source
|
||||
g.setColour (theme::accent);
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720">
|
||||
<rect width="1200" height="720" fill="#101117"/>
|
||||
<radialGradient id="r" cx="0.5" cy="0" r="1.2">
|
||||
<stop offset="0" stop-color="#1a1c24" stop-opacity="1"/>
|
||||
<stop offset="1" stop-color="#101117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<rect width="1200" height="720" fill="url(#r)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="40" viewBox="0 0 180 40">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#4fc3f7"/>
|
||||
<stop offset="1" stop-color="#ff6e9c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#g)" d="M20 6 L34 6 L22 34 L8 34 Z"/>
|
||||
<text x="42" y="27" font-family="Verdana, sans-serif" font-size="22" font-weight="bold" fill="#e8eaf0">SerumAlt</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1,39 @@
|
||||
#include "SubOscillator.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void SubOscillator::noteOn (double freqHz, int octaveShift)
|
||||
{
|
||||
// octaveShift: -2, -1 or 0 (from the subOct param).
|
||||
const double mult = (octaveShift == -2) ? 0.25 : (octaveShift == -1) ? 0.5 : 1.0;
|
||||
(void) freqHz;
|
||||
(void) mult;
|
||||
phase = 0.0;
|
||||
}
|
||||
|
||||
void SubOscillator::processAdd (double freqHz, int shape, float level, float& out) noexcept
|
||||
{
|
||||
if (level <= 0.0f || freqHz <= 0.0)
|
||||
return;
|
||||
|
||||
// The octave shift is baked into freqHz by the voice; here we just phase-accumulate.
|
||||
const double inc = kTwoPi * freqHz / sr;
|
||||
phase += inc;
|
||||
phase -= std::floor (phase * (1.0 / kTwoPi)) * kTwoPi;
|
||||
|
||||
float s = 0.0f;
|
||||
if (shape == (int) SubShape::Triangle)
|
||||
{
|
||||
const float p = (float) (phase * (1.0 / kTwoPi));
|
||||
s = 1.0f - 4.0f * std::abs (p - 0.5f);
|
||||
}
|
||||
else
|
||||
{
|
||||
s = (float) std::sin (phase);
|
||||
}
|
||||
|
||||
out += s * level;
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Simple sub oscillator: sine or triangle, one/two octaves down or unison.
|
||||
// ===========================================================================
|
||||
class SubOscillator
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; }
|
||||
void reset() { phase = 0.0; }
|
||||
|
||||
void noteOn (double freqHz, int octaveShift);
|
||||
|
||||
// Accumulate into out (mono; the caller pans/levels it).
|
||||
void processAdd (double freqHz, int shape, float level, float& out) noexcept;
|
||||
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
double phase = 0.0;
|
||||
static constexpr double kTwoPi = 6.28318530717958647692;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,220 @@
|
||||
#include "SynthVoice.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
inline float clampF (float v, float lo, float hi) noexcept
|
||||
{
|
||||
return v < lo ? lo : (v > hi ? hi : v);
|
||||
}
|
||||
}
|
||||
|
||||
void SynthVoice::prepare (double sampleRate, int maxBlockSize)
|
||||
{
|
||||
oscA.prepare (sampleRate);
|
||||
oscB.prepare (sampleRate);
|
||||
sub.prepare (sampleRate);
|
||||
noise.prepare (sampleRate);
|
||||
filters.prepare (sampleRate, maxBlockSize);
|
||||
for (auto& e : env)
|
||||
e.prepare (sampleRate);
|
||||
scratch.setSize (2, maxBlockSize, false, false, true);
|
||||
reset();
|
||||
}
|
||||
|
||||
void SynthVoice::reset()
|
||||
{
|
||||
oscA.reset(); oscB.reset();
|
||||
sub.reset(); noise.reset();
|
||||
filters.reset();
|
||||
for (auto& e : env)
|
||||
e.reset();
|
||||
note = -1;
|
||||
velocity = 0.0f;
|
||||
baseFreq = 0.0;
|
||||
active = released = false;
|
||||
lastUnisonA = lastUnisonB = 1;
|
||||
noteRandom = 0.5f;
|
||||
scratch.clear();
|
||||
}
|
||||
|
||||
void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce::uint32 noteSeed)
|
||||
{
|
||||
note = noteNumber;
|
||||
velocity = velocity01;
|
||||
baseFreq = freqHz;
|
||||
active = true;
|
||||
released = false;
|
||||
seed = noteSeed;
|
||||
lastUnisonA = lastUnisonB = 0; // force unison reconfigure on first render
|
||||
|
||||
juce::Random rng (noteSeed);
|
||||
noteRandom = rng.nextFloat();
|
||||
|
||||
sub.noteOn (freqHz, -1);
|
||||
noise.noteOn (noteSeed);
|
||||
|
||||
for (auto& e : env)
|
||||
e.noteOn();
|
||||
}
|
||||
|
||||
void SynthVoice::noteOff()
|
||||
{
|
||||
if (! active || released)
|
||||
return;
|
||||
released = true;
|
||||
for (auto& e : env)
|
||||
e.noteOff();
|
||||
}
|
||||
|
||||
void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept
|
||||
{
|
||||
if (! active || numSamples <= 0)
|
||||
return;
|
||||
|
||||
// 1. Refresh envelope parameters (cheap; also lets UI edits affect held notes).
|
||||
for (int i = 0; i < kNumEnvelopes; ++i)
|
||||
env[(size_t) i].setParams (maps::toSeconds (ctx.envAttack[i]),
|
||||
maps::toSeconds (ctx.envDecay[i]),
|
||||
ctx.envSustain[i],
|
||||
maps::toSeconds (ctx.envRelease[i]),
|
||||
ctx.envCurve[i]);
|
||||
|
||||
// 2. Block-start envelope values (control-rate modulation sources).
|
||||
float envStart[kNumEnvelopes];
|
||||
for (int i = 0; i < kNumEnvelopes; ++i)
|
||||
envStart[i] = env[(size_t) i].getValue();
|
||||
|
||||
// 3. Per-voice modulation source values.
|
||||
float src[kNumModSources];
|
||||
src[(int) ModSource::Lfo1] = ctx.lfoValues[0];
|
||||
src[(int) ModSource::Lfo2] = ctx.lfoValues[1];
|
||||
src[(int) ModSource::Lfo3] = ctx.lfoValues[2];
|
||||
src[(int) ModSource::Lfo4] = ctx.lfoValues[3];
|
||||
src[(int) ModSource::Env1] = envStart[0];
|
||||
src[(int) ModSource::Env2] = envStart[1];
|
||||
src[(int) ModSource::Env3] = envStart[2];
|
||||
src[(int) ModSource::Env4] = envStart[3];
|
||||
src[(int) ModSource::Velocity] = velocity;
|
||||
src[(int) ModSource::Note] = clampF ((float) note / 127.0f, 0.0f, 1.0f);
|
||||
src[(int) ModSource::ModWheel] = ctx.modWheel;
|
||||
src[(int) ModSource::PitchBend]= ctx.pitchBend;
|
||||
src[(int) ModSource::Macro1] = ctx.macroValues[0];
|
||||
src[(int) ModSource::Macro2] = ctx.macroValues[1];
|
||||
src[(int) ModSource::Macro3] = ctx.macroValues[2];
|
||||
src[(int) ModSource::Macro4] = ctx.macroValues[3];
|
||||
src[(int) ModSource::Random] = noteRandom;
|
||||
|
||||
// 4. Accumulate modulation offsets (block rate).
|
||||
std::array<float, kNumModTargets> mod { { } };
|
||||
if (ctx.matrix != nullptr)
|
||||
{
|
||||
for (const auto& c : ctx.matrix->connections)
|
||||
{
|
||||
float v = src[(int) c.source];
|
||||
if (c.bipolar && ! isBipolarSource (c.source))
|
||||
v = v * 2.0f - 1.0f;
|
||||
mod[(int) c.target] += v * c.depth;
|
||||
}
|
||||
}
|
||||
if (ctx.macros != nullptr)
|
||||
{
|
||||
for (int m = 0; m < kNumMacros; ++m)
|
||||
for (const auto& a : ctx.macros->assignments[(size_t) m])
|
||||
mod[(int) a.target] += ctx.macroValues[m] * a.depth;
|
||||
}
|
||||
|
||||
// 5. Modulated oscillator parameters.
|
||||
OscParams a = ctx.oscA;
|
||||
OscParams b = ctx.oscB;
|
||||
a.level = clampF (a.level + mod[(int) ModTarget::OscALevel], 0.0f, 1.0f);
|
||||
a.pan = clampF (a.pan + mod[(int) ModTarget::OscAPan], -1.0f, 1.0f);
|
||||
a.wtPos = clampF (a.wtPos + mod[(int) ModTarget::OscAWtPos], 0.0f, 1.0f);
|
||||
a.unison = (int) std::llround (clampF ((float) a.unison + mod[(int) ModTarget::OscAUnison] * 16.0f, 1.0f, 16.0f));
|
||||
a.detune = clampF (a.detune + mod[(int) ModTarget::OscADetune], 0.0f, 1.0f);
|
||||
a.spread = clampF (a.spread + mod[(int) ModTarget::OscASpread], 0.0f, 1.0f);
|
||||
a.warpAmt = clampF (a.warpAmt+ mod[(int) ModTarget::OscAWarpAmt], 0.0f, 1.0f);
|
||||
|
||||
b.level = clampF (b.level + mod[(int) ModTarget::OscBLevel], 0.0f, 1.0f);
|
||||
b.pan = clampF (b.pan + mod[(int) ModTarget::OscBPan], -1.0f, 1.0f);
|
||||
b.wtPos = clampF (b.wtPos + mod[(int) ModTarget::OscBWtPos], 0.0f, 1.0f);
|
||||
b.unison = (int) std::llround (clampF ((float) b.unison + mod[(int) ModTarget::OscBUnison] * 16.0f, 1.0f, 16.0f));
|
||||
b.detune = clampF (b.detune + mod[(int) ModTarget::OscBDetune], 0.0f, 1.0f);
|
||||
b.spread = clampF (b.spread + mod[(int) ModTarget::OscBSpread], 0.0f, 1.0f);
|
||||
b.warpAmt = clampF (b.warpAmt+ mod[(int) ModTarget::OscBWarpAmt], 0.0f, 1.0f);
|
||||
|
||||
// 6. Frequency (pitch bend + mod matrix pitch + per-osc coarse/fine).
|
||||
const double semis = ctx.pitchBend * ctx.pitchBendRange + mod[(int) ModTarget::Pitch] * 24.0;
|
||||
const double bent = baseFreq * std::pow (2.0, semis / 12.0);
|
||||
const double freqA = bent * std::pow (2.0, (double) a.coarse / 12.0 + (double) a.fine / 1200.0);
|
||||
const double freqB = bent * std::pow (2.0, (double) b.coarse / 12.0 + (double) b.fine / 1200.0);
|
||||
|
||||
// Reconfigure unison only when the integer count changes (avoids phase reset
|
||||
// on every block when unison is LFO-modulated at control rate).
|
||||
if (a.unison != lastUnisonA) { oscA.noteOn (freqA, a, seed); lastUnisonA = a.unison; }
|
||||
if (b.unison != lastUnisonB) { oscB.noteOn (freqB, b, seed + 1); lastUnisonB = b.unison; }
|
||||
|
||||
// 7. Modulated filter parameters.
|
||||
FilterBankParams fb = ctx.filters;
|
||||
fb.f1Cutoff = clampF (fb.f1Cutoff + mod[(int) ModTarget::Filter1Cutoff], 0.0f, 1.0f);
|
||||
fb.f1Res = clampF (fb.f1Res + mod[(int) ModTarget::Filter1Res], 0.0f, 1.0f);
|
||||
fb.f1Drive = clampF (fb.f1Drive + mod[(int) ModTarget::Filter1Drive], 0.0f, 1.0f);
|
||||
fb.f2Cutoff = clampF (fb.f2Cutoff + mod[(int) ModTarget::Filter2Cutoff], 0.0f, 1.0f);
|
||||
fb.f2Res = clampF (fb.f2Res + mod[(int) ModTarget::Filter2Res], 0.0f, 1.0f);
|
||||
fb.f2Drive = clampF (fb.f2Drive + mod[(int) ModTarget::Filter2Drive], 0.0f, 1.0f);
|
||||
fb.mix = clampF (fb.mix + mod[(int) ModTarget::FilterMix], 0.0f, 1.0f);
|
||||
fb.out = clampF (fb.out + mod[(int) ModTarget::FilterOut], 0.0f, 1.5f);
|
||||
|
||||
// 8. Amp modulation + velocity.
|
||||
const float ampMod = 1.0f + mod[(int) ModTarget::Amp];
|
||||
const float velGain = velocity * velocity + 0.001f;
|
||||
|
||||
// 9. Generate oscillators/sub/noise into scratch.
|
||||
float* sL = scratch.getWritePointer (0);
|
||||
float* sR = scratch.getWritePointer (1);
|
||||
const Wavetable& wtA = ctx.wavetables->getTable (a.wave);
|
||||
const Wavetable& wtB = ctx.wavetables->getTable (b.wave);
|
||||
const double subMult = (ctx.subOct == -2) ? 0.25 : (ctx.subOct == -1) ? 0.5 : 1.0;
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
float l = 0.0f, r = 0.0f;
|
||||
oscA.processAdd (wtA, a, freqA, l, r);
|
||||
oscB.processAdd (wtB, b, freqB, l, r);
|
||||
|
||||
float mono = 0.0f;
|
||||
if (ctx.subOn)
|
||||
sub.processAdd (bent * subMult, ctx.subShape, ctx.subLevel, mono);
|
||||
if (ctx.noiseOn)
|
||||
noise.processAdd (ctx.noiseType, ctx.noiseLevel, mono);
|
||||
l += mono;
|
||||
r += mono;
|
||||
|
||||
sL[i] = l;
|
||||
sR[i] = r;
|
||||
}
|
||||
|
||||
// 10. Filter bank (control rate).
|
||||
filters.process (sL, sR, numSamples, fb, (float) baseFreq);
|
||||
|
||||
// 11. Amp envelope (per sample) and advance the remaining envelopes.
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
const float amp = env[0].process();
|
||||
env[1].process();
|
||||
env[2].process();
|
||||
env[3].process();
|
||||
|
||||
const float gain = amp * ampMod * velGain;
|
||||
outL[i] += sL[i] * gain;
|
||||
outR[i] += sR[i] * gain;
|
||||
}
|
||||
|
||||
// 12. Release completes when the amp envelope has fully decayed.
|
||||
if (released && ! env[0].isActive())
|
||||
active = false;
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
#include "Wavetable.h"
|
||||
#include "Oscillator.h"
|
||||
#include "SubOscillator.h"
|
||||
#include "NoiseOscillator.h"
|
||||
#include "FilterBank.h"
|
||||
#include "Envelope.h"
|
||||
#include "ModulationMatrix.h"
|
||||
#include "MacroControls.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Everything a voice needs to render one block. Global (non per-voice) values
|
||||
// are provided by the engine; the voice adds its own per-voice modulation.
|
||||
// ===========================================================================
|
||||
struct RenderContext
|
||||
{
|
||||
double sampleRate = 44100.0;
|
||||
const WavetableLibrary* wavetables = nullptr;
|
||||
|
||||
float lfoValues[kNumLfos] { 0.0f, 0.0f, 0.0f, 0.0f };
|
||||
float macroValues[kNumMacros] { 0.0f, 0.0f, 0.0f, 0.0f };
|
||||
float modWheel = 0.0f;
|
||||
float pitchBend = 0.0f;
|
||||
float pitchBendRange = 2.0f;
|
||||
|
||||
const ModulationMatrix* matrix = nullptr;
|
||||
const MacroControls* macros = nullptr;
|
||||
|
||||
OscParams oscA;
|
||||
OscParams oscB;
|
||||
|
||||
bool subOn = false;
|
||||
int subShape = 0;
|
||||
int subOct = -1;
|
||||
float subLevel = 0.0f;
|
||||
|
||||
bool noiseOn = false;
|
||||
int noiseType = 0;
|
||||
float noiseLevel = 0.0f;
|
||||
|
||||
FilterBankParams filters;
|
||||
|
||||
float envAttack[4] { 0.01f, 0.01f, 0.01f, 0.01f };
|
||||
float envDecay[4] { 0.2f, 0.2f, 0.2f, 0.2f };
|
||||
float envSustain[4]{ 0.7f, 0.7f, 0.7f, 0.7f };
|
||||
float envRelease[4]{ 0.3f, 0.3f, 0.3f, 0.3f };
|
||||
float envCurve[4] { 0.5f, 0.5f, 0.5f, 0.5f };
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// One synthesis voice: two oscillators + sub + noise -> filter bank -> amp
|
||||
// envelope. Four envelopes provide per-voice modulation sources.
|
||||
// ===========================================================================
|
||||
class SynthVoice
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize);
|
||||
void reset();
|
||||
|
||||
void noteOn (int noteNumber, float velocity, double freqHz, juce::uint32 seed);
|
||||
void noteOff();
|
||||
|
||||
void render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept;
|
||||
|
||||
bool isActive() const noexcept { return active; }
|
||||
bool isReleased() const noexcept { return released; }
|
||||
int getNote() const noexcept { return note; }
|
||||
juce::uint64 getNoteId() const noexcept { return noteId; }
|
||||
|
||||
private:
|
||||
Oscillator oscA, oscB;
|
||||
SubOscillator sub;
|
||||
NoiseOscillator noise;
|
||||
FilterBank filters;
|
||||
std::array<Envelope, 4> env;
|
||||
|
||||
int note = -1;
|
||||
float velocity = 0.0f;
|
||||
double baseFreq = 0.0;
|
||||
bool active = false;
|
||||
bool released = false;
|
||||
juce::uint64 noteId = 0;
|
||||
|
||||
int lastUnisonA = 1, lastUnisonB = 1;
|
||||
|
||||
juce::uint32 seed = 0;
|
||||
float noteRandom = 0.5f;
|
||||
|
||||
juce::AudioBuffer<float> scratch; // 2 channels
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,40 @@
|
||||
# tests module
|
||||
|
||||
## Owned files
|
||||
|
||||
- TestMain.cpp
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep this a headless console harness that drives the real SerumAltAudioProcessor.
|
||||
- Check rendered blocks for NaN and inf. Check non-silence over the full preset render, not each block, because note release can legitimately produce silent blocks.
|
||||
- Print PASS or FAIL per preset to `std::cout`.
|
||||
- Return a non-zero exit code when any check fails.
|
||||
- Never show a JUCE alert window.
|
||||
- Do not add GUI interaction beyond `juce::ScopedJuceInitialiser_GUI`.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF a check fails THEN increment the failure count and print the preset name.
|
||||
- Build and run natively from the repository root with `./build_harness.sh --run`. Building the `SerumAltTest` target alone does not run checks.
|
||||
- The harness-only configuration sets `SERUMALT_BUILD_PLUGIN=OFF` and `SERUMALT_BUILD_TESTS=ON`. Production scripts disable the harness.
|
||||
- The harness links the real processor and editor sources but creates no plugin bundles. Keep that build separation when adding checks.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: a failing check does not fail the run
|
||||
bool ok = ! hasNaN (buffer) && peak (buffer) > 0.001f; // result ignored
|
||||
return 0;
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: accumulate failures and exit non-zero
|
||||
int failures = 0;
|
||||
const bool ok = ! hasNaN (buffer) && peak (buffer) > 0.001f;
|
||||
if (! ok) ++failures;
|
||||
...
|
||||
return failures == 0 ? 0 : 1;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
@@ -0,0 +1,135 @@
|
||||
#include <JuceHeader.h>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include "../PluginProcessor.h"
|
||||
|
||||
// ===========================================================================
|
||||
// Headless QA harness: instantiates the processor, plays notes through every
|
||||
// factory preset and reports NaN/inf and silence failures.
|
||||
// ===========================================================================
|
||||
namespace
|
||||
{
|
||||
bool hasNaN (const juce::AudioBuffer<float>& b)
|
||||
{
|
||||
for (int ch = 0; ch < b.getNumChannels(); ++ch)
|
||||
{
|
||||
const float* d = b.getReadPointer (ch);
|
||||
for (int i = 0; i < b.getNumSamples(); ++i)
|
||||
if (! std::isfinite (d[i]))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
float peak (const juce::AudioBuffer<float>& b)
|
||||
{
|
||||
float p = 0.0f;
|
||||
for (int ch = 0; ch < b.getNumChannels(); ++ch)
|
||||
{
|
||||
const float* d = b.getReadPointer (ch);
|
||||
for (int i = 0; i < b.getNumSamples(); ++i)
|
||||
p = std::max (p, std::abs (d[i]));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
juce::ScopedJuceInitialiser_GUI initialiser;
|
||||
|
||||
serum::SerumAltAudioProcessor processor;
|
||||
processor.prepareToPlay (44100.0, 512);
|
||||
|
||||
const int numPresets = processor.getNumPrograms();
|
||||
int failures = 0;
|
||||
|
||||
const int notes[3] = { 48, 60, 67 };
|
||||
|
||||
// Default (init) preset.
|
||||
{
|
||||
juce::MidiBuffer midi;
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
|
||||
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
float maxPeak = 0.0f;
|
||||
bool nan = false;
|
||||
|
||||
for (int block = 0; block < 240; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
if (block == 180)
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
|
||||
nan = nan || hasNaN (buffer);
|
||||
maxPeak = std::max (maxPeak, peak (buffer));
|
||||
}
|
||||
|
||||
const bool ok = ! nan && maxPeak > 0.001f;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [Init] peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
|
||||
}
|
||||
|
||||
// Every factory preset.
|
||||
for (int preset = 0; preset < numPresets; ++preset)
|
||||
{
|
||||
processor.setCurrentProgram (preset);
|
||||
|
||||
juce::MidiBuffer midi;
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
|
||||
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
float maxPeak = 0.0f;
|
||||
bool nan = false;
|
||||
|
||||
for (int block = 0; block < 240; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
if (block == 180)
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
|
||||
nan = nan || hasNaN (buffer);
|
||||
maxPeak = std::max (maxPeak, peak (buffer));
|
||||
}
|
||||
|
||||
const bool ok = ! nan && maxPeak > 0.001f;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [" << processor.getProgramName (preset) << "]"
|
||||
<< " peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
|
||||
}
|
||||
|
||||
// RAVE toggle + parameter churn while playing (no crash / NaN).
|
||||
{
|
||||
processor.setCurrentProgram (0);
|
||||
juce::MidiBuffer midi;
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
|
||||
|
||||
processor.setRaveEnabled (true);
|
||||
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
bool nan = false;
|
||||
for (int block = 0; block < 120; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
if (block == 60)
|
||||
processor.setRaveEnabled (false);
|
||||
nan = nan || hasNaN (buffer);
|
||||
}
|
||||
const bool ok = ! nan;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [RAVE toggle / param churn]" << (nan ? " <NaN!>" : "") << "\n";
|
||||
}
|
||||
|
||||
std::cout << "\n" << (failures == 0 ? "ALL QA CHECKS PASSED" : "QA FAILURES DETECTED")
|
||||
<< " (" << failures << " failures)\n";
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
#include "Wavetable.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wavetable
|
||||
// ---------------------------------------------------------------------------
|
||||
void Wavetable::clear()
|
||||
{
|
||||
frames.clear();
|
||||
name = {};
|
||||
}
|
||||
|
||||
void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int numHarmonics)
|
||||
{
|
||||
name = n;
|
||||
frames.assign (kFrames, std::vector<float> (kTableSize, 0.0f));
|
||||
|
||||
const float invN = 1.0f / (float) kTableSize;
|
||||
constexpr double twoPi = 6.28318530717958647692;
|
||||
|
||||
for (int f = 0; f < kFrames; ++f)
|
||||
{
|
||||
auto& frame = frames[(size_t) f];
|
||||
|
||||
// Precompute per-harmonic rotation step (cos/sin of the angular increment).
|
||||
std::vector<double> cosStep ((size_t) numHarmonics + 1, 0.0);
|
||||
std::vector<double> sinStep ((size_t) numHarmonics + 1, 0.0);
|
||||
std::vector<double> mag ((size_t) numHarmonics + 1, 0.0);
|
||||
std::vector<double> phase ((size_t) numHarmonics + 1, 0.0);
|
||||
|
||||
double maxAmp = 1e-9;
|
||||
for (int h = 1; h <= numHarmonics; ++h)
|
||||
{
|
||||
const float a = ampFn (f, h);
|
||||
const double m = std::abs ((double) a);
|
||||
mag[(size_t) h] = m;
|
||||
phase[(size_t) h] = (a < 0.0f) ? twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base
|
||||
const double ang = twoPi * (double) h * (double) invN;
|
||||
cosStep[(size_t) h] = std::cos (ang);
|
||||
sinStep[(size_t) h] = std::sin (ang);
|
||||
maxAmp = std::max (maxAmp, m);
|
||||
}
|
||||
|
||||
double peak = 1e-9;
|
||||
for (int h = 1; h <= numHarmonics; ++h)
|
||||
{
|
||||
if (mag[(size_t) h] < 1e-9)
|
||||
continue;
|
||||
|
||||
// Start the rotating phasor at the harmonic's phase offset.
|
||||
double s = std::sin (phase[(size_t) h]);
|
||||
double c = std::cos (phase[(size_t) h]);
|
||||
const double cs = cosStep[(size_t) h];
|
||||
const double ss = sinStep[(size_t) h];
|
||||
const double m = mag[(size_t) h];
|
||||
|
||||
for (int i = 0; i < kTableSize; ++i)
|
||||
{
|
||||
frame[(size_t) i] += (float) (m * s);
|
||||
const double s2 = s * cs + c * ss;
|
||||
c = c * cs - s * ss;
|
||||
s = s2;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalise each frame to unity peak so morphing keeps a stable level.
|
||||
for (int i = 0; i < kTableSize; ++i)
|
||||
peak = std::max (peak, (double) std::abs (frame[(size_t) i]));
|
||||
|
||||
if (peak > 1e-6)
|
||||
{
|
||||
const float g = (float) (1.0 / peak);
|
||||
for (int i = 0; i < kTableSize; ++i)
|
||||
frame[(size_t) i] *= g;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float Wavetable::read (float framePos, float phase) const noexcept
|
||||
{
|
||||
if (frames.empty())
|
||||
return 0.0f;
|
||||
|
||||
framePos = juce::jlimit (0.0f, (float) (kFrames - 1), framePos);
|
||||
int f0 = (int) framePos;
|
||||
float frac = framePos - (float) f0;
|
||||
int f1 = juce::jmin (f0 + 1, kFrames - 1);
|
||||
|
||||
float p = phase * (float) kTableSize;
|
||||
int i0 = (int) p;
|
||||
if (i0 < 0) i0 = 0;
|
||||
float t = p - (float) i0;
|
||||
int i1 = (i0 + 1) & (kTableSize - 1);
|
||||
i0 &= (kTableSize - 1);
|
||||
|
||||
const auto& a = frames[(size_t) f0];
|
||||
const auto& b = frames[(size_t) f1];
|
||||
const float s0 = lerp (a[(size_t) i0], a[(size_t) i1], t);
|
||||
const float s1 = lerp (b[(size_t) i0], b[(size_t) i1], t);
|
||||
return lerp (s0, s1, frac);
|
||||
}
|
||||
|
||||
void Wavetable::copyFrame (int frameIndex, float* dest, int numSamples) const
|
||||
{
|
||||
if (frames.empty() || dest == nullptr)
|
||||
return;
|
||||
|
||||
frameIndex = juce::jlimit (0, kFrames - 1, frameIndex);
|
||||
const auto& frame = frames[(size_t) frameIndex];
|
||||
const int n = juce::jmin (numSamples, kTableSize);
|
||||
for (int i = 0; i < n; ++i)
|
||||
dest[i] = frame[(size_t) i];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library
|
||||
// ---------------------------------------------------------------------------
|
||||
void WavetableLibrary::prebuild()
|
||||
{
|
||||
for (int i = 0; i < kNumWavetables; ++i)
|
||||
getTable (i);
|
||||
}
|
||||
|
||||
const Wavetable& WavetableLibrary::getTable (int index) const
|
||||
{
|
||||
index = juce::jlimit (0, kNumWavetables - 1, index);
|
||||
if (!built[(size_t) index])
|
||||
buildTable (index);
|
||||
return tables[(size_t) index];
|
||||
}
|
||||
|
||||
void WavetableLibrary::buildTable (int index) const
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 0: tables[(size_t) index] = makeBasic(); break;
|
||||
case 1: tables[(size_t) index] = makeSawPwm(); break;
|
||||
case 2: tables[(size_t) index] = makeSquareSync(); break;
|
||||
case 3: tables[(size_t) index] = makeTriangleFold(); break;
|
||||
case 4: tables[(size_t) index] = makeVowel(); break;
|
||||
case 5: tables[(size_t) index] = makeOrgan(); break;
|
||||
case 6: tables[(size_t) index] = makeWarmSaw(); break;
|
||||
case 7: tables[(size_t) index] = makeDigital(); break;
|
||||
case 8: tables[(size_t) index] = makeGlass(); break;
|
||||
case 9: tables[(size_t) index] = makeBass(); break;
|
||||
default: tables[(size_t) index] = makeBasic(); break;
|
||||
}
|
||||
built[(size_t) index] = true;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
inline float normT (int frame) { return (float) frame / 255.0f; }
|
||||
constexpr float kPi = 3.14159265358979323846f;
|
||||
}
|
||||
|
||||
// sine -> saw morph
|
||||
Wavetable WavetableLibrary::makeBasic()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Basic Shapes", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float sine = (h == 1) ? 1.0f : 0.0f;
|
||||
const float saw = 1.0f / (float) h;
|
||||
return sine + (saw - sine) * t;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// narrow pulse -> wide pulse
|
||||
Wavetable WavetableLibrary::makeSawPwm()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Saw PWM", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float duty = 0.08f + 0.42f * t; // 8% -> 50%
|
||||
const float a = std::sin (kPi * (float) h * duty);
|
||||
return (2.0f / ((float) h * kPi)) * a;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// hard-sync style comb sweep
|
||||
Wavetable WavetableLibrary::makeSquareSync()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Square Sync", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float ratio = 1.0f + 3.0f * t;
|
||||
const float comb = 0.5f + 0.5f * std::cos (2.0f * kPi * (float) h * ratio);
|
||||
return (1.0f / std::pow ((float) h, 0.8f)) * comb;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// triangle -> folded/saw-ish
|
||||
Wavetable WavetableLibrary::makeTriangleFold()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Triangle Fold", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
// Triangle: odd harmonics with alternating sign, 1/h^2.
|
||||
float tri = 0.0f;
|
||||
if ((h & 1) == 1)
|
||||
{
|
||||
const int k = (h - 1) / 2;
|
||||
const float sign = ((k & 1) == 0) ? 1.0f : -1.0f;
|
||||
tri = sign * 8.0f / (kPi * kPi * (float) (h * h));
|
||||
}
|
||||
const float saw = 1.0f / (float) h;
|
||||
return tri + (saw - tri) * t;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// formant morph a -> e -> i
|
||||
Wavetable WavetableLibrary::makeVowel()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Vowel", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
// Three formant sets (Hz) with f0 = 55 Hz.
|
||||
const float f0 = 55.0f;
|
||||
const float aa[3] = { 730.0f, 1090.0f, 2440.0f };
|
||||
const float ee[3] = { 530.0f, 1840.0f, 2480.0f };
|
||||
const float ii[3] = { 270.0f, 2290.0f, 3010.0f };
|
||||
|
||||
float from[3], to[3];
|
||||
if (t < 0.5f)
|
||||
{
|
||||
const float u = t * 2.0f;
|
||||
for (int k = 0; k < 3; ++k) { from[k] = aa[k]; to[k] = ee[k]; }
|
||||
(void) u;
|
||||
}
|
||||
else
|
||||
{
|
||||
const float u = (t - 0.5f) * 2.0f;
|
||||
for (int k = 0; k < 3; ++k) { from[k] = ee[k]; to[k] = ii[k]; }
|
||||
(void) u;
|
||||
}
|
||||
|
||||
float tt = (t < 0.5f) ? t * 2.0f : (t - 0.5f) * 2.0f;
|
||||
float sum = 0.0f;
|
||||
const float sigma = 1.8f; // formant bandwidth in harmonic units
|
||||
for (int k = 0; k < 3; ++k)
|
||||
{
|
||||
const float fc = from[k] + (to[k] - from[k]) * tt;
|
||||
const float center = fc / f0;
|
||||
const float d = ((float) h - center) / sigma;
|
||||
sum += std::exp (-0.5f * d * d) * (k == 0 ? 1.0f : 0.6f);
|
||||
}
|
||||
return sum;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// drawbar organ morph
|
||||
Wavetable WavetableLibrary::makeOrgan()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Organ", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
// two drawbar registrations (16', 8', 5 1/3', 4', 2 2/3', 2', ...)
|
||||
const float regA[8] = { 0.0f, 1.0f, 0.0f, 0.35f, 0.0f, 0.2f, 0.0f, 0.1f };
|
||||
const float regB[8] = { 0.5f, 1.0f, 0.4f, 0.6f, 0.25f, 0.5f, 0.2f, 0.35f };
|
||||
if (h > 8) return 0.0f;
|
||||
const float a = regA[h - 1];
|
||||
const float b = regB[h - 1];
|
||||
return a + (b - a) * t;
|
||||
}, 8);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// warm lowpassed saw -> brighter
|
||||
Wavetable WavetableLibrary::makeWarmSaw()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Warm Saw", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float cutoff = 10.0f + 34.0f * t; // harmonic rolloff centre
|
||||
const float rolloff = std::exp (-((float) h / cutoff) * ((float) h / cutoff));
|
||||
const float body = 1.0f / std::pow ((float) h, 0.9f);
|
||||
return body * (0.4f + 0.6f * rolloff);
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// additive, brighter and slightly combed
|
||||
Wavetable WavetableLibrary::makeDigital()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Digital", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float comb = 0.5f + 0.5f * std::cos ((float) h * 0.9f * (1.0f + t));
|
||||
return (1.0f / std::pow ((float) h, 0.6f)) * (0.5f + 0.5f * comb);
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// inharmonic bell-like partial clusters
|
||||
Wavetable WavetableLibrary::makeGlass()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Glass", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float c1 = 1.0f + t * 2.5f;
|
||||
const float c2 = 3.6f + t * 3.4f;
|
||||
const float c3 = 6.2f + t * 4.0f;
|
||||
const float sigma = 0.9f;
|
||||
float sum = 0.0f;
|
||||
const float centers[3] = { c1, c2, c3 };
|
||||
const float gains[3] = { 1.0f, 0.7f, 0.4f };
|
||||
for (int k = 0; k < 3; ++k)
|
||||
{
|
||||
const float d = ((float) h - centers[k]) / sigma;
|
||||
sum += gains[k] * std::exp (-0.5f * d * d);
|
||||
}
|
||||
return sum;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
// sub-heavy -> fuller bass
|
||||
Wavetable WavetableLibrary::makeBass()
|
||||
{
|
||||
Wavetable wt;
|
||||
wt.buildHarmonic ("Bass", [] (int f, int h)
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float sub = (h == 1) ? 1.0f : ((h == 2) ? 0.4f : ((h == 3) ? 0.12f : 0.0f));
|
||||
const float full = 1.0f / std::pow ((float) h, 1.1f);
|
||||
return sub + (full - sub) * t;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "Params.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// A single wavetable: 256 frames of 2048 samples each. Frames morph smoothly
|
||||
// from a simple base shape to a complex one. Lookups interpolate bilinearly
|
||||
// (across frames and within a frame). Tables are RAM-resident and generated
|
||||
// lazily by the WavetableLibrary.
|
||||
// ===========================================================================
|
||||
class Wavetable
|
||||
{
|
||||
public:
|
||||
static constexpr int kFrames = 256;
|
||||
static constexpr int kTableSize = 2048;
|
||||
|
||||
Wavetable() = default;
|
||||
|
||||
juce::String name;
|
||||
std::vector<std::vector<float>> frames; // [frame][sample]
|
||||
|
||||
bool isValid() const noexcept { return (int) frames.size() == kFrames; }
|
||||
|
||||
void clear();
|
||||
|
||||
// Build a table from a per-frame harmonic amplitude function.
|
||||
// ampFn(frameIndex, harmonicNumber) -> amplitude (harmonic 1 = fundamental)
|
||||
using HarmonicAmpFn = std::function<float (int, int)>;
|
||||
void buildHarmonic (const juce::String& name, HarmonicAmpFn ampFn, int numHarmonics = 64);
|
||||
|
||||
// Read the table at a fractional frame position and a phase in [0,1).
|
||||
float read (float framePos, float phase) const noexcept;
|
||||
|
||||
// Read with phase wrapped and clamped frame position.
|
||||
float readSafe (float framePos, float phase) const noexcept
|
||||
{
|
||||
phase -= std::floor (phase);
|
||||
return read (juce::jlimit (0.0f, (float) kFrames - 1.001f, framePos), phase);
|
||||
}
|
||||
|
||||
// Copy raw samples of a frame into a destination buffer (for the GUI).
|
||||
void copyFrame (int frameIndex, float* dest, int numSamples) const;
|
||||
|
||||
private:
|
||||
static float lerp (float a, float b, float t) noexcept { return a + (b - a) * t; }
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Lazily-built library of factory wavetables. Indices map to kWavetableNames.
|
||||
// ===========================================================================
|
||||
class WavetableLibrary
|
||||
{
|
||||
public:
|
||||
const Wavetable& getTable (int index) const;
|
||||
int size() const noexcept { return kNumWavetables; }
|
||||
|
||||
// Force generation of all tables (used at prepare time).
|
||||
void prebuild();
|
||||
|
||||
private:
|
||||
mutable std::array<Wavetable, kNumWavetables> tables;
|
||||
mutable std::array<bool, kNumWavetables> built { { false } };
|
||||
|
||||
void buildTable (int index) const;
|
||||
static Wavetable makeBasic();
|
||||
static Wavetable makeSawPwm();
|
||||
static Wavetable makeSquareSync();
|
||||
static Wavetable makeTriangleFold();
|
||||
static Wavetable makeVowel();
|
||||
static Wavetable makeOrgan();
|
||||
static Wavetable makeWarmSaw();
|
||||
static Wavetable makeDigital();
|
||||
static Wavetable makeGlass();
|
||||
static Wavetable makeBass();
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,48 @@
|
||||
# engine module
|
||||
|
||||
## Related source files
|
||||
|
||||
This directory contains guidance; the implementation remains in `../Engine.h`
|
||||
and `../Engine.cpp`. The preparation rules below are requirements for changes;
|
||||
current buffer resizing and lazy wavetable issues are recorded in the root AGENT.md.
|
||||
|
||||
## Rules
|
||||
|
||||
- Own the voice pool, LFOs, wavetable library, FX rack, matrix and macros by value.
|
||||
- Read every APVTS value through the null-guarding `v()` helper.
|
||||
- Build the `RenderContext` once per block and pass it to voices as `const&`.
|
||||
- Size `mixBuffer` once in `prepare()` and only `clear()` it per block.
|
||||
- Prebuild wavetables in `prepare()` before the first render.
|
||||
- Clamp the final output with the soft limiter.
|
||||
- Never call `setSize` on any buffer inside `processBlock`.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF a per-block buffer is needed THEN allocate it in `prepare()` and clear it per block.
|
||||
- IF a module needs the matrix, macros or wavetables THEN go through Engine accessors rather than reaching into another module.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: per-callback buffer reconfiguration on the audio thread
|
||||
void processBlock (...)
|
||||
{
|
||||
mixBuffer.setSize (2, n, false, false, true);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: size once, clear per block
|
||||
void prepare (double sr, int blockSize)
|
||||
{
|
||||
mixBuffer.setSize (2, blockSize, false, false, true);
|
||||
}
|
||||
void processBlock (...)
|
||||
{
|
||||
mixBuffer.clear();
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
@@ -0,0 +1,34 @@
|
||||
# modulation module
|
||||
|
||||
## Related source files
|
||||
|
||||
This directory contains guidance; implementations remain in the parent `Source/`
|
||||
directory:
|
||||
|
||||
- `../ModulationMatrix.h`, `../ModulationMatrix.cpp`
|
||||
- `../MacroControls.h`, `../MacroControls.cpp`
|
||||
- `../RAVEButton.h`, `../RAVEButton.cpp`, which define `RaveController`
|
||||
|
||||
## Rules
|
||||
|
||||
- Use `bool` return codes for bounded inserts (`addConnection`, `addAssignment`).
|
||||
- Require a cap on connection and assignment counts (`kMaxConnections`, `kMaxAssignments`).
|
||||
- Serialise and restore state through `juce::ValueTree` so presets survive save and load.
|
||||
- Never accept a `ModTarget::None` connection; reject it and return false.
|
||||
- Use `ids::` for every APVTS parameter reference in RaveController.
|
||||
- Keep the existing RAVEButton filenames unless a rename is explicitly in scope.
|
||||
|
||||
## GUI/audio state
|
||||
|
||||
The current public connection and assignment vectors are shared mutable state.
|
||||
GUI writes can race with audio-thread iteration. A mutex taken only by the writer
|
||||
does not protect an unlocked reader, and taking a blocking mutex in the audio
|
||||
callback conflicts with the real-time requirements.
|
||||
|
||||
When fixing this path, publish a stable snapshot or use a bounded thread-safe
|
||||
handoff. Keep allocation and snapshot reclamation off the audio thread. Validate
|
||||
reader and writer lifetimes together, including preset and state restore.
|
||||
|
||||
If `addConnection` or `addAssignment` returns false, handle the failed insert.
|
||||
|
||||
This file refines the repository-root AGENT.md for the related sources.
|
||||
@@ -0,0 +1,39 @@
|
||||
# params module
|
||||
|
||||
## Related source files
|
||||
|
||||
This directory contains guidance; the implementation remains in `../Params.h`
|
||||
and `../Params.cpp`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Use the `ids::` namespace for every APVTS parameter ID string.
|
||||
- Require one `ids::` entry per audio parameter, and one entry only.
|
||||
- Use `enum class` for every enum. Give each a trailing `Count` enumerator.
|
||||
- Use `inline constexpr` for parameter ID strings and `k`-prefixed integer constants.
|
||||
- Keep all 0..1 to physical unit conversions in the `maps::` namespace.
|
||||
- Keep the wavetable name table (`kWavetableNames`) in sync with `kNumWavetables`.
|
||||
- Return an empty `juce::String` from enum-to-string switches for unknown values.
|
||||
- Never re-declare a parameter ID string literal outside Params.h.
|
||||
- Never add a physical unit mapping anywhere but `maps::`.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF you add an audio parameter THEN add its ID to `ids::` and, when it has physical units, add its mapping to `maps::` before any engine or GUI code reads it.
|
||||
- IF a switch converts an enum to a string THEN include a default case that returns an empty string.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: re-declares the ID and drifts from Params.h
|
||||
if (auto* p = apvts.getParameter ("f1Cutoff"))
|
||||
return p->getValue();
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: one source of truth
|
||||
if (auto* p = apvts.getParameter (ids::f1Cutoff))
|
||||
return p->getValue();
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
@@ -0,0 +1,41 @@
|
||||
# plugin module
|
||||
|
||||
## Related source files
|
||||
|
||||
This directory contains guidance; the implementation remains in
|
||||
`../PluginProcessor.h` and `../PluginProcessor.cpp`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep this the JUCE `AudioProcessor` boundary: APVTS, engine, RAVE, presets and state.
|
||||
- Declare the full parameter layout in `createParameterLayout()`.
|
||||
- Use `juce::ScopedNoDenormals` in `processBlock`.
|
||||
- Guard `getRawParameterValue` and `getParameter` results before dereferencing them.
|
||||
- Return silently on null or invalid state XML and invalid ValueTrees.
|
||||
- Use raw `new` only for JUCE-owned objects (`createEditor`, `createPluginFilter`).
|
||||
- Prefer const accessors over the public `parameters`, `engine` and `rave` fields for new code.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF state XML is null or invalid THEN return without touching the APVTS.
|
||||
- IF a parameter is missing from a preset load THEN skip it with an `if (auto* param = ...)` guard.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: dereferences a possibly null XML result
|
||||
auto xml = getXmlFromBinary (data, size);
|
||||
auto state = juce::ValueTree::fromXml (*xml);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: null guard, silent return
|
||||
std::unique_ptr<juce::XmlElement> xml (getXmlFromBinary (data, size));
|
||||
if (xml == nullptr)
|
||||
return;
|
||||
juce::ValueTree state = juce::ValueTree::fromXml (*xml);
|
||||
if (! state.isValid())
|
||||
return;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
@@ -0,0 +1,58 @@
|
||||
# synth module
|
||||
|
||||
## Related source files
|
||||
|
||||
This directory contains guidance. All files listed below remain in the parent
|
||||
`Source/` directory. The preparation rules below are requirements for changes,
|
||||
not a claim that lazy wavetable allocation has already been fixed.
|
||||
|
||||
- Wavetable.h, Wavetable.cpp
|
||||
- Oscillator.h, Oscillator.cpp
|
||||
- SubOscillator.h, SubOscillator.cpp
|
||||
- NoiseOscillator.h, NoiseOscillator.cpp
|
||||
- Envelope.h, Envelope.cpp
|
||||
- LFO.h, LFO.cpp
|
||||
- Filter.h, Filter.cpp
|
||||
- FilterBank.h, FilterBank.cpp
|
||||
- SynthVoice.h, SynthVoice.cpp
|
||||
|
||||
## Rules
|
||||
|
||||
- Use `noexcept` on every per-sample DSP path and cheap getter.
|
||||
- Use `std::array` for fixed-size state and `std::vector<float>` for delay lines and table buffers.
|
||||
- Size every buffer and delay line in `prepare()`, never in `process` or `render`.
|
||||
- Clamp filter and envelope state so extreme settings cannot produce NaN or inf.
|
||||
- Use `juce::MathConstants<T>::pi` and `twoPi` instead of raw pi literals.
|
||||
- Use `(size_t)` casts when indexing containers with int loop variables.
|
||||
- Pass global per-block state into voices as a `const RenderContext&`.
|
||||
- Never allocate memory in the audio path.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF a wavetable is read in the render path THEN build all tables in `prepare()` by calling `WavetableLibrary::prebuild()` before the first block.
|
||||
- IF a parameter is read every block THEN snapshot it into a struct (`OscParams`, `RenderContext`) instead of reading the APVTS per sample.
|
||||
|
||||
## Examples
|
||||
|
||||
```cpp
|
||||
// BAD: lazy table build reached from the render path allocates on the audio thread
|
||||
const Wavetable& wt = library.getTable (wave);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: build once at prepare time, then only read const references
|
||||
library.prebuild(); // in prepareToPlay
|
||||
const Wavetable& wt = library.getTable (wave); // lookup, no allocation
|
||||
```
|
||||
|
||||
```cpp
|
||||
// BAD: raw pi literal duplicated across files
|
||||
const double step = 6.28318530717958647692 * freq / sr;
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: single source
|
||||
const double step = juce::MathConstants<double>::twoPi * freq / sr;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
BUILD_DIR="${BUILD_DIR:-$SCRIPT_DIR/build_harness}"
|
||||
BUILD_TYPE="${BUILD_TYPE:-Release}"
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
|
||||
|
||||
if [[ $# -gt 1 || ( $# -eq 1 && "$1" != --run ) ]]; then
|
||||
echo "Usage: $0 [--run]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOOLS=(cmake ninja "${CC:-cc}" "${CXX:-c++}")
|
||||
case "$(uname -s)" in
|
||||
Linux) TOOLS+=(pkg-config) ;;
|
||||
Darwin) TOOLS+=(xcrun) ;;
|
||||
*) echo "ERROR: build_harness.sh requires a native Linux or macOS host." >&2; exit 1 ;;
|
||||
esac
|
||||
if [[ $# -eq 1 ]]; then
|
||||
TOOLS+=(ctest)
|
||||
fi
|
||||
for tool in "${TOOLS[@]}"; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: $tool not found. See README.md prerequisites." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$(uname -s)" == Darwin ]]; then
|
||||
xcrun --sdk macosx --show-sdk-path
|
||||
fi
|
||||
|
||||
echo "Configuring the native QA harness only in $BUILD_DIR ($BUILD_TYPE)..."
|
||||
cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
|
||||
-DSERUMALT_BUILD_PLUGIN=OFF \
|
||||
-DSERUMALT_BUILD_TESTS=ON
|
||||
|
||||
echo "Building SerumAltTest..."
|
||||
cmake --build "$BUILD_DIR" --config "$BUILD_TYPE" --target SerumAltTest
|
||||
|
||||
if [[ $# -eq 1 ]]; then
|
||||
echo "Running the QA harness with CTest..."
|
||||
ctest --test-dir "$BUILD_DIR" --build-config "$BUILD_TYPE" --output-on-failure --no-tests=error
|
||||
fi
|
||||
|
||||
echo "Done. QA harness: $BUILD_DIR/SerumAltTest_artefacts/$BUILD_TYPE/SerumAltTest"
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
BUILD_DIR="${BUILD_DIR:-$SCRIPT_DIR/build_linux}"
|
||||
BUILD_TYPE="${BUILD_TYPE:-Release}"
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
|
||||
|
||||
if [[ "$(uname -s)" != Linux ]]; then
|
||||
echo "ERROR: build_linux.sh requires a Linux host." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for tool in cmake ninja pkg-config "${CC:-cc}" "${CXX:-c++}"; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: $tool not found. See README.md prerequisites." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Configuring Linux production targets in $BUILD_DIR ($BUILD_TYPE)..."
|
||||
cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
|
||||
-DSERUMALT_BUILD_PLUGIN=ON \
|
||||
-DSERUMALT_BUILD_TESTS=OFF
|
||||
|
||||
echo "Building Linux VST3 and standalone..."
|
||||
cmake --build "$BUILD_DIR" --config "$BUILD_TYPE" --target SerumAlt_VST3 SerumAlt_Standalone
|
||||
|
||||
echo "Done. Linux artifacts: $BUILD_DIR/SerumAlt_artefacts/$BUILD_TYPE"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
BUILD_DIR="${BUILD_DIR:-$SCRIPT_DIR/build_macos}"
|
||||
BUILD_TYPE="${BUILD_TYPE:-Release}"
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
|
||||
|
||||
if [[ "$(uname -s)" != Darwin ]]; then
|
||||
echo "ERROR: build_macos.sh requires macOS and the Apple SDK." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for tool in cmake ninja xcrun "${CC:-clang}" "${CXX:-clang++}"; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: $tool not found. See README.md prerequisites." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
xcrun --sdk macosx --show-sdk-path
|
||||
|
||||
OSX_OPTIONS=("-DCMAKE_BUILD_TYPE=$BUILD_TYPE")
|
||||
if [[ -n "${CMAKE_OSX_ARCHITECTURES:-}" ]]; then
|
||||
OSX_OPTIONS+=("-DCMAKE_OSX_ARCHITECTURES=$CMAKE_OSX_ARCHITECTURES")
|
||||
fi
|
||||
if [[ -n "${CMAKE_OSX_DEPLOYMENT_TARGET:-}" ]]; then
|
||||
OSX_OPTIONS+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$CMAKE_OSX_DEPLOYMENT_TARGET")
|
||||
fi
|
||||
|
||||
echo "Configuring macOS production targets in $BUILD_DIR ($BUILD_TYPE)..."
|
||||
cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -G Ninja \
|
||||
"${OSX_OPTIONS[@]}" \
|
||||
-DSERUMALT_BUILD_PLUGIN=ON \
|
||||
-DSERUMALT_BUILD_TESTS=OFF
|
||||
|
||||
echo "Building macOS VST3, AU, and standalone..."
|
||||
cmake --build "$BUILD_DIR" --config "$BUILD_TYPE" --target SerumAlt_VST3 SerumAlt_AU SerumAlt_Standalone
|
||||
|
||||
echo "Done. macOS artifacts: $BUILD_DIR/SerumAlt_artefacts/$BUILD_TYPE"
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_windows.sh — cross-compile SerumAlt VST3 for Windows using MinGW-w64
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
TOOLCHAIN_FILE="$SCRIPT_DIR/mingw64-toolchain.cmake"
|
||||
BUILD_DIR="${BUILD_DIR:-$SCRIPT_DIR/build_windows}"
|
||||
BUILD_TYPE="${BUILD_TYPE:-Release}"
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-$SCRIPT_DIR/SerumAlt_Windows}"
|
||||
PLUGIN_NAME="SerumAlt"
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
|
||||
|
||||
if [[ "$(uname -s)" != Linux ]]; then
|
||||
echo "ERROR: build_windows.sh cross-compiles from Linux using MinGW-w64." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Check for the host tools and MinGW-w64 cross-compiler.
|
||||
for tool in cmake ninja pkg-config "${CC:-cc}" "${CXX:-c++}" \
|
||||
x86_64-w64-mingw32-gcc x86_64-w64-mingw32-g++ \
|
||||
x86_64-w64-mingw32-windres x86_64-w64-mingw32-ar \
|
||||
x86_64-w64-mingw32-ranlib x86_64-w64-mingw32-strip; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: $tool not found. See README.md prerequisites." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Found: $(command -v x86_64-w64-mingw32-g++)"
|
||||
|
||||
# 2. Use the checked-in CMake toolchain file without overwriting it.
|
||||
if [[ ! -f "$TOOLCHAIN_FILE" ]]; then
|
||||
echo "ERROR: Toolchain file not found: $TOOLCHAIN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Using toolchain file: $TOOLCHAIN_FILE"
|
||||
|
||||
# 3. Configure with CMake + Ninja (VST3-only cross build, no test harness).
|
||||
echo "Configuring Windows VST3 in $BUILD_DIR ($BUILD_TYPE)..."
|
||||
cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_FILE" \
|
||||
-DSERUMALT_BUILD_PLUGIN=ON \
|
||||
-DSERUMALT_BUILD_TESTS=OFF
|
||||
|
||||
# 4. Build the VST3 target.
|
||||
echo "Building Windows VST3..."
|
||||
cmake --build "$BUILD_DIR" --config "$BUILD_TYPE" --target SerumAlt_VST3
|
||||
|
||||
# 5. Inject the VST3 moduleinfo.json.
|
||||
# The automatic manifest step is disabled when cross-compiling (see
|
||||
# cmake/Plugin.cmake), because JUCE's manifest helper is a host tool that would
|
||||
# otherwise be built as a Windows executable. The moduleinfo content is
|
||||
# deterministic for this Windows plugin: it depends on the plugin's
|
||||
# name, manufacturer/plugin codes, and version, so we write the same metadata
|
||||
# that the native Windows helper generates. Keep it in sync with the metadata
|
||||
# in CMakeLists.txt and cmake/Plugin.cmake, including the vendored VST3 SDK version.
|
||||
VST3_BUNDLE="$BUILD_DIR/${PLUGIN_NAME}_artefacts/$BUILD_TYPE/VST3/$PLUGIN_NAME.vst3"
|
||||
echo "Writing Windows VST3 manifest..."
|
||||
mkdir -p "$VST3_BUNDLE/Contents/Resources"
|
||||
cat > "$VST3_BUNDLE/Contents/Resources/moduleinfo.json" <<'EOF'
|
||||
{
|
||||
"Name": "SerumAlt",
|
||||
"Version": "1.0.0",
|
||||
"Factory Info": {
|
||||
"Vendor": "SerumAlt Audio",
|
||||
"URL": "",
|
||||
"E-Mail": "",
|
||||
"Flags": {
|
||||
"Unicode": true,
|
||||
"Classes Discardable": false,
|
||||
"Component Non Discardable": false
|
||||
}
|
||||
},
|
||||
"Classes": [
|
||||
{
|
||||
"CID": "ABCDEF019182FAEB53616C7453657261",
|
||||
"Category": "Audio Module Class",
|
||||
"Name": "SerumAlt",
|
||||
"Vendor": "SerumAlt Audio",
|
||||
"Version": "1.0.0",
|
||||
"SDKVersion": "VST 3.7.8",
|
||||
"Sub Categories": [
|
||||
"Instrument",
|
||||
"Synth"
|
||||
],
|
||||
"Class Flags": 2,
|
||||
"Cardinality": 2147483647,
|
||||
"Snapshots": []
|
||||
},
|
||||
{
|
||||
"CID": "ABCDEF011234ABCD53616C7453657261",
|
||||
"Category": "Component Controller Class",
|
||||
"Name": "SerumAlt",
|
||||
"Vendor": "SerumAlt Audio",
|
||||
"Version": "1.0.0",
|
||||
"SDKVersion": "VST 3.7.8",
|
||||
"Sub Categories": [
|
||||
"Instrument",
|
||||
"Synth"
|
||||
],
|
||||
"Class Flags": 2,
|
||||
"Cardinality": 2147483647,
|
||||
"Snapshots": []
|
||||
},
|
||||
{
|
||||
"CID": "ABCDEF01C0DEF00D53616C7453657261",
|
||||
"Category": "Plugin Compatibility Class",
|
||||
"Name": "SerumAlt",
|
||||
"Vendor": "SerumAlt Audio",
|
||||
"Version": "1.0.0",
|
||||
"SDKVersion": "VST 3.7.8",
|
||||
"Class Flags": 0,
|
||||
"Cardinality": 2147483647,
|
||||
"Snapshots": []
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
# 6. Copy the resulting .vst3 bundle to SerumAlt_Windows/ without deleting files.
|
||||
echo "Staging Windows VST3 in $OUTPUT_DIR..."
|
||||
cmake -E copy_directory "$VST3_BUNDLE" "$OUTPUT_DIR/$PLUGIN_NAME.vst3"
|
||||
|
||||
echo
|
||||
echo "Done. Windows VST3 plugin at: $OUTPUT_DIR/$PLUGIN_NAME.vst3"
|
||||
@@ -0,0 +1,7 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Headless QA harness (console app that drives the processor and checks output)
|
||||
# ---------------------------------------------------------------------------
|
||||
juce_add_console_app(SerumAltTest)
|
||||
serumalt_configure_target(SerumAltTest)
|
||||
target_sources(SerumAltTest PRIVATE Source/Tests/TestMain.cpp)
|
||||
add_test(NAME SerumAltTest COMMAND SerumAltTest)
|
||||
@@ -0,0 +1,52 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin target
|
||||
# ---------------------------------------------------------------------------
|
||||
# JUCE 7.0.12 builds its VST3 manifest helper (juce_vst3_helper) with the same
|
||||
# toolchain as the plugin itself. When cross-compiling, that produces a Windows
|
||||
# .exe which cannot run on the Linux build host, so we disable the automatic
|
||||
# manifest step and inject a Windows moduleinfo.json afterwards
|
||||
# (see build_windows.sh). Native builds keep the automatic step.
|
||||
set(SERUMALT_VST3_AUTO_MANIFEST TRUE)
|
||||
if(CMAKE_CROSSCOMPILING)
|
||||
set(SERUMALT_VST3_AUTO_MANIFEST FALSE)
|
||||
endif()
|
||||
|
||||
set(SERUMALT_FORMATS VST3)
|
||||
if(NOT WIN32)
|
||||
list(APPEND SERUMALT_FORMATS Standalone)
|
||||
endif()
|
||||
if(APPLE)
|
||||
list(APPEND SERUMALT_FORMATS AU)
|
||||
endif()
|
||||
|
||||
juce_add_plugin(SerumAlt
|
||||
VERSION ${PROJECT_VERSION}
|
||||
COMPANY_NAME "SerumAlt Audio"
|
||||
IS_SYNTH TRUE
|
||||
NEEDS_MIDI_INPUT TRUE
|
||||
NEEDS_MIDI_OUTPUT FALSE
|
||||
IS_MIDI_EFFECT FALSE
|
||||
EDITOR_WANTS_KEYBOARD_FOCUS FALSE
|
||||
PLUGIN_MANUFACTURER_CODE Salt
|
||||
PLUGIN_CODE Sera
|
||||
BUNDLE_ID com.serumalt.SerumAlt
|
||||
FORMATS ${SERUMALT_FORMATS}
|
||||
PRODUCT_NAME "SerumAlt"
|
||||
VST3_AUTO_MANIFEST ${SERUMALT_VST3_AUTO_MANIFEST})
|
||||
|
||||
serumalt_configure_target(SerumAlt)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MinGW cross-build: static C++/threading runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
# When cross-compiling for Windows with MinGW, link libgcc / libstdc++ /
|
||||
# winpthread statically so the VST3 DLL is self-contained. Otherwise the DLL
|
||||
# imports libgcc_s_seh-1.dll, libstdc++-6.dll and libwinpthread-1.dll, none of
|
||||
# which ship with the bundle (or exist on a stock Windows host), so the host's
|
||||
# scan would fail to load the plugin. Native (Linux) builds are unaffected.
|
||||
if(MINGW)
|
||||
target_link_options(SerumAlt_VST3 PRIVATE
|
||||
-static-libgcc
|
||||
-static-libstdc++
|
||||
-static)
|
||||
endif()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user