chore: land janitor round 1 and graft integration
Round 1 findings and fixes, all previously uncommitted: - FXProcessor per-slot DSP history repair (HEAD does not compile without it) - SynthVoice::getModulationValue, editor control-lock guard - ExactCompare.h naming the deliberate exact float comparisons - KnobLookAndFeel replacing the never-installed SerumLookAndFeel - 55 first-party compiler warnings to 0 - AGENT.md renamed to AGENTS.md across Source/ - graft repository context graph and its hooks - JANITOR_TASKS.md and UI_UX_AUDIT.md records
This commit is contained in:
1 parent
5c942cad0c
commit
e4cea300db
63 files changed
+2360
-805
No files matched your search
@@ -64,4 +64,4 @@ function entry(name) {
|
||||
return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent
|
||||
}
|
||||
|
||||
import(pathToFileURL(entry("hooks.js")).href).then((m) => m.main(process.argv[2])).catch(() => { /* graft unavailable — no-op */ });
|
||||
import(pathToFileURL(entry("hooks.js")).href).then((m) => m.main(process.argv[2])).catch(() => { /* graft unavailable, no-op */ });
|
||||
@@ -64,4 +64,4 @@ function entry(name) {
|
||||
return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent
|
||||
}
|
||||
|
||||
import(pathToFileURL(entry("statusline.js")).href).then((m) => m.main()).catch(() => { /* graft unavailable — no-op */ });
|
||||
import(pathToFileURL(entry("statusline.js")).href).then((m) => m.main()).catch(() => { /* graft unavailable, no-op */ });
|
||||
@@ -14,34 +14,35 @@ graph of who-calls-what. Querying a node costs a few hundred tokens; rebuilding
|
||||
that understanding by reading source costs thousands, and misses the edges.
|
||||
|
||||
Every command below is `$0`, needs no API key, and returns in under a second.
|
||||
There are six of them. **Pick the one that fits the task, run it, act on the
|
||||
answer; don't chain tools hoping for more. Most tasks need one call.**
|
||||
There are six of them. Pick the one that fits the task, run it, and act on the
|
||||
answer. Do not chain tools hoping for more. Most tasks need one call.
|
||||
|
||||
## The tools
|
||||
|
||||
### 1 · `graft ask "<question>" --source`: locate + understand (the default)
|
||||
Ranked retrieval over the graph, routed automatically between prose nodes and
|
||||
the wiring graph, returning the top hits with exact `file:line`.
|
||||
- `--source` inlines the code at each hit, the ≤8-line **crux** of each
|
||||
- `--source` inlines the code at each hit, the ≤8-line crux of each
|
||||
definition, so the result IS the code you need, no follow-up file read. Add
|
||||
`--full` only when the crux is too small to act on.
|
||||
- `--in <path>` narrows to a subtree before ranking; `-n N` caps results (default 8).
|
||||
- **Use it when** the question is conceptual or locational: "how does auth
|
||||
work", "where is rate-limiting handled", "what assembles the request pipeline".
|
||||
- Use it when the question is conceptual or locational, for example "how does
|
||||
auth work", "where is rate-limiting handled", "what assembles the request
|
||||
pipeline".
|
||||
- One ask usually answers. A genuinely multi-part question needs one ask per
|
||||
distinct sub-aspect, never the same question reworded. Few or weak hits mean
|
||||
switch tool (grep / skeleton / callers), don't re-ask.
|
||||
|
||||
### 2 · `graft grep "<pattern>"`: exhaustive find
|
||||
Regex (or `--fixed` for a literal) over every indexed file, hits **grouped by
|
||||
enclosing symbol** and ranked by coupling; it also reports files it couldn't read.
|
||||
- **Use it when** you need every occurrence: all call sites, all uses of a
|
||||
constant, all providers. `ask` is ranked top-N and *will* miss instances;
|
||||
grep won't. One grep replaces a spray of asks.
|
||||
- Search a **short symbol name or literal**, not a full guessed signature: an
|
||||
Regex (or `--fixed` for a literal) over every indexed file, hits grouped by
|
||||
enclosing symbol and ranked by coupling; it also reports files it could not read.
|
||||
- Use it when you need every occurrence: all call sites, all uses of a
|
||||
constant, all providers. `ask` is ranked top-N and can miss instances;
|
||||
grep does not. One grep replaces a spray of asks.
|
||||
- Search a short symbol name or literal, not a full guessed signature: an
|
||||
over-specific regex (`func (s *Server) GenerateHandler`) returns nothing even
|
||||
when the code is indexed. If a grep misses, **loosen it** (drop the receiver
|
||||
and signature, keep the bare name) and retry `graft grep` — do NOT switch to
|
||||
when the code is indexed. If a grep misses, loosen it (drop the receiver
|
||||
and signature, keep the bare name) and retry `graft grep`. Do not switch to
|
||||
raw `grep -rn`, which is slower and unranked.
|
||||
- `-i` case-insensitive; `--in <path>` scopes to a subtree. Raw `grep -rn` is
|
||||
only for files graft genuinely doesn't index (docs, configs, brand-new files).
|
||||
@@ -49,38 +50,38 @@ enclosing symbol** and ranked by coupling; it also reports files it couldn't rea
|
||||
### 3 · `graft skeleton <file>`: a file's API at a glance
|
||||
Signatures-only view of one file (every function / method / type with its span)
|
||||
in ~200 tokens, ~10x cheaper than reading the file.
|
||||
- **Use it when** you need "what's in this file / what can I call here" before
|
||||
editing or wiring into it. One skeleton is the whole answer for a file; don't
|
||||
re-skeleton the same file, and don't skeleton every file `map` already named.
|
||||
- Use it when you need "what's in this file / what can I call here" before
|
||||
editing or wiring into it. One skeleton is the whole answer for a file; do not
|
||||
re-skeleton the same file, and do not skeleton every file `map` already named.
|
||||
|
||||
### 4 · `graft callers <symbol>`: the exact edges
|
||||
Precomputed call/reference edges, not a text search. Symbol can be bare
|
||||
(`Foo`), qualified (`Class.method`), or package-qualified (`pkg.Fn`).
|
||||
- default `--direction in`: **who calls/references** this; run before you
|
||||
- default `--direction in`: who calls or references this; run before you
|
||||
rename, delete, or change its signature.
|
||||
- `--direction out`: **what this symbol itself calls/depends on** (the old `callees`).
|
||||
- `--depth N`: walk transitively N hops for the **full blast radius** (the old
|
||||
- `--direction out`: what this symbol itself calls or depends on (the old `callees`).
|
||||
- `--depth N`: walk transitively N hops for the full blast radius (the old
|
||||
`impact`); `--depth 2` is the usual "what breaks if I touch this".
|
||||
- `--depth all`: the **entire connected closure** — every source reachable
|
||||
through the edges. Reach for this before a **refactor, rename, or any
|
||||
multi-file change**: it surfaces the sibling and downstream files (platform
|
||||
- `--depth all`: the entire connected closure, meaning every source reachable
|
||||
through the edges. Reach for this before a refactor, rename, or any
|
||||
multi-file change: it surfaces the sibling and downstream files (platform
|
||||
variants, a module you must split out) that a single-file edit would miss.
|
||||
|
||||
### 5 · `graft map`: orientation for an unfamiliar repo or area
|
||||
A token-budgeted tour: directory clusters, per-directory hubs, and global
|
||||
hotspots, straight from the wiring graph.
|
||||
- **Use it when** you land in a repo cold or are asked for "the architecture".
|
||||
`map` alone is the answer: read the hub cards it names; do NOT then skeleton
|
||||
- Use it when you land in a repo cold or are asked for "the architecture".
|
||||
`map` alone is the answer: read the hub cards it names; do not then skeleton
|
||||
or ask your way through every subsystem it lists. `--max-dirs N` widens it.
|
||||
|
||||
### 6 · Lifecycle: `graft build` / `graft check`
|
||||
Every tool above refreshes the graph itself before answering, so what those tools
|
||||
return always describes the code as it is right now — including edits you just made
|
||||
and have not committed. You do **not** need to run `build` after editing.
|
||||
return always describes the code as it is right now, including edits you just made
|
||||
and have not committed. You do not need to run `build` after editing.
|
||||
|
||||
One caveat, if you `grep` the markdown under `graft/` directly: those cards are a
|
||||
One caveat: if you `grep` the markdown under `graft/` directly, those cards are a
|
||||
projection, rebuilt at the end of the turn rather than on each query, so after an edit
|
||||
they can lag. The tools above never do — prefer them, and treat a card's spans as
|
||||
they can lag. The tools above never do. Prefer them, and treat a card's spans as
|
||||
stale if you have edited that file this turn.
|
||||
|
||||
`build` is for the LLM layer (`--deep` adds a concept map; skip unless asked);
|
||||
@@ -93,9 +94,9 @@ stale if you have edited that file this turn.
|
||||
| Onboarding / "explain this codebase" | `graft map`, then read the named hub cards | 1 |
|
||||
| Understanding a flow ("how does X work") | `graft ask "<flow>" --source` | 1 |
|
||||
| Finding where a change belongs | `graft ask "where is <behavior>" --source` | 1 |
|
||||
| Editing a symbol you can already name | `graft grep "<symbol>"`, edit at the `file:line` (skip `ask` — you know where it is) | 1 |
|
||||
| Editing a symbol you can already name | `graft grep "<symbol>"`, edit at the `file:line` (skip `ask`, you know where it is) | 1 |
|
||||
| Renaming / deleting / changing a signature | `graft callers <sym> --depth 2` first | 1 |
|
||||
| Refactor / multi-file change (before editing) | `graft callers <sym> --depth all` — map every connected file, don't stop at the first | 1 |
|
||||
| Refactor / multi-file change (before editing) | `graft callers <sym> --depth all` to map every connected file, do not stop at the first | 1 |
|
||||
| "What does this depend on?" | `graft callers <sym> --direction out` | 1 |
|
||||
| Finding every occurrence of a pattern | `graft grep "<literal>"` | 1 |
|
||||
| "What's the API of this file?" | `graft skeleton <file>` | 1 |
|
||||
@@ -113,25 +114,26 @@ you already know where you're working, narrow with `graft ask "<task>" --in <sco
|
||||
don't re-open or re-grep files to "double-check".
|
||||
- When the task already names the file or symbol to change, go straight there:
|
||||
`graft grep "<symbol>"` for the exact `file:line`, then edit. Reserve
|
||||
`graft ask` for when you don't yet know where the code lives — an `ask`
|
||||
round-trip is wasted on a target you can already name.
|
||||
`graft ask` for when you do not yet know where the code lives, because an
|
||||
`ask` round-trip is wasted on a target you can already name.
|
||||
- Trust the answer and act. Reach for a second tool only when the first genuinely
|
||||
fell short: weak hits, a truncated span, or a need to be exhaustive.
|
||||
- If graft names a path that isn't on disk, its index is ahead of your checkout
|
||||
(a branch switch or unpulled move). Don't read the missing file — `graft grep`
|
||||
the symbol to find where it lives now, or run `graft build` to refresh.
|
||||
(a branch switch or unpulled move). Do not read the missing file. Instead
|
||||
`graft grep` the symbol to find where it lives now, or run `graft build` to
|
||||
refresh.
|
||||
|
||||
## Report what graft saved, every turn
|
||||
Each retrieval tool **opens** its output with a `[graft] tokens saved ≈ N` line:
|
||||
Each retrieval tool opens its output with a `[graft] tokens saved ≈ N` line:
|
||||
the estimated tokens that call saved versus reading the files it covers whole.
|
||||
Whenever you used any graft tool in a turn, close your reply with a one-line
|
||||
tally summing those numbers across every graft call you made, e.g.
|
||||
`🌱 graft saved ~12,400 tokens this turn (3 calls)`. A call with no such line
|
||||
tally summing those numbers across every graft call you made, for example
|
||||
`graft saved ~12,400 tokens this turn (3 calls)`. A call with no such line
|
||||
(tiny files, where the pointers cost as much as the source) saved nothing, so
|
||||
skip it. This is the per-turn figure; the statusline carries the running
|
||||
session total.
|
||||
|
||||
**Never pipe a graft command through `head`, `tail`, or `sed -n`.** Every tool
|
||||
Never pipe a graft command through `head`, `tail`, or `sed -n`. Every tool
|
||||
is already capped and states what it dropped; clipping it costs you hits you
|
||||
asked for, and it silently drops the savings line the statusline's running
|
||||
total is parsed from.
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# 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.
|
||||
@@ -39,3 +39,95 @@ re-read whole files.
|
||||
After big code changes, refresh the graph with `graft build` (deterministic,
|
||||
no API key, $0).
|
||||
<!-- graft:end -->
|
||||
|
||||
# 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.
|
||||
|
||||
## Development scope
|
||||
|
||||
This is a fun proof of concept. Prefer small working changes, clear names, and
|
||||
successful compilation over release engineering or broad rewrites. Use the existing
|
||||
smoke harness for DSP changes; add test infrastructure only when requested. The user
|
||||
will judge sound and interaction by using the synth. Keep ownership and audio-thread
|
||||
safety fixes practical, without adding a generic framework.
|
||||
|
||||
## 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
|
||||
`AGENTS.md` files when editing the related root-level sources. Consult the matching
|
||||
`AGENTS.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.
|
||||
|
||||
The engine prebuilds wavetables and renders through bounded buffer views. GUI and
|
||||
state edits hold `Engine::getControlLock()`; audio uses a try-lock to capture into
|
||||
reserved storage and keeps its last snapshot when busy. Keep those two copies
|
||||
separate. RAVE changes render settings, not the underlying knob values. The
|
||||
historical audit describes the older implementation, not current behavior.
|
||||
|
||||
## 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.
|
||||
+9
-9
@@ -120,9 +120,9 @@ The Windows cross-build does not build or run tests: `build_windows.sh:50` passe
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -138,7 +138,7 @@ generic synth architecture.
|
||||
|
||||
- `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.
|
||||
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`).
|
||||
@@ -244,7 +244,7 @@ generic synth architecture.
|
||||
|
||||
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()`
|
||||
- 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
|
||||
@@ -283,7 +283,7 @@ mix of asserts, null guards, and return codes.
|
||||
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
|
||||
Net style: programmer errors use `jassert` (sparingly), runtime/state errors use null
|
||||
guards or default values, and bounded-insertion failures use `bool` return codes that
|
||||
callers currently ignore.
|
||||
|
||||
@@ -317,9 +317,9 @@ thread:
|
||||
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.
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -372,7 +372,7 @@ describes intent.
|
||||
|
||||
## 7. Anti-pattern catalog
|
||||
|
||||
Each entry lists the location, the problem, and the standard that a later AGENT.md phase
|
||||
Each entry lists the location, the problem, and the standard that a later AGENTS.md phase
|
||||
should encode.
|
||||
|
||||
1. Parameter ID tables duplicated three times.
|
||||
|
||||
@@ -53,6 +53,7 @@ set(SERUMALT_SOURCES
|
||||
Source/EffectUnits/Delay.cpp
|
||||
Source/EffectUnits/Reverb.cpp
|
||||
Source/GUI/Knob.cpp
|
||||
Source/GUI/KnobLookAndFeel.cpp
|
||||
Source/GUI/Slider.cpp
|
||||
Source/GUI/ToggleButton.cpp
|
||||
Source/GUI/Display.cpp
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
# Prepared janitor tasks
|
||||
|
||||
Prepared from the current working tree on branch `main`, using the `graft` context
|
||||
graph and a read-only verification pass. All 14 tasks were then dispatched to
|
||||
`janitors-coder` agents on the user's confirmation and completed. See the
|
||||
Execution status section at the end of this file for what each agent changed and
|
||||
the one remaining limitation.
|
||||
|
||||
## How to read this file
|
||||
|
||||
Each task is a handoff for one `janitors-coder` agent. The shape follows the
|
||||
janitor skill: observed problem, desired behavior, file locations, acceptance
|
||||
check, and conflicts.
|
||||
|
||||
Task types are `Defect fix` when current behavior is wrong for the user, and
|
||||
`Cleanup` when the change should preserve behavior. Fixes that change what the
|
||||
user sees or hears are marked `Risk`.
|
||||
|
||||
Baseline at preparation time. A clean harness build and CTest pass:
|
||||
`BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` reaches `100% tests
|
||||
passed`. The build also reports first-party compiler warnings (task JT-05).
|
||||
|
||||
## Ownership before dispatch
|
||||
|
||||
The worktree is dirty. These files have uncommitted edits and should be
|
||||
committed or frozen before any task touches them:
|
||||
|
||||
```text
|
||||
Source/FXProcessor.cpp Source/PluginEditor.cpp
|
||||
Source/SynthVoice.cpp Source/SynthVoice.h
|
||||
README.md AGENTS.md
|
||||
AUDIT_REPORT.md opencode.json
|
||||
Source/AGENT.md -> AGENTS.md (staged rename)
|
||||
Source/*/AGENT.md -> AGENTS.md (staged renames)
|
||||
```
|
||||
|
||||
The `Source/FXProcessor.cpp` edit repairs code that does not compile at `HEAD`
|
||||
(`HEAD` declares a two-dimensional `units` array in `FXProcessor.h` but indexes
|
||||
it as one-dimensional in `FXProcessor.cpp`). Commit it first. Do not let two
|
||||
agents edit the same file at once.
|
||||
|
||||
## Task index
|
||||
|
||||
| Id | Title | Type | Priority | Primary files |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| JT-01 | Remove the unused look-and-feel | Cleanup | P1 | `Source/PluginEditor.h`, `Source/GUI/` |
|
||||
| JT-02 | Make the LFO preview use the real LFO shape | Defect fix | P1 | `Source/LFO.{h,cpp}`, `Source/GUI/LFODisplay.{h,cpp}` |
|
||||
| JT-03 | Give macros one set of names | Defect fix | P1 | `Source/MacroControls.*`, `Source/PluginProcessor.cpp`, `Source/PluginEditor.cpp` |
|
||||
| JT-04 | Report rejected mod and macro inserts | Defect fix | P1 | `Source/PluginEditor.cpp` |
|
||||
| JT-05 | Drive first-party compiler warnings to zero | Cleanup | P2 | several `Source/` files |
|
||||
| JT-06 | Use `FXProcessor::fxTypeName` for the FX type list | Cleanup | P2 | `Source/PluginEditor.cpp` |
|
||||
| JT-07 | Remove unused members and dead tracking | Cleanup | P2 | `Source/PluginEditor.h`, `Source/Filter.*`, `Source/Engine.*` |
|
||||
| JT-08 | Remove dead parameters and discarded computations | Cleanup | P2 | `Source/SubOscillator.*`, `Source/Oscillator.*`, `Source/Wavetable.cpp`, `Source/Filter.*`, `Source/FilterBank.*` |
|
||||
| JT-09 | De-duplicate the test render loop | Cleanup | P2 | `Source/Tests/TestMain.cpp` |
|
||||
| JT-10 | Unify pi and two-pi constants | Cleanup | P2 | `Source/EffectUnits/*`, `Source/Wavetable.cpp`, `Source/SubOscillator.h`, `Source/GUI/LFODisplay.cpp`, `Source/EffectUnits/Biquad.h` |
|
||||
| JT-11 | Share the envelope curve and mark the filter preview | Cleanup | P2 | `Source/Envelope.*`, `Source/GUI/EnvelopeDisplay.cpp`, `Source/GUI/FilterDisplay.cpp` |
|
||||
| JT-12 | Reuse `paramIds::` in `PluginProcessor` | Cleanup | P3 | `Source/PluginProcessor.cpp` |
|
||||
| JT-13 | Guard the mono-buffer and capacity assumptions | Hardening | P3 | `Source/FXProcessor.cpp`, `Source/SynthVoice.cpp` |
|
||||
| JT-14 | Fix stale guidance and remaining prose slop | Cleanup | P3 | `AGENTS.md` files, `.claude/`, `build_windows.sh`, `AUDIT_REPORT.md` |
|
||||
|
||||
---
|
||||
|
||||
## JT-01 Remove the unused look-and-feel
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P1.
|
||||
Risk. None visible. The class was never installed, so deleting it changes nothing
|
||||
on screen. This outcome was chosen by the user on 2026-05-10: keep JUCE defaults.
|
||||
|
||||
Observed problem. `Source/PluginEditor.h:39` declares `SerumLookAndFeel laf`, and
|
||||
no source file installs it. A repo-wide search finds no `setLookAndFeel` or
|
||||
`setDefaultLookAndFeel` call, and `git log -S setLookAndFeel` shows none ever
|
||||
existed. Every control draws with JUCE's default look-and-feel, and the custom
|
||||
`drawRotarySlider` and `drawLinearSlider` in `Source/GUI/SerumLookAndFeel.h` have
|
||||
never run. The class and its member are dead, and `Source/GUI/AGENTS.md:19` still
|
||||
requires controls to be drawn through it. `Source/GUI/Knob.h:11` repeats the claim.
|
||||
|
||||
Desired behavior. JUCE's default look-and-feel stays in use, the dead class and
|
||||
member are gone, and the guidance matches the code.
|
||||
|
||||
Work. Delete `Source/GUI/SerumLookAndFeel.h`. Remove the include at
|
||||
`Source/PluginEditor.h:5` and the `laf` member at `Source/PluginEditor.h:39`.
|
||||
Rewrite the comment at `Source/GUI/Knob.h:11` so it no longer says the knob is
|
||||
styled by `SerumLookAndFeel`. In `Source/GUI/AGENTS.md`, drop `SerumLookAndFeel.h`
|
||||
from Owned files and remove the "Draw all controls through `SerumLookAndFeel`"
|
||||
rule. The header is header-only and absent from `SERUMALT_SOURCES`, so CMake needs
|
||||
no change.
|
||||
|
||||
Files in scope. `Source/PluginEditor.h`, `Source/GUI/SerumLookAndFeel.h`
|
||||
(delete), `Source/GUI/Knob.h`, `Source/GUI/AGENTS.md`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes.
|
||||
`grep -rn "SerumLookAndFeel" Source/` returns nothing. The GUI is unchanged,
|
||||
because the class was never installed.
|
||||
|
||||
---
|
||||
|
||||
## JT-02 Make the LFO preview use the real LFO shape
|
||||
|
||||
Type. Defect fix.
|
||||
Priority. P1.
|
||||
Risk. Changes the LFO display. The audio path should not change.
|
||||
|
||||
Observed problem. `Source/GUI/LFODisplay.cpp:23-52` re-implements every LFO
|
||||
shape. The audio path implements the same shapes in `Source/LFO.cpp:65-96`. Two
|
||||
problems follow.
|
||||
|
||||
1. Sample and hold disagrees. The display computes a fixed multiplicative hash
|
||||
at `Source/GUI/LFODisplay.cpp:31-32`
|
||||
(`((step * 2654435761u) & 0xffffu) / 32768.0f - 1.0f`), while the audio
|
||||
returns `holdValue`, which comes from `juce::Random`
|
||||
(`Source/LFO.cpp:13,79,131-133`). The preview shows a sequence the synth
|
||||
never plays.
|
||||
2. The remaining shapes can drift. Triangle, saw, square, step and freehand are
|
||||
copied at `LFODisplay.cpp:26-28,34-52` and `LFO.cpp:73,75,77,80-92`.
|
||||
|
||||
The display also hardcodes `64` at `Source/GUI/LFODisplay.cpp:8,9,10,17`, while
|
||||
`LFO.h:16` already exposes `static constexpr int kShapePoints = 64;`.
|
||||
|
||||
Desired behavior. One function decides the value of a shape, and both the audio
|
||||
render and the display call it. The preview then cannot disagree with the sound.
|
||||
|
||||
Work. Extract the shape switch from `LFO::shapeValue` (`Source/LFO.cpp:65-96`)
|
||||
into a shared, pure function that takes phase, shape, step count and the shape
|
||||
buffer, for example `float lfoShapeValue (float phase, LfoShape shape, int steps,
|
||||
const float* data, int dataSize)`. Call it from both `LFO::shapeValue` and
|
||||
`LFODisplay::valueAt`. Replace the hardcoded `64` in `LFODisplay` with
|
||||
`LFO::kShapePoints`. For sample and hold, the preview should not invent values;
|
||||
either have the display read the live hold value through `Engine::getLfos()`
|
||||
(`Source/Engine.h:42`), or draw the S&H shape as a clearly labelled schematic and
|
||||
stop pretending it is the played sequence.
|
||||
|
||||
Files in scope. `Source/LFO.h`, `Source/LFO.cpp`, `Source/GUI/LFODisplay.h`,
|
||||
`Source/GUI/LFODisplay.cpp`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. A
|
||||
focused check confirms the display and `LFO::shapeValue` return the same value for
|
||||
the same phase and shape, including sample and hold. State which S&H approach you
|
||||
took.
|
||||
|
||||
---
|
||||
|
||||
## JT-03 Give macros one set of names
|
||||
|
||||
Type. Defect fix.
|
||||
Priority. P1.
|
||||
Risk. The macro parameter display names change in the host. Parameter IDs do not
|
||||
change, so saved projects still load.
|
||||
|
||||
Observed problem. The same four macros have two names. `Source/MacroControls.cpp:30-40`
|
||||
returns "Energy", "Width", "Drive" and "Atmosphere". The APVTS parameters are
|
||||
named "Macro 1" through "Macro 4" at `Source/PluginProcessor.cpp:168-171`, and
|
||||
the assignment combo repeats "Macro 1" through "Macro 4" at
|
||||
`Source/PluginEditor.cpp:686`. The macro panels and the macro list use
|
||||
`MacroControls::macroName` (`Source/PluginEditor.cpp:668-669,948`). A user sees
|
||||
"Energy" on the panel and "Macro 1" in the host automation lane and in the assign
|
||||
combo.
|
||||
|
||||
Desired behavior. One source of truth for the four macro names.
|
||||
|
||||
Work. Make `MacroControls::macroName` the source of truth. Use it to build the
|
||||
APVTS display names in `PluginProcessor.cpp` and the `macroAssignIndex` items in
|
||||
`PluginEditor.cpp`. Leave the parameter IDs (`ids::macro1` and friends) and the
|
||||
normalized values unchanged.
|
||||
|
||||
Files in scope. `Source/MacroControls.h`, `Source/MacroControls.cpp`,
|
||||
`Source/PluginProcessor.cpp`, `Source/PluginEditor.cpp`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. The
|
||||
host automation names, the assign combo and the panel labels show the same four
|
||||
strings. `grep -rn '"Macro 1"' Source/` returns only
|
||||
`Source/Params.cpp:22`, which is the stable `ModSource` serialisation token used by
|
||||
saved state and must not change.
|
||||
|
||||
---
|
||||
|
||||
## JT-04 Report rejected mod and macro inserts
|
||||
|
||||
Type. Defect fix.
|
||||
Priority. P1.
|
||||
Risk. Small UI change, no audio change.
|
||||
|
||||
Observed problem. `ModulationMatrix::addConnection` and
|
||||
`MacroControls::addAssignment` return `bool` and reject inserts past
|
||||
`kMaxConnections` or `kMaxAssignments` (`Source/ModulationMatrix.cpp:6-12`,
|
||||
`Source/MacroControls.cpp:6-13`). The editor ignores both results at
|
||||
`Source/PluginEditor.cpp:550` and `Source/PluginEditor.cpp:716`, so a click that
|
||||
fails looks identical to a click that worked. The list does not change and no
|
||||
message appears.
|
||||
|
||||
Desired behavior. A rejected insert is visible to the user.
|
||||
|
||||
Work. Check the returned value at both call sites. The smallest useful fix is to
|
||||
disable or grey the Add buttons when the relevant count reaches its cap, and to
|
||||
keep the list text and selection unchanged on rejection. A status message is also
|
||||
acceptable. Do not change the caps.
|
||||
|
||||
Files in scope. `Source/PluginEditor.cpp`. Read the caps from
|
||||
`ModulationMatrix::kMaxConnections` and `MacroControls::kMaxAssignments`; do not
|
||||
hardcode the numbers.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. With
|
||||
the connection or assignment count at its cap, the Add control is visibly
|
||||
unavailable and clicking it changes nothing.
|
||||
|
||||
---
|
||||
|
||||
## JT-05 Drive first-party compiler warnings to zero
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P2.
|
||||
|
||||
Observed problem. The harness build compiles with `-Wall -Wextra` plus JUCE's
|
||||
recommended warning flags and reports more than fifty warning lines from files
|
||||
under `Source/`. Representative sites, all current:
|
||||
|
||||
- `Source/SynthVoice.cpp:131,138` sign conversion from `int` to `size_t`.
|
||||
- `Source/GUI/LFODisplay.cpp:11,12,16,32` sign conversion and a float equality.
|
||||
- `Source/PluginEditor.cpp:88` `fmtSlope` is defined and never used.
|
||||
- `Source/Oscillator.cpp:15` unused parameter `freqHz`.
|
||||
- `Source/GUI/Slider.cpp:6` a local named `formatter` shadows a member.
|
||||
- `Source/GUI/Knob.cpp:77` a local named `cursor` shadows a member.
|
||||
- `Source/Params.cpp:8,33,58,97`, `Source/Oscillator.cpp:85,108`,
|
||||
`Source/LFO.cpp:68`, `Source/Filter.cpp:57`, `Source/FXProcessor.cpp:53`,
|
||||
`Source/GUI/FilterDisplay.cpp:13`, `Source/GUI/LFODisplay.cpp:23`
|
||||
`-Wswitch-enum` cases.
|
||||
- `-Wfloat-equal` comparisons in `Source/GUI/EnvelopeDisplay.cpp:8`,
|
||||
`Source/GUI/FilterDisplay.cpp:40`, `Source/GUI/WaveformDisplay.h:18`,
|
||||
`Source/Filter.cpp:99`, `Source/LFO.cpp:43`, `Source/Oscillator.cpp:28,29`,
|
||||
`Source/MacroControls.cpp:82`, `Source/PluginProcessor.cpp:409`.
|
||||
|
||||
Desired behavior. The harness build reports no warnings from `Source/`. Warnings
|
||||
from `third_party/JUCE` are out of scope.
|
||||
|
||||
Work. List the warnings with:
|
||||
|
||||
```bash
|
||||
BUILD_DIR=/tmp/serumalt-check ./build_harness.sh 2>&1 | grep 'warning:' | grep -v third_party
|
||||
```
|
||||
|
||||
Fix the unused function, the unused parameter and the two shadowed locals by
|
||||
deletion or rename. Fix the sign-conversion sites with explicit `(size_t)` or
|
||||
`(juce::uint32)` casts, matching the surrounding style. For `-Wswitch-enum`, name
|
||||
every enumerator explicitly. For `-Wfloat-equal`, check the meaning of each
|
||||
comparison first. Some are deliberate cache keys that compare a stored parameter
|
||||
against the current one. If the exact comparison is intended, keep the behavior
|
||||
and make the intent explicit with a small named helper or a narrow, commented
|
||||
suppression. Do not silently weaken a change-detection check.
|
||||
|
||||
Files in scope. The `Source/` files named above and any other first-party file
|
||||
that the command reports.
|
||||
|
||||
Acceptance. The command above prints nothing from `Source/`.
|
||||
`BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes.
|
||||
|
||||
---
|
||||
|
||||
## JT-06 Use `FXProcessor::fxTypeName` for the FX type list
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P2.
|
||||
|
||||
Observed problem. `FXProcessor::fxTypeName` is declared at
|
||||
`Source/FXProcessor.h:47` and defined at `Source/FXProcessor.cpp:51-67` with no
|
||||
callers. The same ten labels are written again as a literal `juce::StringArray`
|
||||
at `Source/PluginEditor.cpp:604-605`. The two lists must stay in the same order as
|
||||
the `FxType` enum, and nothing enforces that.
|
||||
|
||||
Desired behavior. The FX type combo is built from the one label function.
|
||||
|
||||
Work. In `PluginEditor::buildFxTab`, replace the literal array with a loop over
|
||||
`(int) FxType::Count` that adds `FXProcessor::fxTypeName (t)`. Confirm the combo
|
||||
indices still line up with `FxType` values, since the combo index is used
|
||||
directly as the parameter value (`Source/PluginEditor.cpp:246` area and the FX
|
||||
type attachment).
|
||||
|
||||
Files in scope. `Source/PluginEditor.cpp`, `Source/FXProcessor.h`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. The
|
||||
combo shows Off through Reverb in enum order and selecting a type still sets the
|
||||
matching slot type. `FXProcessor::fxTypeName` now has a caller.
|
||||
|
||||
Conflict. `Source/FXProcessor.cpp` and `Source/PluginEditor.cpp` both have
|
||||
uncommitted edits. Commit or freeze them first.
|
||||
|
||||
---
|
||||
|
||||
## JT-07 Remove unused members and dead tracking
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P2.
|
||||
|
||||
Observed problem. Four unused declarations remain:
|
||||
|
||||
1. `Source/PluginEditor.h:66` declares
|
||||
`std::vector<juce::Component*> oscAComponents, oscBComponents;`. Neither name
|
||||
appears anywhere else in `Source/`.
|
||||
2. `Filter::svfLow` and `Filter::svfHigh` are declared at
|
||||
`Source/Filter.h:53,55` and defined at `Source/Filter.cpp:108,130`. Each name
|
||||
occurs twice, at the declaration and the definition. The sibling `svfBand` is
|
||||
used at `Source/Filter.cpp:222`.
|
||||
3. `Engine::getActiveVoiceCount` (`Source/Engine.h:46`) has no callers, so
|
||||
`activeVoiceCount` (`Source/Engine.h:70`) is written at
|
||||
`Source/Engine.cpp:59` and `Source/Engine.cpp:363` and never read. The per-block
|
||||
tally at `Source/Engine.cpp:360-362` exists only to feed it.
|
||||
4. `PluginEditor.cpp:88` `fmtSlope` is unused, which the compiler also reports
|
||||
(task JT-05 overlaps here; one agent should own both).
|
||||
|
||||
Desired behavior. No unused storage and no work that feeds nothing.
|
||||
|
||||
Work. Delete the two `PluginEditor` vectors. Delete `svfLow` and `svfHigh` from
|
||||
the header and the implementation. Delete the getter, the atomic field, the
|
||||
`reset()` store and the per-block tally, unless you can point to an intended
|
||||
caller and wire it up instead. Delete `fmtSlope` unless a knob needs it; the
|
||||
slope readout currently uses a different formatter.
|
||||
|
||||
Files in scope. `Source/PluginEditor.h`, `Source/Filter.h`, `Source/Filter.cpp`,
|
||||
`Source/Engine.h`, `Source/Engine.cpp`. Optionally `Source/PluginEditor.cpp` for
|
||||
`fmtSlope`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes and
|
||||
`grep -rn "oscAComponents\|oscBComponents\|svfLow\|svfHigh\|getActiveVoiceCount\|activeVoiceCount" Source/`
|
||||
returns nothing.
|
||||
|
||||
---
|
||||
|
||||
## JT-08 Remove dead parameters and discarded computations
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P2.
|
||||
|
||||
Observed problem. Four sites compute or accept values that nothing uses:
|
||||
|
||||
1. `Source/SubOscillator.cpp:9-11` computes `mult` from `octaveShift`, then
|
||||
discards it and `freqHz` with `(void)` casts. Only `phase = 0.0;` has an
|
||||
effect. The octave shift is already baked into `freqHz` by the voice
|
||||
(`Source/SynthVoice.cpp:200,212`). The only caller passes `-1` for the shift
|
||||
(`Source/SynthVoice.cpp:58`).
|
||||
2. `Source/Oscillator.cpp:15` accepts `freqHz` only to feed
|
||||
`jassert (freqHz > 0.0)`. In a release build the assert compiles out and the
|
||||
parameter is unused.
|
||||
3. `Source/Wavetable.cpp:238` and `:244` compute `const float u = ...` and
|
||||
discard it with `(void) u` at `:240` and `:246`, while `:248` immediately
|
||||
recomputes the same expression as `tt`, which is the value actually used.
|
||||
4. `Source/Filter::prepare` accepts `maxBlockSize` and discards it at
|
||||
`Source/Filter.cpp:28`. `FilterBank::prepare` forwards it to four `Filter`
|
||||
objects (`Source/FilterBank.cpp:8-11`), so the parameter is dead end to end.
|
||||
|
||||
Desired behavior. Signatures and locals describe what the code uses.
|
||||
|
||||
Work. Reduce `SubOscillator::noteOn` to the work it does, which is resetting
|
||||
phase, and update the one caller. Drop `freqHz` from `Oscillator::noteOn` and
|
||||
update the two callers at `Source/SynthVoice.cpp:170-171`. Delete the two `u`
|
||||
locals and both `(void) u` casts in `Wavetable.cpp`, keeping `tt`. Drop
|
||||
`maxBlockSize` from `Filter::prepare`, `FilterBank::prepare` and their headers,
|
||||
and update the `FilterBank` call at `Source/SynthVoice.cpp:20`. The `FXUnit`
|
||||
interface keeps its `prepare (double, int)` shape because all units share it, and
|
||||
the units that ignore the block size already leave the parameter unnamed, which is
|
||||
the existing convention. See `Source/EffectUnits/AGENTS.md` and
|
||||
`Source/synth/AGENTS.md`.
|
||||
|
||||
Files in scope. `Source/SubOscillator.h`, `Source/SubOscillator.cpp`,
|
||||
`Source/Oscillator.h`, `Source/Oscillator.cpp`, `Source/Wavetable.cpp`,
|
||||
`Source/Filter.h`, `Source/Filter.cpp`, `Source/FilterBank.h`,
|
||||
`Source/FilterBank.cpp`, `Source/SynthVoice.cpp`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes.
|
||||
`grep -rn "(void)" Source/SubOscillator.cpp Source/Wavetable.cpp Source/Filter.cpp`
|
||||
returns nothing. The sub oscillator still tracks the voice frequency, because
|
||||
that frequency is applied in `SynthVoice::render`.
|
||||
|
||||
Conflict. `Source/SynthVoice.cpp` has uncommitted edits.
|
||||
|
||||
---
|
||||
|
||||
## JT-09 De-duplicate the test render loop
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P2.
|
||||
|
||||
Observed problem. `Source/Tests/TestMain.cpp` repeats the same render loop twice.
|
||||
The init-preset block at lines 49-74 and the per-preset block at lines 76-105 do
|
||||
the same work: build three note-ons, render 240 blocks of 512 samples, release at
|
||||
block 180, accumulate a NaN flag and a peak, compare against `0.001f`, print, and
|
||||
count failures. The RAVE block at lines 107-130 is a smaller variant.
|
||||
|
||||
Desired behavior. One helper runs a preset and returns whether it passed.
|
||||
|
||||
Work. Extract a function, for example
|
||||
`bool runPreset (serum::SerumAltAudioProcessor&, const juce::String& label, double maxPeakFloor)`,
|
||||
and call it for the init case and for each factory preset. Keep the output format
|
||||
and the exit code behavior. Keep the rule from `Source/Tests/AGENTS.md`: check
|
||||
non-silence across the whole render, not per block, because a release can produce
|
||||
a legitimately silent block.
|
||||
|
||||
Files in scope. `Source/Tests/TestMain.cpp`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes and
|
||||
still prints one PASS line per preset.
|
||||
|
||||
---
|
||||
|
||||
## JT-10 Unify pi and two-pi constants
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P2.
|
||||
|
||||
Observed problem. `Source/synth/AGENTS.md` and
|
||||
`Source/EffectUnits/AGENTS.md` require `juce::MathConstants`. Raw literals and
|
||||
local constants remain:
|
||||
|
||||
- `Source/SubOscillator.h:26` `kTwoPi = 6.28318530717958647692`.
|
||||
- `Source/Wavetable.cpp:21` `twoPi = 6.28318530717958647692`.
|
||||
- `Source/Wavetable.cpp:156` `kPi = 3.14159265358979323846f`.
|
||||
- `Source/EffectUnits/Chorus.cpp:40,41` two-pi literals, `:46` a pi literal.
|
||||
- `Source/EffectUnits/Flanger.cpp:38,39` two-pi literals.
|
||||
- `Source/EffectUnits/Phaser.cpp:30,31` two-pi literals.
|
||||
- `Source/GUI/LFODisplay.cpp:25` `6.2831853f`.
|
||||
- `Source/EffectUnits/Biquad.h:31,45,60,75,90` write
|
||||
`2.0 * juce::MathConstants<double>::pi`, which should be `twoPi`.
|
||||
|
||||
Already converted and correct: `Source/Oscillator.h:72`, `Source/Filter.cpp:8`,
|
||||
`Source/LFO.cpp:71`, `Source/Oscillator.cpp:63` (half pi, leave it).
|
||||
|
||||
Desired behavior. One spelling for each constant, taken from
|
||||
`juce::MathConstants`.
|
||||
|
||||
Work. Replace each literal with `juce::MathConstants<double>::twoPi` or
|
||||
`juce::MathConstants<float>::twoPi` as the surrounding precision requires, and
|
||||
`pi` where only pi is meant. Remove the now-unused local constants. Keep the
|
||||
per-sample DSP value types unchanged, so an `f`-suffixed literal becomes
|
||||
`juce::MathConstants<float>::twoPi` and a `double` literal becomes the `double`
|
||||
form.
|
||||
|
||||
Files in scope. `Source/SubOscillator.h`, `Source/SubOscillator.cpp`,
|
||||
`Source/Wavetable.cpp`, `Source/EffectUnits/Chorus.cpp`,
|
||||
`Source/EffectUnits/Flanger.cpp`, `Source/EffectUnits/Phaser.cpp`,
|
||||
`Source/EffectUnits/Biquad.h`, `Source/GUI/LFODisplay.cpp`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes.
|
||||
`grep -rn "6.28318530717958647692\|3.14159265358979323846\|6.2831853f" Source/`
|
||||
returns nothing. The harness reports no NaN and preset peaks match the values
|
||||
from the unmodified build.
|
||||
|
||||
---
|
||||
|
||||
## JT-11 Share the envelope curve and mark the filter preview
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P2.
|
||||
|
||||
Observed problem. Two smaller duplications remain between DSP and display.
|
||||
|
||||
1. Envelope curve. `Source/Envelope.cpp:44-45` computes
|
||||
`attackShape = 0.3 + curve * 2.7` and `decayShape = 3.0 - curve * 2.7`.
|
||||
`Source/GUI/EnvelopeDisplay.cpp:22-23` repeats both formulas with `f` suffixes.
|
||||
2. Filter preview. `Source/GUI/FilterDisplay.cpp:6-36` re-implements a magnitude
|
||||
response that does not match the DSP in `Source/Filter.cpp:87-88`. The display
|
||||
uses `q = 0.6f + res * 14.0f`, while the DSP uses
|
||||
`k = clamp (2.0 * (1.0 - res), 0.05, 2.0)`. The header calls the view
|
||||
approximate (`Source/GUI/FilterDisplay.h:11,17`), but the re-implementation
|
||||
site has no comment at all.
|
||||
|
||||
Desired behavior. The envelope preview cannot drift from the envelope DSP, and
|
||||
the filter preview states at the code that it is schematic.
|
||||
|
||||
Work. Add one shared helper for the curve mapping, for example
|
||||
`attackShapeFor (float curve)` and `decayShapeFor (float curve)` in
|
||||
`Source/Envelope.h`, and call it from both `Envelope::setParams` and
|
||||
`EnvelopeDisplay::paint`. Add a short comment at the top of
|
||||
`FilterDisplay::magnitude` that says the response is a schematic preview and does
|
||||
not model the DSP, the drive, the slope or modulation, and point to
|
||||
`Source/Filter.cpp`. Do not try to make the preview numerically match the
|
||||
nonlinear DSP.
|
||||
|
||||
Files in scope. `Source/Envelope.h`, `Source/Envelope.cpp`,
|
||||
`Source/GUI/EnvelopeDisplay.cpp`, `Source/GUI/FilterDisplay.cpp`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes.
|
||||
`grep -n "2.7" Source/Envelope.h Source/Envelope.cpp Source/GUI/EnvelopeDisplay.cpp`
|
||||
returns the two uses in the shared helper only, and nothing in the two callers.
|
||||
`FilterDisplay.cpp` carries the approximate comment.
|
||||
|
||||
---
|
||||
|
||||
## JT-12 Reuse `paramIds::` in `PluginProcessor`
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P3.
|
||||
|
||||
Observed problem. `Source/Params.h:229-269` defines the `paramIds::` indexed
|
||||
groups. `Source/Engine.cpp` and `Source/PluginEditor.cpp` now iterate those
|
||||
groups. `Source/PluginProcessor.cpp` still builds its own arrays that mirror the
|
||||
same groups:
|
||||
|
||||
- `:102-106` five envelope arrays.
|
||||
- `:121-127` seven LFO arrays.
|
||||
- `:142-155` FX type, mix and parameter arrays.
|
||||
|
||||
The arrays reference `ids::`, so no string literal is duplicated. The structure
|
||||
is.
|
||||
|
||||
Desired behavior. The parameter layout walks the shared groups instead of
|
||||
re-declaring parallel arrays.
|
||||
|
||||
Work. Replace the local arrays with `paramIds::envAttack` and the other groups, or
|
||||
iterate them directly. Keep the parameter names and defaults exactly as they are,
|
||||
because they define the host-visible layout and the saved-state order. Do not
|
||||
change parameter IDs.
|
||||
|
||||
Files in scope. `Source/PluginProcessor.cpp`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes.
|
||||
The parameter count and order are unchanged. A saved state from before the change
|
||||
still loads.
|
||||
|
||||
---
|
||||
|
||||
## JT-13 Guard the mono-buffer and capacity assumptions
|
||||
|
||||
Type. Hardening.
|
||||
Priority. P3.
|
||||
Risk. No behavior change for the current 2-channel, 64-sample path.
|
||||
|
||||
Observed problem. `FXProcessor::process` reads and writes channel 1
|
||||
unconditionally (`Source/FXProcessor.cpp:92,98`). It is safe today only because
|
||||
`Engine` always hands it a 2-channel view (`Source/Engine.cpp:307`). A future
|
||||
one-channel caller reads a null channel. Similarly, `FXProcessor::process` and
|
||||
`SynthVoice::render` trust the sample count to fit the buffers sized in
|
||||
`prepare` (`Source/FXProcessor.cpp:38`, `Source/SynthVoice.cpp:23`) with no
|
||||
assert. The engine's 64-sample segmentation (`Source/Engine.cpp:270`) is the only
|
||||
thing keeping that true.
|
||||
|
||||
Desired behavior. The boundaries are explicit, and a debug build catches a
|
||||
violation instead of corrupting memory.
|
||||
|
||||
Work. In `FXProcessor::process`, handle `buffer.getNumChannels() < 2` by
|
||||
processing only the channels present, or by returning early with a `jassert`.
|
||||
Add `jassert (n <= wet.getNumSamples())` and
|
||||
`jassert (numSamples <= scratch.getNumSamples())` at the top of the two render
|
||||
functions. Do not add allocation and do not change the audio-thread behavior for
|
||||
valid input.
|
||||
|
||||
Files in scope. `Source/FXProcessor.cpp`, `Source/SynthVoice.cpp`.
|
||||
|
||||
Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. A
|
||||
debug build asserts when `SynthVoice::render` is called with
|
||||
`numSamples > scratch.getNumSamples()`.
|
||||
|
||||
Conflict. Both files have uncommitted edits.
|
||||
|
||||
---
|
||||
|
||||
## JT-14 Fix stale guidance and remaining prose slop
|
||||
|
||||
Type. Cleanup.
|
||||
Priority. P3.
|
||||
|
||||
Observed problem. Two kinds of documentation drift.
|
||||
|
||||
Guidance that contradicts the code:
|
||||
|
||||
- `Source/params/AGENTS.md` says to use the `ids::` namespace for every
|
||||
parameter ID and never to re-declare one outside `Params.h`. It does not mention
|
||||
the `paramIds::` indexed groups at `Source/Params.h:229`, which the engine and
|
||||
editor now use.
|
||||
- `Source/synth/AGENTS.md` says "Wavetables are prebuilt during Engine
|
||||
construction" but its example still ends with `// in prepareToPlay`. The real
|
||||
call is `Source/Engine.cpp:22` in the constructor.
|
||||
- `Source/engine/AGENTS.md` shows a "GOOD" example that calls
|
||||
`mixBuffer.clear()` in `processBlock`. The code instead builds a non-owning view
|
||||
per segment (`Source/Engine.cpp:307`). Update the example to match.
|
||||
|
||||
Prose that still violates the unslop rules:
|
||||
|
||||
- `.claude/skills/graft/SKILL.md` carries em dashes, an emoji at line 129,
|
||||
boldface across whole sentences, and colon connectors. This is the largest
|
||||
remaining prose problem.
|
||||
- `build_windows.sh:3` has an em dash, and `:58-59` uses a colon as a connector.
|
||||
- `AUDIT_REPORT.md:123,141,247,320,322` use colons as mid-sentence connectors, and
|
||||
`:286` reads "very sparingly".
|
||||
- `.claude/helpers/graft-hooks.cjs:67` and `.claude/helpers/graft-statusline.cjs:67`
|
||||
have an em dash in a trailing comment.
|
||||
- Root `AGENTS.md` has nine em dashes inside the generated
|
||||
`<!-- graft:start -->` block. That block is produced by `graft init` from a
|
||||
Graft template, so editing it in place will be overwritten on the next
|
||||
`graft init`. Fix the template source, or leave the block and note it as
|
||||
generated.
|
||||
|
||||
Desired behavior. Guidance matches the code, and prose reads plainly.
|
||||
|
||||
Work. Apply the fixes above. For each unslop edit, replace em dashes with a
|
||||
comma or a full stop, split colon connectors into two sentences, remove the
|
||||
emoji, and unbold body text. Keep literal technical terms such as "harness",
|
||||
"vector" and "surface" when they name a real thing. Do not edit
|
||||
`third_party/JUCE`.
|
||||
|
||||
Files in scope. `Source/params/AGENTS.md`, `Source/synth/AGENTS.md`,
|
||||
`Source/engine/AGENTS.md`, `.claude/skills/graft/SKILL.md`,
|
||||
`.claude/helpers/graft-hooks.cjs`, `.claude/helpers/graft-statusline.cjs`,
|
||||
`build_windows.sh`, `AUDIT_REPORT.md`, and root `AGENTS.md`.
|
||||
|
||||
Acceptance. `bash -n` passes for `build_*.sh`.
|
||||
`grep -rn "—" AGENTS.md Source/*/AGENTS.md build_windows.sh .claude/skills/graft/SKILL.md`
|
||||
returns nothing outside the generated graft block. A build and harness run still
|
||||
pass, since only comments and prose changed.
|
||||
|
||||
Conflict. `AGENTS.md`, `AUDIT_REPORT.md` and the `Source/**/AGENTS.md` files have
|
||||
uncommitted edits. Commit or freeze them first.
|
||||
|
||||
---
|
||||
|
||||
## Considered and rejected
|
||||
|
||||
These came up during verification and are deliberately not tasks.
|
||||
|
||||
- Rename `Source/RAVEButton.{h,cpp}` to match `class RaveController`. Both
|
||||
`Source/modulation/AGENTS.md:19` and root `AGENTS.md` say to keep the filenames
|
||||
unless a rename is in scope.
|
||||
- Rewrite `AUDIT_REPORT.md`. Its own header and root `AGENTS.md` mark it as a
|
||||
historical snapshot, so it is not a pending instruction. Only its prose is
|
||||
touched, in JT-14.
|
||||
- Move `PluginEditor`, `FXProcessor`, `Resources` and `RAVEButton` into
|
||||
subdirectories. The proposed moves in `AUDIT_REPORT.md` are suggestions, and
|
||||
root `AGENTS.md` says the current layout is intentional until a move is part of
|
||||
a requested task.
|
||||
- Make `ModulationMatrix::connections` and `MacroControls::assignments` private
|
||||
and force all mutation through locked members. Every current caller already
|
||||
holds `Engine::getControlLock()`, and the change is a wide interface refactor,
|
||||
not cleanup. Revisit only if a caller appears outside the lock.
|
||||
- Make the lazy `WavetableLibrary::getTable` build branch unreachable. The
|
||||
constructor prebuilds every index (`Source/Engine.cpp:22`), so the branch is
|
||||
dead today. JT-13 covers the defensive asserts; deleting the lazy path is a
|
||||
separate design decision.
|
||||
- Add tests. `AGENTS.md` says to add test infrastructure only when requested.
|
||||
|
||||
## Proposed dispatch order
|
||||
|
||||
Dispatch one task at a time per file cluster. The conflicts column above names
|
||||
the files with uncommitted edits.
|
||||
|
||||
- Batch 1, independent files: JT-02, JT-09, JT-11, JT-13.
|
||||
- Batch 2, editor cluster, serial: JT-01, then JT-03, then JT-04, then JT-06.
|
||||
- Batch 3, dead-code cluster, serial across `SynthVoice` and `Filter`: JT-08,
|
||||
then JT-07.
|
||||
- Batch 4, wide edits, serial: JT-05, then JT-10, then JT-12, then JT-14.
|
||||
|
||||
Each agent must run the harness before handing back:
|
||||
|
||||
```bash
|
||||
BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run
|
||||
```
|
||||
|
||||
## Execution status
|
||||
|
||||
All 14 tasks were dispatched and completed. Final consolidated verification on the
|
||||
frozen tree:
|
||||
|
||||
```bash
|
||||
BUILD_DIR=/tmp/jt-final CMAKE_BUILD_PARALLEL_LEVEL=8 ./build_harness.sh --run
|
||||
```
|
||||
|
||||
Result: exit 0, `1/1 Test #1: SerumAltTest ... Passed`, `100% tests passed`, and
|
||||
zero first-party compiler warnings in `Source/` (down from 55 warning lines).
|
||||
|
||||
| Id | Result | Note |
|
||||
| --- | --- | --- |
|
||||
| JT-01 | Done | `Source/GUI/SerumLookAndFeel.h` deleted. The class was never installed, so the GUI is unchanged. |
|
||||
| JT-02 | Done | One `lfoShapeValue` function now serves `LFO` and `LFODisplay`. The sample-and-hold preview is a labelled schematic and no longer invents a sequence. |
|
||||
| JT-03 | Done | Macro names come from `MacroControls::macroName` in the APVTS, the assign combo, the panels and the list. Only the `Params.cpp` serialisation tokens keep the old strings. |
|
||||
| JT-04 | Done | Add and Assign grey out at `kMaxConnections` and `kMaxAssignments`, and rejected inserts no longer look like success. |
|
||||
| JT-05 | Done | 55 first-party warning lines to 0. New `Source/ExactCompare.h` names the deliberate exact float comparisons instead of weakening them to an epsilon. |
|
||||
| JT-06 | Done | The FX type combo is built from `FXProcessor::fxTypeName`; the duplicate literal list is gone. |
|
||||
| JT-07 | Done | Removed the unused `oscAComponents`/`oscBComponents`, `Filter::svfLow`/`svfHigh`, `Engine::getActiveVoiceCount` and the per-block tally, and the unused `fmtSlope`. |
|
||||
| JT-08 | Done | Removed the dead `SubOscillator` octave math, the assert-only `Oscillator::noteOn` frequency, the discarded `Wavetable` locals, and the unused `maxBlockSize` chain through `Filter` and `FilterBank`. |
|
||||
| JT-09 | Done | The duplicated test loops are one `runPreset` helper. Output format and peak values are unchanged. |
|
||||
| JT-10 | Done | Raw pi and two-pi literals replaced with `juce::MathConstants`. The only remaining match is the intentional "BAD" example in `Source/synth/AGENTS.md`. |
|
||||
| JT-11 | Done | The envelope curve mapping lives in `Envelope.h` and both callers use it. `FilterDisplay::magnitude` now states that it is a schematic preview. |
|
||||
| JT-12 | Done | `PluginProcessor::createParameterLayout` uses the `paramIds::` groups. A layout comparison confirmed all 96 `(id, name, default)` entries are identical and in the same order. |
|
||||
| JT-13 | Done | `FXProcessor::process` returns early on a mono buffer, and capacity asserts guard `FXProcessor::process` and `SynthVoice::render`. |
|
||||
| JT-14 | Done, one limitation | Guidance and prose fixed across the `AGENTS.md` files, `.claude/skills/graft/SKILL.md`, `build_windows.sh`, `AUDIT_REPORT.md` and the two helper scripts. Nine em dashes remain inside the generated `<!-- graft:start -->` block in root `AGENTS.md`. |
|
||||
|
||||
The JT-14 limitation. The graft block in root `AGENTS.md` is generated by the
|
||||
installed `@nanonets/graft` package, whose template carries the em dashes.
|
||||
`graft init` would reproduce them, so the block was left as generated. Removing
|
||||
them needs a change to the package template at
|
||||
`/home/biggy/Documents/Coding/_skills/Graft/src/hosts/instructions.ts` and a
|
||||
reinstall, which is outside this repository.
|
||||
|
||||
Other notes.
|
||||
|
||||
- The pre-existing uncommitted work in the tree was preserved throughout. It
|
||||
includes the `FXProcessor` per-slot repair, the `SynthVoice::getModulationValue`
|
||||
addition, the editor control-lock line, and the `AGENT.md` to `AGENTS.md`
|
||||
renames. `HEAD` does not compile without the `FXProcessor` repair, so commit the
|
||||
current tree before treating any single task as an isolated diff.
|
||||
- Every task ran its own harness build in `/tmp`, so no `build_*` directory in the
|
||||
repository was modified and `third_party/JUCE` was not touched.
|
||||
- No task changed APVTS parameter IDs, so saved projects stay compatible.
|
||||
@@ -1,70 +1,76 @@
|
||||
# SerumAlt
|
||||
|
||||
A modern, commercial-quality wavetable synthesiser plugin (VST3 / AU / Standalone)
|
||||
built with **JUCE 7**, **CMake** and **C++17**. SerumAlt is a self-contained synth
|
||||
inspired by the workflow of Serum/Vital: two morphing wavetable oscillators, a full
|
||||
filter section, four envelopes and LFOs, a modulation matrix, a reorderable FX rack,
|
||||
four macros and a one-click **RAVE** button.
|
||||
A wavetable synthesiser experiment built with JUCE 7, CMake and C++17. It targets
|
||||
VST3, AU and Standalone formats, with a workflow inspired by Serum and Vital.
|
||||
This is a fun proof of concept, not a commercial release. Development favors
|
||||
working builds and hands-on listening over elaborate test infrastructure.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Two wavetable oscillators** — 256 frames × 2048 samples, bilinear morphing,
|
||||
8 warp modes (Bend+/-, Sync, PWM, Asym, Mirror, Fold), unison 1–16 with phase
|
||||
spread/detune/width.
|
||||
- **10 factory wavetables** (Basic Shapes, Saw PWM, Square Sync, Triangle Fold,
|
||||
Vowel, Organ, Warm Saw, Digital, Glass, Bass), generated lazily and RAM-resident.
|
||||
- **Sub oscillator** (sine/triangle, −2/−1/0 oct) and **noise** (white/pink).
|
||||
- **Filter section** — two slots, 7 models (Ladder LP/HP/BP, Diode, Comb, Formant,
|
||||
Screamer), 6/12/24 dB slopes, drive, keytrack, and serial/parallel/split routing.
|
||||
- **4 ADSR envelopes** with curve tension and live preview.
|
||||
- **4 LFOs** — tempo-sync, 7 shapes (sine/tri/saw/square/S&H/step/freehand),
|
||||
editable step-sequencer and freehand drawing, fade-in and start delay.
|
||||
- **Modulation matrix** — direct source→destination connections with bipolar depth.
|
||||
- **FX rack** — 9 effects (Hyper/OTT, Chorus, Flanger, Phaser, Distortion, EQ,
|
||||
Compressor, Delay, Reverb) in 8 reorderable slots.
|
||||
- **4 macros** (Energy, Width, Drive, Atmosphere) with assignable destinations.
|
||||
- **RAVE** — one-click boost (unison, width, drive, OTT, reverb) with snapshot/restore.
|
||||
- **16 factory presets** with production-quality values.
|
||||
- **Scalable GUI** (75% / 100% / 125% / 150% / 200%) — dark modern vector look,
|
||||
Path-drawn controls, SVG logo/background, real-time waveform/filter/envelope/LFO
|
||||
displays.
|
||||
- Two wavetable oscillators, each with 256 frames of 2048 samples, bilinear
|
||||
morphing, seven warp effects plus bypass, and up to 16 unison voices.
|
||||
- Ten factory wavetables, built before audio rendering and kept in memory.
|
||||
- Sine/triangle sub oscillator and white/pink noise.
|
||||
- Two filter slots with seven models, drive, key tracking and serial, parallel
|
||||
or split routing.
|
||||
- Four ADSR envelopes and four free-running or tempo-synced LFOs, including
|
||||
editable step and freehand shapes.
|
||||
- A modulation matrix and four assignable macros.
|
||||
- Nine effect types in eight reorderable slots, with independent state per slot.
|
||||
- A non-destructive RAVE boost for unison, spread, drive, Hyper and reverb.
|
||||
- Sixteen factory presets and a scalable vector GUI with waveform, envelope,
|
||||
LFO and schematic filter previews.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Source/
|
||||
PluginProcessor.{h,cpp} APVTS, presets, state, RAVE/UI-scale glue
|
||||
PluginEditor.{h,cpp} Tabbed dark GUI, scaling, visualisations
|
||||
Params.{h,cpp} Parameter IDs, enums, mod sources/targets, mappings
|
||||
Wavetable.{h,cpp} 256×2048 tables + lazy library
|
||||
PluginEditor.{h,cpp} Tabbed GUI, owned controls, scaling, previews
|
||||
Params.{h,cpp} Parameter IDs, indexed groups, enums, mappings
|
||||
Wavetable.{h,cpp} 256×2048 tables, prebuilt by the engine
|
||||
Oscillator.{h,cpp} Morph/warp/unison oscillator
|
||||
SubOscillator.{h,cpp} Sine/triangle sub
|
||||
NoiseOscillator.{h,cpp} White/pink noise
|
||||
Filter.{h,cpp} 7 filter models
|
||||
FilterBank.{h,cpp} 2 slots + routing
|
||||
Filter.{h,cpp} Seven filter models and cached coefficients
|
||||
FilterBank.{h,cpp} Two slots and routing
|
||||
Envelope.{h,cpp} ADSR with curve
|
||||
LFO.{h,cpp} Synced LFO + shape editing
|
||||
LFO.{h,cpp} Continuous phase, sync and shape editing
|
||||
ModulationMatrix.{h,cpp} Connections
|
||||
FXProcessor.{h,cpp} Reorderable rack
|
||||
FXProcessor.{h,cpp} Independent effect state per rack slot
|
||||
EffectUnits/ Hyper, Chorus, Flanger, Phaser, Distortion, EQ,
|
||||
Compressor, Delay, Reverb
|
||||
MacroControls.{h,cpp} 4 assignable macros
|
||||
RAVEButton.{h,cpp} RAVE snapshot/boost
|
||||
SynthVoice.{h,cpp} 32-voice pool render path
|
||||
Engine.{h,cpp} Per-block DSP + MIDI + FX + limiting
|
||||
GUI/ Knob, Slider, ToggleButton, Display, Panel,
|
||||
Waveform/Filter/Envelope/LFO displays
|
||||
Presets/FactoryPresets.cpp 16 factory presets
|
||||
MacroControls.{h,cpp} Four assignable macros
|
||||
RAVEButton.{h,cpp} Render-time boost, without rewriting parameters
|
||||
SynthVoice.{h,cpp} Per-voice oscillators, filters and envelopes
|
||||
Engine.{h,cpp} 32 voices, MIDI timing, control snapshots, FX
|
||||
GUI/ Controls and previews
|
||||
Presets/FactoryPresets.cpp Factory preset definitions
|
||||
Resources/ SVG assets
|
||||
```
|
||||
|
||||
Key architectural decisions: a fixed 32-voice pool with pre-allocated unison
|
||||
sub-voices (max 16/osc), scalar processing (SIMD deferred), control-rate modulation
|
||||
(one level, no modulation-of-modulation), and a `juce::AudioProcessorValueTreeState`
|
||||
backed parameter store that survives preset save/load together with the mod matrix,
|
||||
macro assignments and LFO shape data.
|
||||
The engine splits rendering at MIDI event positions and into segments of at most
|
||||
64 samples. Buffers and effect instances are prepared ahead of time. GUI edits
|
||||
use a control-side copy; audio tries to capture that copy once per host block and
|
||||
keeps its previous settings if an edit is still in progress.
|
||||
|
||||
### Behavior notes
|
||||
|
||||
- Loading a factory preset resets sound parameters before applying its settings,
|
||||
while preserving UI scale.
|
||||
- RAVE is automatable and saved with the project. It changes the rendered sound
|
||||
without moving the underlying knobs; turning it off removes the boost.
|
||||
- Modulation of Master and FX mixes uses global sources directly. Envelope,
|
||||
velocity, note and random sources for these destinations use the newest active voice.
|
||||
- Effect types can repeat across slots. Reordering swaps settings, not running
|
||||
delay or reverb tails. Preconstructing effects uses more memory but avoids
|
||||
allocating effect instances while audio is running.
|
||||
- Filter graphs are schematic previews, not measurements of the nonlinear DSP.
|
||||
- LFO shape data is stored as JSON text inside the project's XML state. Older
|
||||
saves containing only the literal `[Array]` did not preserve the shape data
|
||||
and fall back to the default pattern.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -139,7 +145,10 @@ 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`.
|
||||
runtimes statically via `-static-libgcc -static-libstdc++ -static`. Before staging,
|
||||
the script verifies the linked module is x86-64, imports none of those MinGW
|
||||
runtimes, and exports the VST3 entry points, so a module that a stock Windows host
|
||||
could not load fails the build instead of being staged silently.
|
||||
|
||||
macOS builds target the host architecture by default. To request a universal binary:
|
||||
|
||||
|
||||
@@ -50,4 +50,4 @@ void prepare (double sampleRate, int) override
|
||||
}
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -28,7 +28,7 @@ public:
|
||||
|
||||
void setLowpass (double freq, double q)
|
||||
{
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
@@ -42,7 +42,7 @@ public:
|
||||
|
||||
void setHighpass (double freq, double q)
|
||||
{
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
@@ -57,7 +57,7 @@ public:
|
||||
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 w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / 2.0 * std::sqrt (2.0);
|
||||
@@ -72,7 +72,7 @@ public:
|
||||
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 w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / 2.0 * std::sqrt (2.0);
|
||||
@@ -87,7 +87,7 @@ public:
|
||||
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 w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
|
||||
@@ -37,13 +37,13 @@ void ChorusUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
delayL[(size_t) writePos] = inL;
|
||||
delayR[(size_t) writePos] = inR;
|
||||
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
|
||||
if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
|
||||
|
||||
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 mod = std::sin (lfoPhase + v * juce::MathConstants<double>::pi) * depth;
|
||||
const double d = base + mod + (v == 1 ? width * depth * 0.5 : 0.0);
|
||||
double readPos = writePos - d;
|
||||
if (readPos < 0.0) readPos += len;
|
||||
|
||||
@@ -35,12 +35,16 @@ void FlangerUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const float inL = l[i], inR = r[i];
|
||||
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
|
||||
if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
|
||||
const double mod = std::sin (lfoPhase) * depth;
|
||||
|
||||
// base + mod is not guaranteed positive: at high depth the LFO offset
|
||||
// can exceed the centre delay, so the read position runs ahead of the
|
||||
// write position and needs the wrap in both directions to stay in range.
|
||||
double readPos = writePos - (base + mod);
|
||||
if (readPos < 0.0) readPos += len;
|
||||
if (readPos >= (double) len) readPos -= len;
|
||||
const int i0 = (int) readPos;
|
||||
const int i1 = (i0 + 1) % len;
|
||||
const float frac = (float) (readPos - std::floor (readPos));
|
||||
|
||||
@@ -27,11 +27,15 @@ void PhaserUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
|
||||
if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
|
||||
|
||||
const float sweep = (float) (0.5 + 0.5 * std::sin (lfoPhase));
|
||||
const float a = 0.3f + depth * sweep; // allpass coefficient
|
||||
// The allpass cascade is stable only while the coefficient stays inside
|
||||
// (-1, 1). depth can push 0.3 + depth * sweep above 1, which makes every
|
||||
// stage self-excite, so the filter state runs away to inf/NaN and keeps
|
||||
// producing it. Clamp at the point where the coefficient is computed.
|
||||
const float a = juce::jlimit (0.0f, 0.99f, 0.3f + depth * sweep);
|
||||
|
||||
float outL = l[i] + yL[5] * feedback;
|
||||
float outR = r[i] + yR[5] * feedback;
|
||||
|
||||
+15
-6
@@ -56,16 +56,30 @@ void Engine::reset()
|
||||
fx.reset();
|
||||
pitchBend = 0.0f;
|
||||
modWheel = 0.0f;
|
||||
activeVoiceCount.store (0, std::memory_order_relaxed);
|
||||
mixBuffer.clear();
|
||||
}
|
||||
|
||||
void Engine::requestReset() noexcept
|
||||
{
|
||||
const juce::ScopedLock lock (controlLock);
|
||||
resetRequested = true;
|
||||
}
|
||||
|
||||
void Engine::captureControls()
|
||||
{
|
||||
const juce::ScopedTryLock lock (controlLock);
|
||||
if (! lock.isLocked())
|
||||
return;
|
||||
|
||||
// A preset change asks for a clean start. reset() runs here, on the audio
|
||||
// thread, because the message thread must never touch the voice, filter or
|
||||
// FX state the render is reading.
|
||||
if (resetRequested)
|
||||
{
|
||||
resetRequested = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
audioMatrix.connections.assign (matrix.connections.begin(),
|
||||
matrix.connections.begin() + juce::jmin (matrix.size(), ModulationMatrix::kMaxConnections));
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
@@ -356,11 +370,6 @@ void Engine::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& m
|
||||
modWheel = message.getControllerValue() / 127.0f;
|
||||
}
|
||||
renderUntil (n);
|
||||
|
||||
int active = 0;
|
||||
for (const auto& voice : voices)
|
||||
active += voice.isActive() ? 1 : 0;
|
||||
activeVoiceCount.store (active, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
+7
-3
@@ -25,6 +25,12 @@ public:
|
||||
void prepare (double sampleRate, int blockSize, juce::AudioProcessorValueTreeState& apvts);
|
||||
void reset();
|
||||
|
||||
// Ask for the DSP state to be cleared before the next block is rendered.
|
||||
// Safe to call from the message thread while controlLock is held: the audio
|
||||
// thread performs the reset in captureControls, so the DSP objects are only
|
||||
// ever touched by the audio thread.
|
||||
void requestReset() noexcept;
|
||||
|
||||
void processBlock (juce::AudioBuffer<float>& buffer,
|
||||
juce::MidiBuffer& midi,
|
||||
juce::AudioProcessorValueTreeState& apvts,
|
||||
@@ -43,8 +49,6 @@ public:
|
||||
const WavetableLibrary& getWavetables() const { return wavetables; }
|
||||
void setLfoShapeData (int index, const std::vector<float>& data, int steps);
|
||||
|
||||
int getActiveVoiceCount() const { return activeVoiceCount.load (std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
std::array<SynthVoice, kNumVoices> voices;
|
||||
std::array<LFO, kNumLfos> lfos, controlLfos;
|
||||
@@ -53,6 +57,7 @@ private:
|
||||
ModulationMatrix matrix, audioMatrix;
|
||||
MacroControls macros, audioMacros;
|
||||
juce::CriticalSection controlLock;
|
||||
bool resetRequested = false; // guarded by controlLock
|
||||
|
||||
struct ParameterValue
|
||||
{
|
||||
@@ -67,7 +72,6 @@ private:
|
||||
float pitchBend = 0.0f;
|
||||
float modWheel = 0.0f;
|
||||
juce::uint64 noteCounter = 0;
|
||||
std::atomic<int> activeVoiceCount { 0 };
|
||||
|
||||
juce::AudioBuffer<float> mixBuffer;
|
||||
|
||||
|
||||
+2
-2
@@ -41,8 +41,8 @@ void Envelope::setParams (float attackSec, float decaySec, float sustainLevel, f
|
||||
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
|
||||
attackShape = (double) attackShapeFor (curve);
|
||||
decayShape = (double) decayShapeFor (curve);
|
||||
|
||||
shape.attack = attackSec;
|
||||
shape.decay = decaySec;
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// Curve-to-shape mapping shared by the envelope DSP and the GUI preview, so the
|
||||
// two cannot drift apart.
|
||||
inline float attackShapeFor (float curve) noexcept { return 0.3f + curve * 2.7f; } // 0.3 .. 3.0
|
||||
inline float decayShapeFor (float curve) noexcept { return 3.0f - curve * 2.7f; } // 3.0 .. 0.3
|
||||
|
||||
// ===========================================================================
|
||||
// ADSR envelope with curve tension. Durations are in seconds; the sustain
|
||||
// level is 0..1. Retriggering (noteOn while active) starts from the current
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
// Exact floating-point comparison helpers.
|
||||
//
|
||||
// The engine caches derived state - filter coefficients, per-voice parameter
|
||||
// snapshots, GUI preview values - and re-derives it only when a stored value
|
||||
// changes. Those checks must stay bit-exact: an epsilon compare would either
|
||||
// keep stale cached state or skip a needed rebuild near the threshold, so the
|
||||
// intent is named here instead of weakened at each call site.
|
||||
//
|
||||
// Every first-party use of == / != on floating point goes through one of these
|
||||
// helpers, and the single -Wfloat-equal suppression lives here rather than
|
||||
// being repeated across the call sites.
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wfloat-equal"
|
||||
#endif
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// Exact equality, no epsilon. Used where the exact value carries meaning, such
|
||||
// as "the output level is exactly unity" or "the untouched tail is the zero
|
||||
// default".
|
||||
inline bool exactly (float a, float b) noexcept { return a == b; }
|
||||
inline bool exactly (double a, double b) noexcept { return a == b; }
|
||||
|
||||
// Exact inequality, for "the cached value changed" tests.
|
||||
inline bool changed (float a, float b) noexcept { return a != b; }
|
||||
inline bool changed (double a, double b) noexcept { return a != b; }
|
||||
|
||||
// True when a stored double that should hold a whole number really does.
|
||||
inline bool isWholeNumber (double v) noexcept { return std::floor (v) == v; }
|
||||
|
||||
} // namespace serum
|
||||
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
+30
-31
@@ -33,17 +33,18 @@ FXProcessor::~FXProcessor() = default;
|
||||
void FXProcessor::prepare (double sampleRate, int maxBlockSize)
|
||||
{
|
||||
for (auto& slotUnits : units)
|
||||
for (auto& u : slotUnits)
|
||||
u->prepare (sampleRate, maxBlockSize);
|
||||
for (auto& unit : slotUnits)
|
||||
unit->prepare (sampleRate, maxBlockSize);
|
||||
wet.setSize (2, maxBlockSize, false, false, true);
|
||||
reset();
|
||||
}
|
||||
|
||||
void FXProcessor::reset()
|
||||
{
|
||||
for (auto& u : units)
|
||||
u->reset();
|
||||
dry.clear();
|
||||
for (auto& slotUnits : units)
|
||||
for (auto& unit : slotUnits)
|
||||
unit->reset();
|
||||
activeTypes.fill ((int) FxType::Off);
|
||||
wet.clear();
|
||||
}
|
||||
|
||||
@@ -61,52 +62,50 @@ juce::String FXProcessor::fxTypeName (int type)
|
||||
case FxType::Compressor: return "Compressor";
|
||||
case FxType::Delay: return "Delay";
|
||||
case FxType::Reverb: return "Reverb";
|
||||
case FxType::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
void FXProcessor::process (juce::AudioBuffer<float>& buffer, const FxSlotParams* slots, int numSlots)
|
||||
{
|
||||
// The rack is stereo end to end; a mono buffer would read a channel that is not there.
|
||||
jassert (buffer.getNumChannels() >= 2);
|
||||
if (buffer.getNumChannels() < 2)
|
||||
return;
|
||||
|
||||
const int n = buffer.getNumSamples();
|
||||
jassert (n <= wet.getNumSamples());
|
||||
if (n <= 0)
|
||||
return;
|
||||
|
||||
for (int s = 0; s < numSlots; ++s)
|
||||
for (int s = 0; s < juce::jmin (numSlots, kNumFxSlots); ++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())
|
||||
if (unitIdx < 0 || unitIdx >= kNumEffectTypes)
|
||||
{
|
||||
activeTypes[(size_t) s] = (int) FxType::Off;
|
||||
continue;
|
||||
}
|
||||
|
||||
dry.copyFrom (0, 0, buffer, 0, 0, n);
|
||||
dry.copyFrom (1, 1, buffer, 1, 0, n);
|
||||
auto& unit = *units[(size_t) s][(size_t) unitIdx];
|
||||
if (activeTypes[(size_t) s] != slot.type)
|
||||
{
|
||||
unit.reset();
|
||||
activeTypes[(size_t) s] = slot.type;
|
||||
}
|
||||
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);
|
||||
wet.copyFrom (1, 0, buffer, 1, 0, n);
|
||||
unit.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)
|
||||
for (int ch = 0; ch < 2; ++ch)
|
||||
{
|
||||
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);
|
||||
float* out = buffer.getWritePointer (ch);
|
||||
const float* effect = wet.getReadPointer (ch);
|
||||
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;
|
||||
}
|
||||
out[i] = out[i] * (1.0f - mix) + effect[i] * mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-32
@@ -1,4 +1,5 @@
|
||||
#include "Filter.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -18,14 +19,13 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
void Filter::prepare (double sampleRate, int maxBlockSize)
|
||||
void Filter::prepare (double sampleRate)
|
||||
{
|
||||
sr = sampleRate;
|
||||
combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay
|
||||
combWrite = 0;
|
||||
combDamp = 0.0;
|
||||
reset();
|
||||
(void) maxBlockSize;
|
||||
}
|
||||
|
||||
void Filter::reset()
|
||||
@@ -38,14 +38,22 @@ void Filter::reset()
|
||||
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);
|
||||
// combLine is one second of samples per filter, and a preset change now
|
||||
// resets on the audio thread. It only needs clearing when the comb has
|
||||
// actually written to it.
|
||||
if (combDirty)
|
||||
{
|
||||
std::fill (combLine.begin(), combLine.end(), 0.0f);
|
||||
combDirty = false;
|
||||
}
|
||||
combWrite = 0;
|
||||
combDamp = 0.0;
|
||||
}
|
||||
|
||||
void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
|
||||
{
|
||||
if (cutoffHz == lastCutoffHz && res == lastRes && type == lastType)
|
||||
// Cache key: rebuild coefficients only when a parameter actually changes.
|
||||
if (! changed (cutoffHz, lastCutoffHz) && ! changed (res, lastRes) && type == lastType)
|
||||
return;
|
||||
lastCutoffHz = cutoffHz;
|
||||
lastRes = res;
|
||||
@@ -89,6 +97,8 @@ void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
|
||||
updateSvf (g, k);
|
||||
break;
|
||||
}
|
||||
case FilterModel::Comb:
|
||||
case FilterModel::Count:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -96,7 +106,8 @@ void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
|
||||
|
||||
void Filter::updateSvf (double g, double k) noexcept
|
||||
{
|
||||
if (g == lastG && k == lastK)
|
||||
// Cache key: the SVF coefficients only change with g and k.
|
||||
if (! changed (g, lastG) && ! changed (k, lastK))
|
||||
return;
|
||||
lastG = g;
|
||||
lastK = k;
|
||||
@@ -105,17 +116,6 @@ void Filter::updateSvf (double g, double k) noexcept
|
||||
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);
|
||||
@@ -127,17 +127,6 @@ double Filter::svfBand (double in, double g, double k) noexcept
|
||||
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);
|
||||
@@ -193,6 +182,7 @@ double Filter::comb (double in, double freqHz, double res, double drive) noexcep
|
||||
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;
|
||||
combDirty = true;
|
||||
|
||||
return (double) y;
|
||||
}
|
||||
@@ -204,12 +194,12 @@ double Filter::formant (double in) noexcept
|
||||
for (int i = 0; i < 3; ++i)
|
||||
{
|
||||
const auto& c = formantCoefficients[(size_t) i];
|
||||
const double a1 = c[0];
|
||||
const double a2 = c[1];
|
||||
const double a3 = c[2];
|
||||
const double c0 = c[0];
|
||||
const double c1 = c[1];
|
||||
const double c2 = c[2];
|
||||
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;
|
||||
const double v1 = c0 * formantState[(size_t) i][0] + c1 * v3;
|
||||
const double v2 = formantState[(size_t) i][1] + c1 * formantState[(size_t) i][0] + c2 * 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];
|
||||
@@ -269,6 +259,7 @@ float Filter::processSample (float in, float cutoffHz, float res, float drive, i
|
||||
case FilterModel::Screamer:
|
||||
out = screamer (in, drive);
|
||||
break;
|
||||
case FilterModel::Count:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
+2
-3
@@ -14,7 +14,7 @@ namespace serum
|
||||
class Filter
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize);
|
||||
void prepare (double sampleRate);
|
||||
void reset();
|
||||
|
||||
// Process a mono buffer in-place. cutoffNorm is 0..1 (mapped to 20Hz..20kHz).
|
||||
@@ -43,6 +43,7 @@ private:
|
||||
std::vector<float> combLine;
|
||||
int combWrite = 0;
|
||||
double combDamp = 0.0;
|
||||
bool combDirty = false; // set once comb() has written to combLine
|
||||
|
||||
// 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 } } } };
|
||||
@@ -50,9 +51,7 @@ private:
|
||||
|
||||
void updateCoefficients (float cutoffHz, float res, int type) noexcept;
|
||||
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) noexcept;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
#include "FilterBank.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void FilterBank::prepare (double sampleRate, int maxBlockSize)
|
||||
void FilterBank::prepare (double sampleRate)
|
||||
{
|
||||
f1L.prepare (sampleRate, maxBlockSize);
|
||||
f1R.prepare (sampleRate, maxBlockSize);
|
||||
f2L.prepare (sampleRate, maxBlockSize);
|
||||
f2R.prepare (sampleRate, maxBlockSize);
|
||||
f1L.prepare (sampleRate);
|
||||
f1R.prepare (sampleRate);
|
||||
f2L.prepare (sampleRate);
|
||||
f2R.prepare (sampleRate);
|
||||
}
|
||||
|
||||
void FilterBank::reset()
|
||||
@@ -67,8 +68,9 @@ void FilterBank::process (float* l, float* r, int numSamples, const FilterBankPa
|
||||
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)
|
||||
// Post-filter output level. Skip the multiply only at exact unity so the
|
||||
// bypass never changes the rendered samples.
|
||||
if (! exactly (p.out, 1.0f))
|
||||
{
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ struct FilterBankParams
|
||||
class FilterBank
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize);
|
||||
void prepare (double sampleRate);
|
||||
void reset();
|
||||
|
||||
void process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
## Owned files
|
||||
|
||||
- Related editor implementation: `../PluginEditor.h`, `../PluginEditor.cpp`
|
||||
- SerumLookAndFeel.h
|
||||
- Knob.h, Knob.cpp
|
||||
- Slider.h, Slider.cpp
|
||||
- ToggleButton.h, ToggleButton.cpp
|
||||
@@ -16,7 +15,6 @@
|
||||
|
||||
## 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.
|
||||
@@ -44,4 +42,4 @@ const float v = processor.parameters.getRawParameterValue (ids::f1Cutoff)
|
||||
: 0.0f;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -1,11 +1,14 @@
|
||||
#include "EnvelopeDisplay.h"
|
||||
#include "../Envelope.h"
|
||||
#include "../ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c)
|
||||
{
|
||||
if (attack == a && decay == d && sustain == s && release == r && curve == c)
|
||||
if (! changed (attack, a) && ! changed (decay, d) && ! changed (sustain, s)
|
||||
&& ! changed (release, r) && ! changed (curve, c))
|
||||
return;
|
||||
attack = a; decay = d; sustain = s; release = r; curve = c;
|
||||
repaint();
|
||||
@@ -19,8 +22,8 @@ void EnvelopeDisplay::paint (juce::Graphics& g)
|
||||
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;
|
||||
const float atkShape = attackShapeFor (curve);
|
||||
const float decShape = decayShapeFor (curve);
|
||||
|
||||
// Normalise durations for display, allowing a short sustain plateau.
|
||||
const float aSec = maps::toSeconds (attack);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#include "FilterDisplay.h"
|
||||
#include "../ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// Schematic frequency-response preview for this view only. It does not model the
|
||||
// real DSP in Source/Filter.cpp, nor the drive, the slope or modulation, so it is
|
||||
// not expected to match the nonlinear filter numerically.
|
||||
float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int type)
|
||||
{
|
||||
const float w = (cutoffHz > 1.0f) ? freqHz / cutoffHz : 1.0f;
|
||||
@@ -29,7 +33,9 @@ float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int typ
|
||||
}
|
||||
return m / 3.0f;
|
||||
}
|
||||
case FilterModel::LadderLP:
|
||||
case FilterModel::Diode:
|
||||
case FilterModel::Count:
|
||||
default:
|
||||
return (denom > 1e-6f) ? 1.0f / denom : 0.0f;
|
||||
}
|
||||
@@ -37,7 +43,8 @@ float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int typ
|
||||
|
||||
void FilterDisplay::setParams (int t, float c, float r, float d, int s)
|
||||
{
|
||||
if (type == t && cutoff == c && res == r && drive == d && slope == s)
|
||||
if (type == t && ! changed (cutoff, c) && ! changed (res, r)
|
||||
&& ! changed (drive, d) && slope == s)
|
||||
return;
|
||||
type = t; cutoff = c; res = r; drive = d; slope = s;
|
||||
repaint();
|
||||
|
||||
+75
-7
@@ -3,6 +3,17 @@
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
// One shared look-and-feel for every knob. It outlives every Knob because
|
||||
// it is a function-local static and the GUI owns the knobs.
|
||||
KnobLookAndFeel& knobStyle()
|
||||
{
|
||||
static KnobLookAndFeel style;
|
||||
return style;
|
||||
}
|
||||
}
|
||||
|
||||
Knob::Knob (const juce::String& name, std::function<juce::String (float)> fmt)
|
||||
: formatter (std::move (fmt))
|
||||
{
|
||||
@@ -12,6 +23,10 @@ Knob::Knob (const juce::String& name, std::function<juce::String (float)> fmt)
|
||||
setDoubleClickReturnValue (true, 0.5);
|
||||
setName (name);
|
||||
setComponentID (name);
|
||||
|
||||
// Draw the dial and the name strip through the theme, not through the
|
||||
// shared default look-and-feel.
|
||||
setLookAndFeel (&knobStyle());
|
||||
}
|
||||
|
||||
juce::String Knob::getTextFromValue (double value)
|
||||
@@ -21,6 +36,32 @@ juce::String Knob::getTextFromValue (double value)
|
||||
return formatPercent ((float) value);
|
||||
}
|
||||
|
||||
// The dial takes the top of the footprint and the name takes a strip at the
|
||||
// bottom, so the name is always visible without a tooltip hover.
|
||||
void Knob::resized()
|
||||
{
|
||||
juce::Slider::resized();
|
||||
textArea = getLocalBounds().removeFromBottom (
|
||||
juce::jmin (KnobLookAndFeel::labelHeight, getHeight() / 2));
|
||||
}
|
||||
|
||||
void Knob::paint (juce::Graphics& g)
|
||||
{
|
||||
juce::Slider::paint (g);
|
||||
|
||||
if (textArea.isEmpty())
|
||||
return;
|
||||
|
||||
// Dim while disabled, bright while hovered, otherwise the normal label
|
||||
// colour, so the three states are distinguishable without colour alone.
|
||||
const auto colour = isEnabled() ? (isMouseOverOrDragging() ? theme::text : theme::textDim)
|
||||
: theme::outline;
|
||||
|
||||
g.setColour (colour);
|
||||
g.setFont (juce::Font (11.0f));
|
||||
g.drawText (getName(), textArea, juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
void Knob::mouseDown (const juce::MouseEvent& e)
|
||||
{
|
||||
juce::Slider::mouseDown (e);
|
||||
@@ -56,6 +97,24 @@ void Knob::showValuePopup (const juce::MouseEvent& e)
|
||||
if (topLevel == nullptr)
|
||||
return;
|
||||
|
||||
constexpr int baseW = 84;
|
||||
constexpr int baseH = 24;
|
||||
constexpr int gap = 10;
|
||||
|
||||
// The interface scale is applied to a parent component, not to this slider.
|
||||
// Measure it from the local-space distance between two neighbouring points,
|
||||
// so a scaled interface does not leave the readout at a fixed size.
|
||||
float scale = 1.0f;
|
||||
if (auto* parent = getParentComponent())
|
||||
{
|
||||
const auto origin = getLocalPoint (parent, juce::Point<int> (0, 0)).toFloat();
|
||||
const auto unitX = getLocalPoint (parent, juce::Point<int> (1, 0)).toFloat();
|
||||
scale = origin.getDistanceFrom (unitX);
|
||||
}
|
||||
scale = juce::jlimit (0.5f, 2.5f, scale);
|
||||
const int w = juce::roundToInt (baseW * scale);
|
||||
const int h = juce::roundToInt (baseH * scale);
|
||||
|
||||
if (valuePopup == nullptr)
|
||||
{
|
||||
valuePopup = std::make_unique<juce::Label>();
|
||||
@@ -63,19 +122,20 @@ void Knob::showValuePopup (const juce::MouseEvent& e)
|
||||
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->setFont (juce::Font (14.0f * scale, juce::Font::bold));
|
||||
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);
|
||||
// Keep the readout inside the window so a knob near an edge still shows it.
|
||||
const auto popupPos = topLevel->getLocalPoint (nullptr, e.getScreenPosition());
|
||||
valuePopup->setBounds (juce::jlimit (0, juce::jmax (0, topLevel->getWidth() - w),
|
||||
popupPos.getX() - w / 2),
|
||||
juce::jlimit (0, juce::jmax (0, topLevel->getHeight() - h),
|
||||
popupPos.getY() - h - gap),
|
||||
w, h);
|
||||
}
|
||||
|
||||
void Knob::hideValuePopup()
|
||||
@@ -83,4 +143,12 @@ void Knob::hideValuePopup()
|
||||
valuePopup.reset();
|
||||
}
|
||||
|
||||
// A section switch can hide the view holding this knob mid-drag. The popup is a
|
||||
// sibling of that view, so it has to be taken down here or it stays on screen.
|
||||
void Knob::visibilityChanged()
|
||||
{
|
||||
if (! isVisible())
|
||||
hideValuePopup();
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
+14
-2
@@ -3,13 +3,16 @@
|
||||
#include <JuceHeader.h>
|
||||
#include <memory>
|
||||
#include "../Resources.h"
|
||||
#include "KnobLookAndFeel.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Rotary knob — a juce::Slider styled by SerumLookAndFeel. The value readout is
|
||||
// formatted by a user-provided function via getTextFromValue().
|
||||
// Rotary knob — a juce::Slider drawn by KnobLookAndFeel. The knob paints its
|
||||
// own name below the dial, so no control is a bare arc whose only hint is a
|
||||
// tooltip. 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
|
||||
@@ -23,8 +26,16 @@ public:
|
||||
|
||||
void setFormatter (std::function<juce::String (float)> f) { formatter = std::move (f); }
|
||||
|
||||
// Value a double click returns to. Call it with the parameter's own default
|
||||
// so the gesture restores the real default, never a hardcoded 0.5.
|
||||
void setDefaultValue (double v) { setDoubleClickReturnValue (true, v); }
|
||||
|
||||
juce::String getTextFromValue (double value) override;
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
void resized() override;
|
||||
void visibilityChanged() override;
|
||||
|
||||
void mouseDown (const juce::MouseEvent& e) override;
|
||||
void mouseDrag (const juce::MouseEvent& e) override;
|
||||
void mouseUp (const juce::MouseEvent& e) override;
|
||||
@@ -36,6 +47,7 @@ private:
|
||||
|
||||
std::function<juce::String (float)> formatter;
|
||||
std::unique_ptr<juce::Label> valuePopup;
|
||||
juce::Rectangle<int> textArea; // name strip under the dial
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "KnobLookAndFeel.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void KnobLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||
float sliderPos, float rotaryStartAngle, float rotaryEndAngle,
|
||||
juce::Slider& slider)
|
||||
{
|
||||
const auto dial = juce::Rectangle<float> ((float) x, (float) y, (float) width, (float) height);
|
||||
if (dial.isEmpty())
|
||||
return;
|
||||
|
||||
const float radius = juce::jmin (dial.getWidth(), dial.getHeight()) * 0.5f - 2.0f;
|
||||
if (radius <= 1.0f)
|
||||
return;
|
||||
|
||||
const float lineW = juce::jlimit (1.5f, 5.0f, radius * 0.22f);
|
||||
const float arcR = radius - lineW * 0.5f;
|
||||
const float centreX = dial.getCentreX();
|
||||
const float centreY = dial.getCentreY();
|
||||
const float toAngle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle);
|
||||
const bool live = slider.isEnabled();
|
||||
|
||||
juce::Path track;
|
||||
track.addCentredArc (centreX, centreY, arcR, arcR, 0.0f, rotaryStartAngle, rotaryEndAngle, true);
|
||||
g.setColour (theme::outline);
|
||||
g.strokePath (track, juce::PathStrokeType (lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
|
||||
|
||||
if (live)
|
||||
{
|
||||
juce::Path value;
|
||||
value.addCentredArc (centreX, centreY, arcR, arcR, 0.0f, rotaryStartAngle, toAngle, true);
|
||||
g.setColour (theme::accent.withAlpha (slider.isMouseOverOrDragging() ? 1.0f : 0.75f));
|
||||
g.strokePath (value, juce::PathStrokeType (lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
|
||||
}
|
||||
|
||||
const float pointer = radius * 0.62f;
|
||||
g.setColour (live ? theme::text : theme::textDim);
|
||||
g.drawLine (centreX + std::sin (toAngle) * pointer * 0.35f,
|
||||
centreY - std::cos (toAngle) * pointer * 0.35f,
|
||||
centreX + std::sin (toAngle) * pointer,
|
||||
centreY - std::cos (toAngle) * pointer,
|
||||
juce::jmax (1.5f, lineW * 0.5f));
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Look-and-feel for Knob.
|
||||
//
|
||||
// Two jobs:
|
||||
//
|
||||
// 1. Draw the rotary with theme:: colours. The shared default look-and-feel
|
||||
// paints it with its own palette, which ignores the rest of the editor.
|
||||
//
|
||||
// 2. Leave room for the knob's name. juce::LookAndFeel_V4 reduces the rotary
|
||||
// bounds by a fixed ten pixels on each side, so a 76 pixel knob loses
|
||||
// 20 pixels of dial and a smaller knob loses most of it. This version
|
||||
// keeps the dial whole inside its footprint and reserves a text strip
|
||||
// under it, which is the strip Knob paints its name into.
|
||||
// ===========================================================================
|
||||
class KnobLookAndFeel : public juce::LookAndFeel_V4
|
||||
{
|
||||
public:
|
||||
// Height of the name strip reserved under the dial.
|
||||
static constexpr int labelHeight = 16;
|
||||
|
||||
void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||
float sliderPos, float rotaryStartAngle, float rotaryEndAngle,
|
||||
juce::Slider& slider) override;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
+29
-33
@@ -1,52 +1,28 @@
|
||||
#include "LFODisplay.h"
|
||||
#include "../ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void LFODisplay::setShapeData (const std::vector<float>& data, int s)
|
||||
{
|
||||
const int newSteps = juce::jlimit (2, 64, s);
|
||||
const size_t dataSize = std::min (data.size(), size_t (64));
|
||||
if (steps == newSteps && shapeData.size() == 64
|
||||
const int newSteps = juce::jlimit (2, LFO::kShapePoints, s);
|
||||
const auto dataSize = (std::ptrdiff_t) std::min (data.size(), size_t (LFO::kShapePoints));
|
||||
if (steps == newSteps && (int) shapeData.size() == LFO::kShapePoints
|
||||
&& std::equal (data.begin(), data.begin() + dataSize, shapeData.begin())
|
||||
&& std::all_of (shapeData.begin() + dataSize, shapeData.end(), [] (float v) { return v == 0.0f; }))
|
||||
&& std::all_of (shapeData.begin() + dataSize, shapeData.end(), [] (float v) { return exactly (v, 0.0f); }))
|
||||
return;
|
||||
|
||||
steps = newSteps;
|
||||
shapeData.assign (data.begin(), data.begin() + dataSize);
|
||||
shapeData.resize (64, 0.0f);
|
||||
shapeData.resize ((size_t) LFO::kShapePoints, 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;
|
||||
}
|
||||
return lfoShapeValue (phase, (LfoShape) shape, steps,
|
||||
shapeData.data(), (int) shapeData.size(), kSampleHoldLevel);
|
||||
}
|
||||
|
||||
void LFODisplay::paint (juce::Graphics& g)
|
||||
@@ -75,6 +51,26 @@ void LFODisplay::paint (juce::Graphics& g)
|
||||
}
|
||||
}
|
||||
|
||||
// Sample and hold is stateful in the audio path, so the preview draws the
|
||||
// held level as a flat schematic with its hold steps marked, and labels it,
|
||||
// rather than inventing a sequence the synth will never play.
|
||||
if (shape == (int) LfoShape::SampleHold)
|
||||
{
|
||||
const float stepW = (b.getWidth() - 8.0f) / (float) steps;
|
||||
g.setColour (theme::outline);
|
||||
for (int i = 1; i < steps; ++i)
|
||||
{
|
||||
const float x = b.getX() + 4.0f + stepW * (float) i;
|
||||
g.drawLine (x, midY - b.getHeight() * 0.28f, x, midY + b.getHeight() * 0.28f, 1.0f);
|
||||
}
|
||||
|
||||
g.setColour (theme::textDim);
|
||||
g.setFont (10.0f);
|
||||
g.drawText ("S&H schematic",
|
||||
juce::Rectangle<float> (b.getX(), b.getY(), b.getWidth(), 14.0f).toNearestInt(),
|
||||
juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
juce::Path path;
|
||||
path.startNewSubPath (b.getX() + 4.0f, midY);
|
||||
const int n = 256;
|
||||
@@ -99,7 +95,7 @@ void LFODisplay::editAt (float x, float y)
|
||||
const float v = juce::jlimit (-1.0f, 1.0f, (midY - y) / (b.getHeight() * 0.42f));
|
||||
|
||||
if (shapeData.empty())
|
||||
shapeData.assign (64, 0.0f);
|
||||
shapeData.assign ((size_t) LFO::kShapePoints, 0.0f);
|
||||
|
||||
if (shape == (int) LfoShape::StepSeq)
|
||||
{
|
||||
|
||||
+14
-1
@@ -3,12 +3,17 @@
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
#include "../Params.h"
|
||||
#include "../LFO.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// LFO shape view. Step-sequencer and freehand shapes are click/drag editable.
|
||||
// The preview evaluates the same lfoShapeValue() function as the audio path, so
|
||||
// the drawn shape cannot drift from the sound. Sample and hold has no phase
|
||||
// function (the audio level is stateful), so its preview is a labelled
|
||||
// schematic rather than a fabricated sequence.
|
||||
// ===========================================================================
|
||||
class LFODisplay : public juce::Component
|
||||
{
|
||||
@@ -21,13 +26,21 @@ public:
|
||||
void mouseDown (const juce::MouseEvent& e) override;
|
||||
void mouseDrag (const juce::MouseEvent& e) override;
|
||||
|
||||
// Level the sample-and-hold preview draws. The audio LFO holds a level that
|
||||
// the display cannot observe on the message thread, so the preview shows a
|
||||
// flat schematic line at this value instead of inventing a sequence.
|
||||
static constexpr float kSampleHoldLevel = 0.0f;
|
||||
|
||||
// Value of the current shape at `phase` (0..1); the audio path calls the
|
||||
// same lfoShapeValue() with its live sample-and-hold level.
|
||||
float valueAt (float phase) const noexcept;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
#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
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace serum
|
||||
{
|
||||
|
||||
Slider::Slider (const juce::String& name, std::function<juce::String (float)> formatter)
|
||||
: Knob (name, std::move (formatter))
|
||||
Slider::Slider (const juce::String& name, std::function<juce::String (float)> fmt)
|
||||
: Knob (name, std::move (fmt))
|
||||
{
|
||||
setSliderStyle (juce::Slider::LinearHorizontal);
|
||||
setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
|
||||
|
||||
@@ -33,21 +33,29 @@ void ToggleButton::paint (juce::Graphics& g)
|
||||
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);
|
||||
|
||||
const bool live = isEnabled();
|
||||
const bool hovered = live && isMouseOverOrDragging();
|
||||
|
||||
if (state)
|
||||
{
|
||||
g.setColour (onColour.withAlpha (0.35f));
|
||||
// Hover brightens the halo, disabled dims it, so the three states are
|
||||
// distinguishable without relying on colour alone.
|
||||
const float halo = live ? (hovered ? 0.55f : 0.35f) : 0.15f;
|
||||
g.setColour (onColour.withAlpha (halo));
|
||||
g.fillEllipse (led.expanded (6.0f));
|
||||
g.setColour (onColour);
|
||||
g.setColour (live ? onColour : onColour.withAlpha (0.4f));
|
||||
}
|
||||
else
|
||||
{
|
||||
g.setColour (theme::outline);
|
||||
g.setColour (hovered ? theme::textDim : theme::outline);
|
||||
}
|
||||
g.fillEllipse (led);
|
||||
g.setColour (state ? juce::Colours::black : theme::textDim);
|
||||
g.drawEllipse (led, 1.0f);
|
||||
g.drawEllipse (led, live ? (hovered ? 1.6f : 1.0f) : 0.8f);
|
||||
|
||||
g.setColour (state ? theme::text : theme::textDim);
|
||||
const auto labelColour = ! live ? theme::outline
|
||||
: (state ? theme::text : (hovered ? theme::text : theme::textDim));
|
||||
g.setColour (labelColour);
|
||||
g.setFont (juce::Font (11.0f));
|
||||
g.drawText (label, 0, (int) (led.getBottom() + 2), getWidth(), 14, juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../ExactCompare.h"
|
||||
#include "../Resources.h"
|
||||
#include "../Wavetable.h"
|
||||
|
||||
@@ -15,7 +16,7 @@ class WaveformDisplay : public juce::Component
|
||||
public:
|
||||
void setWavetables (const WavetableLibrary* lib) { if (wtLib != lib) { wtLib = lib; repaint(); } }
|
||||
void setWaveIndex (int index) { if (wave != index) { wave = index; repaint(); } }
|
||||
void setFramePosition (float pos) { if (wtPos != pos) { wtPos = pos; repaint(); } }
|
||||
void setFramePosition (float pos) { if (changed (wtPos, pos)) { wtPos = pos; repaint(); } }
|
||||
void setEnabled (bool e) { if (enabled != e) { enabled = e; repaint(); } }
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
|
||||
+29
-16
@@ -1,4 +1,5 @@
|
||||
#include "LFO.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -40,7 +41,7 @@ void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
|
||||
|
||||
// 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)
|
||||
if (changed (delay, prevDelayParam) || changed (fade, prevFadeParam))
|
||||
{
|
||||
delayCounter = delaySeconds * sr;
|
||||
fadeCounter = 0.0;
|
||||
@@ -62,39 +63,51 @@ void LFO::setShapeData (const std::vector<float>& data, int steps)
|
||||
? juce::jlimit (-1.0f, 1.0f, data[i]) : 0.0f;
|
||||
}
|
||||
|
||||
float LFO::shapeValue() noexcept
|
||||
float lfoShapeValue (float phase, LfoShape shape, int steps,
|
||||
const float* data, int dataSize, float sampleHoldValue) noexcept
|
||||
{
|
||||
const float p = getPhase();
|
||||
switch ((LfoShape) shape)
|
||||
const int numSteps = juce::jlimit (2, LFO::kShapePoints, steps);
|
||||
const int numData = data != nullptr ? juce::jlimit (0, LFO::kShapePoints, dataSize) : 0;
|
||||
|
||||
auto point = [data, numData] (int index) noexcept
|
||||
{
|
||||
return numData > 0 ? data[(size_t) juce::jlimit (0, numData - 1, index)] : 0.0f;
|
||||
};
|
||||
|
||||
switch (shape)
|
||||
{
|
||||
case LfoShape::Sine:
|
||||
return std::sin (p * juce::MathConstants<float>::twoPi);
|
||||
return std::sin (phase * juce::MathConstants<float>::twoPi);
|
||||
case LfoShape::Triangle:
|
||||
return 1.0f - 4.0f * std::abs (p - 0.5f);
|
||||
return 1.0f - 4.0f * std::abs (phase - 0.5f);
|
||||
case LfoShape::Saw:
|
||||
return 2.0f * p - 1.0f;
|
||||
return 2.0f * phase - 1.0f;
|
||||
case LfoShape::Square:
|
||||
return (p < 0.5f) ? 1.0f : -1.0f;
|
||||
return (phase < 0.5f) ? 1.0f : -1.0f;
|
||||
case LfoShape::SampleHold:
|
||||
return holdValue;
|
||||
return sampleHoldValue;
|
||||
case LfoShape::StepSeq:
|
||||
{
|
||||
const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps));
|
||||
return shapeBuffer[(size_t) idx];
|
||||
}
|
||||
return point (juce::jlimit (0, numSteps - 1, (int) (phase * (float) numSteps)));
|
||||
case LfoShape::Freehand:
|
||||
{
|
||||
const float pos = p * (float) (shapeSteps - 1);
|
||||
const float pos = phase * (float) (numSteps - 1);
|
||||
const int i0 = (int) pos;
|
||||
const int i1 = juce::jmin (i0 + 1, shapeSteps - 1);
|
||||
const int i1 = juce::jmin (i0 + 1, numSteps - 1);
|
||||
const float frac = pos - (float) i0;
|
||||
return shapeBuffer[(size_t) i0] * (1.0f - frac) + shapeBuffer[(size_t) i1] * frac;
|
||||
return point (i0) * (1.0f - frac) + point (i1) * frac;
|
||||
}
|
||||
case LfoShape::Count:
|
||||
default:
|
||||
return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
float LFO::shapeValue() noexcept
|
||||
{
|
||||
return lfoShapeValue (getPhase(), (LfoShape) shape, shapeSteps,
|
||||
shapeBuffer.data(), (int) shapeBuffer.size(), holdValue);
|
||||
}
|
||||
|
||||
float LFO::process() noexcept
|
||||
{
|
||||
return advance (1);
|
||||
|
||||
@@ -62,4 +62,13 @@ private:
|
||||
float shapeValue() noexcept;
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Pure shape evaluation shared by the audio render (LFO::shapeValue) and the
|
||||
// GUI preview (LFODisplay::valueAt), so the drawn shape cannot drift from the
|
||||
// played one. `sampleHoldValue` supplies the current held level for
|
||||
// LfoShape::SampleHold, which is stateful in the audio path.
|
||||
// ===========================================================================
|
||||
float lfoShapeValue (float phase, LfoShape shape, int steps,
|
||||
const float* data, int dataSize, float sampleHoldValue) noexcept;
|
||||
|
||||
} // namespace serum
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "MacroControls.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -79,7 +80,7 @@ void MacroControls::fromValueTree (const juce::ValueTree& tree)
|
||||
continue;
|
||||
const double savedIndex = (double) mac.getProperty ("index", -1);
|
||||
if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumMacros
|
||||
|| std::floor (savedIndex) != savedIndex)
|
||||
|| ! isWholeNumber (savedIndex))
|
||||
continue;
|
||||
const int index = (int) savedIndex;
|
||||
for (const auto& asg : mac)
|
||||
|
||||
+13
-4
@@ -1,4 +1,5 @@
|
||||
#include "Oscillator.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -12,9 +13,8 @@ void Oscillator::reset()
|
||||
paramsValid = false;
|
||||
}
|
||||
|
||||
void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed)
|
||||
void Oscillator::noteOn (const OscParams& p, juce::uint32 seed)
|
||||
{
|
||||
jassert (freqHz > 0.0);
|
||||
rng.setSeed (seed);
|
||||
initializedUnison = 0;
|
||||
paramsValid = false;
|
||||
@@ -25,8 +25,9 @@ void Oscillator::setParams (const OscParams& p) noexcept
|
||||
{
|
||||
const int uni = juce::jlimit (1, kMaxUnison, p.unison);
|
||||
const bool countChanged = ! paramsValid || uni != activeUnison;
|
||||
const bool detuneChanged = countChanged || p.detune != cachedParams.detune;
|
||||
const bool panChanged = countChanged || p.pan != cachedParams.pan || p.spread != cachedParams.spread;
|
||||
const bool detuneChanged = countChanged || changed (p.detune, cachedParams.detune);
|
||||
const bool panChanged = countChanged || changed (p.pan, cachedParams.pan)
|
||||
|| changed (p.spread, cachedParams.spread);
|
||||
if (! detuneChanged && ! panChanged)
|
||||
return;
|
||||
|
||||
@@ -98,6 +99,7 @@ float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept
|
||||
}
|
||||
case WarpMode::PWM: return phase;
|
||||
case WarpMode::Fold: return phase;
|
||||
case WarpMode::Count: return phase;
|
||||
default: return phase;
|
||||
}
|
||||
}
|
||||
@@ -114,6 +116,13 @@ float Oscillator::warpSample (float sample, const OscParams& p) const noexcept
|
||||
}
|
||||
case WarpMode::Fold:
|
||||
return std::sin (sample * (1.0f + amt * 5.0f) * 1.5707963267948966f);
|
||||
case WarpMode::None:
|
||||
case WarpMode::BendPlus:
|
||||
case WarpMode::BendMinus:
|
||||
case WarpMode::Sync:
|
||||
case WarpMode::Asym:
|
||||
case WarpMode::Mirror:
|
||||
case WarpMode::Count:
|
||||
default:
|
||||
return sample;
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ public:
|
||||
void reset();
|
||||
|
||||
// Initialise a fresh note's unison sub-voices: phase offsets, detune, pan and gain.
|
||||
void noteOn (double freqHz, const OscParams& p, juce::uint32 seed);
|
||||
void noteOn (const OscParams& p, juce::uint32 seed);
|
||||
void setParams (const OscParams& p) noexcept;
|
||||
|
||||
// Accumulate this oscillator's contribution into outL/outR.
|
||||
|
||||
@@ -24,6 +24,7 @@ juce::String modSourceName (ModSource s)
|
||||
case ModSource::Macro3: return "Macro 3";
|
||||
case ModSource::Macro4: return "Macro 4";
|
||||
case ModSource::Random: return "Random";
|
||||
case ModSource::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
@@ -41,6 +42,7 @@ juce::String modSourceToString (ModSource s)
|
||||
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";
|
||||
case ModSource::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
@@ -88,6 +90,8 @@ juce::String modTargetName (ModTarget t)
|
||||
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";
|
||||
case ModTarget::None: return {};
|
||||
case ModTarget::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
@@ -127,6 +131,8 @@ juce::String modTargetParamId (ModTarget t)
|
||||
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;
|
||||
case ModTarget::None: return {};
|
||||
case ModTarget::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
+315
-136
@@ -19,7 +19,6 @@ namespace
|
||||
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.
|
||||
@@ -28,7 +27,12 @@ namespace
|
||||
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 toggleWide = 60; // wider toggle for a word like "Bipolar"
|
||||
constexpr int headerHeight = toggleHeight; // top control row height
|
||||
constexpr int depthKnobHeight = 60; // matrix / macro depth knob height
|
||||
// The header row is centred on the depth knob, which is taller than a
|
||||
// toggle, so the row centre follows the knob and not headerHeight.
|
||||
constexpr int headerCentreY = titleHeight + depthKnobHeight / 2;
|
||||
constexpr int displayHeight = 66; // waveform / filter display height
|
||||
constexpr int envDisplayHeight = 56; // envelope display height
|
||||
constexpr int lfoDisplayHeight = 64; // LFO display height
|
||||
@@ -36,6 +40,24 @@ namespace
|
||||
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
|
||||
|
||||
// Tab content starts below the top bar, so every view shares one origin.
|
||||
constexpr int tabBarHeight = 60;
|
||||
|
||||
// Panel grids. Each tab lays its panels out in a fixed number of equal
|
||||
// columns, so the column width lives here rather than beside the code
|
||||
// that happens to use it.
|
||||
constexpr int twoColW = 545; // two equal columns (OSC, FX)
|
||||
constexpr int threeColW = 360; // three equal columns (FILTER)
|
||||
constexpr int fourColW = 269; // four equal columns (MOD, MACRO)
|
||||
constexpr int fourColGap = 8; // MOD and MACRO pack their columns tighter
|
||||
|
||||
constexpr int oscPanelH = 308; // oscillator panel height
|
||||
constexpr int smallPanelH = 150; // sub, noise and FX slot panel height
|
||||
constexpr int filterPanelH = 300; // filter panel height
|
||||
constexpr int envPanelH = 154; // envelope panel height
|
||||
constexpr int lfoPanelH = 206; // LFO panel height
|
||||
constexpr int macroPanelH = 158; // macro panel height
|
||||
}
|
||||
|
||||
// Horizontal position of a grid column (0-based).
|
||||
@@ -85,8 +107,70 @@ namespace
|
||||
}
|
||||
|
||||
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); }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Display formatters. One per physical unit the editor shows. Each takes a
|
||||
// normalised 0..1 parameter and returns the value the DSP reads, with its
|
||||
// unit, so a readout and a tooltip can state the same range.
|
||||
// -----------------------------------------------------------------------
|
||||
juce::String fmtGainDb (float v) { return juce::String ((v - 0.5f) * 30.0f, 1) + " dB"; }
|
||||
juce::String fmtGainDb12 (float v) { return juce::String ((v - 0.5f) * 24.0f, 1) + " dB"; }
|
||||
juce::String fmtCompThreshold (float v) { return juce::String (-40.0f + v * 40.0f, 1) + " dB"; }
|
||||
juce::String fmtCompRatio (float v) { return juce::String (2.0f + v * 18.0f, 1) + ":1"; }
|
||||
juce::String fmtCompAttack (float v) { return juce::String (2.0f + v * 98.0f, 0) + " ms"; }
|
||||
juce::String fmtCompRelease (float v) { return juce::String (30.0f + v * 470.0f, 0) + " ms"; }
|
||||
juce::String fmtDelayTime (float v) { return juce::String (maps::delayToMs (v), 0) + " ms"; }
|
||||
juce::String fmtLfoRate (float v) { return juce::String (maps::rateToHz (v), 2) + " Hz"; }
|
||||
juce::String fmtLfoFade (float v) { return juce::String (v * 8.0f, 2) + " s"; }
|
||||
juce::String fmtLfoDelay (float v) { return juce::String (v * 4.0f, 2) + " s"; }
|
||||
|
||||
// Beat division, as the period the LFO holds for one cycle.
|
||||
juce::String fmtLfoBeat (float v)
|
||||
{
|
||||
static const char* const names[16] = {
|
||||
"1/32", "1/16", "1/8", "1/6", "1/4", "1/3", "1/2", "1",
|
||||
"1.5", "2", "3", "4", "6", "8", "12", "16"
|
||||
};
|
||||
const int idx = juce::jlimit (0, 15, (int) std::llround (v * 15.0f));
|
||||
return juce::String ("1 cycle = ") + names[idx] + " beat" + (idx == 7 ? "" : "s");
|
||||
}
|
||||
|
||||
// Phase and random phase are fractions of one cycle.
|
||||
juce::String fmtDegrees (float v) { return juce::String (juce::roundToInt (v * 360.0f)) + " deg"; }
|
||||
|
||||
// Filter output is a gain of 0 to 1.5, so 100 % is 1.5x, not unity.
|
||||
juce::String fmtFilterOut (float v) { return juce::String (juce::roundToInt (v * 150.0f)) + " %"; }
|
||||
|
||||
// Modulation depth spans -100 % to +100 %; a negative depth inverts.
|
||||
juce::String fmtModDepth (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + " %"; }
|
||||
|
||||
// Beat divisions, indexed the same way as maps::beatToMultiplier.
|
||||
juce::String formatFrequency (float hz)
|
||||
{
|
||||
if (hz >= 1000.0f)
|
||||
return juce::String (hz / 1000.0f, 1) + " kHz";
|
||||
return juce::String (hz, hz < 100.0f ? 1 : 0) + " Hz";
|
||||
}
|
||||
|
||||
juce::String fmtChorusRate (float v) { return formatFrequency (0.1f + v * 4.9f); }
|
||||
juce::String fmtFlangerRate (float v) { return formatFrequency (0.05f + v * 2.0f); }
|
||||
juce::String fmtPhaserRate (float v) { return formatFrequency (0.05f + v * 2.0f); }
|
||||
juce::String fmtDistTone (float v) { return formatFrequency (800.0f + v * 15200.0f); }
|
||||
juce::String fmtEqMidFreq (float v) { return formatFrequency (200.0f + v * 4800.0f); }
|
||||
|
||||
// Chorus and flanger depth is a delay excursion in milliseconds.
|
||||
juce::String fmtChorusDepth (float v) { return juce::String (0.5f + v * 5.5f, 2) + " ms"; }
|
||||
juce::String fmtFlangerDepth (float v) { return juce::String (0.3f + v * 4.7f, 2) + " ms"; }
|
||||
|
||||
juce::String fmtDistDrive (float v) { return juce::String (1.0f + v * 24.0f, 1) + "x"; }
|
||||
juce::String fmtDistOutput (float v) { return juce::String (0.4f + v * 1.2f, 2) + "x"; }
|
||||
juce::String fmtHyperOutput (float v) { return juce::String (0.5f + v, 2) + "x"; }
|
||||
juce::String fmtPhaserStages (float v)
|
||||
{
|
||||
return juce::String (juce::jlimit (2, 6, 2 + (int) (v * 4.0f + 0.5f))) + " stages";
|
||||
}
|
||||
juce::String fmtPingPong (float v) { return v > 0.5f ? "On" : "Off"; }
|
||||
juce::String fmtPredelayMs (float v) { return juce::String (v * 150.0f, 0) + " ms"; }
|
||||
|
||||
void placeKnobs (std::vector<Knob*>& knobs, int x, int y, int w, int h, int gap = layout::gap)
|
||||
{
|
||||
@@ -101,17 +185,61 @@ namespace
|
||||
constexpr auto& kFxP3 = paramIds::fxP3;
|
||||
constexpr auto& kFxP4 = paramIds::fxP4;
|
||||
|
||||
constexpr const char* kFxParamNames[(int) FxType::Count][4] = {
|
||||
{ "P1", "P2", "P3", "P4" },
|
||||
{ "Intensity", "Low Amount", "High Amount", "Output" },
|
||||
{ "Rate", "Depth", "Width", "Unused" },
|
||||
{ "Rate", "Depth", "Feedback", "Unused" },
|
||||
{ "Rate", "Depth", "Feedback", "Stages" },
|
||||
{ "Drive", "Shape", "Tone", "Output" },
|
||||
{ "Low Gain", "Mid Gain", "High Gain", "Mid Freq" },
|
||||
{ "Threshold", "Ratio", "Attack", "Release" },
|
||||
{ "Time", "Feedback", "Damping", "Ping-Pong" },
|
||||
{ "Size", "Damping", "Width", "Predelay" }
|
||||
// How one FX slot parameter is presented: the name under the knob, the text
|
||||
// that converts its normalised value to physical units, and the tooltip that
|
||||
// states what it does and its range.
|
||||
struct FxParamDisplay
|
||||
{
|
||||
const char* name;
|
||||
const char* tooltip;
|
||||
std::function<juce::String (float)> formatter;
|
||||
};
|
||||
|
||||
// Indexed by FxType then by parameter slot. Names and ranges come from the
|
||||
// effect that reads the parameter in Source/EffectUnits/.
|
||||
const FxParamDisplay kFxParamDisplay[(int) FxType::Count][4] = {
|
||||
// Off: the rack skips the slot entirely, so say so rather than showing
|
||||
// four knobs that do nothing.
|
||||
{ { "Unused", "Effect is off, this slot does nothing", formatPercent },
|
||||
{ "Unused", "Effect is off, this slot does nothing", formatPercent },
|
||||
{ "Unused", "Effect is off, this slot does nothing", formatPercent },
|
||||
{ "Unused", "Effect is off, this slot does nothing", formatPercent } },
|
||||
{ { "Intensity", "How far the multiband up and down compression goes, 0 to 100 %", formatPercent },
|
||||
{ "Low Amount", "How much of the low band is fed back in, 40 to 100 %", formatPercent },
|
||||
{ "High Amount", "How much of the high band is fed back in, 40 to 100 %", formatPercent },
|
||||
{ "Output", "Output gain, 0.5 to 1.5x", fmtHyperOutput } },
|
||||
{ { "Rate", "Modulation speed, 0.1 Hz to 5 Hz", fmtChorusRate },
|
||||
{ "Depth", "Pitch swing, 0.5 ms to 6 ms of delay", fmtChorusDepth },
|
||||
{ "Width", "Stereo width of the modulated copy, 0 to 100 %", formatPercent },
|
||||
{ "Unused", "Not used by this effect", formatPercent } },
|
||||
{ { "Rate", "Modulation speed, 0.05 Hz to 2.05 Hz", fmtFlangerRate },
|
||||
{ "Depth", "Pitch swing, 0.3 ms to 5 ms of delay", fmtFlangerDepth },
|
||||
{ "Feedback", "How much output is fed back in, 0 to 90 %", formatPercent },
|
||||
{ "Unused", "Not used by this effect", formatPercent } },
|
||||
{ { "Rate", "Modulation speed, 0.05 Hz to 2.05 Hz", fmtPhaserRate },
|
||||
{ "Depth", "Sweep depth, 30 to 90 %", formatPercent },
|
||||
{ "Feedback", "How much output is fed back in, 0 to 70 %", formatPercent },
|
||||
{ "Stages", "Number of all-pass stages, 2 to 6", fmtPhaserStages } },
|
||||
{ { "Drive", "Input gain into the shaper, 1x to 25x", fmtDistDrive },
|
||||
{ "Shape", "Clipping shape. Low is soft, middle is hard, high folds.", formatPercent },
|
||||
{ "Tone", "Tone filter cutoff, 800 Hz to 16 kHz", fmtDistTone },
|
||||
{ "Output", "Output gain, 0.4x to 1.6x", fmtDistOutput } },
|
||||
{ { "Low Gain", "Low band gain, -15 dB to +15 dB", fmtGainDb },
|
||||
{ "Mid Gain", "Mid band gain, -12 dB to +12 dB", fmtGainDb12 },
|
||||
{ "High Gain", "High band gain, -15 dB to +15 dB", fmtGainDb },
|
||||
{ "Mid Freq", "Mid band centre frequency, 200 Hz to 5 kHz", fmtEqMidFreq } },
|
||||
{ { "Threshold", "Level where compression starts, -40 dB to 0 dB", fmtCompThreshold },
|
||||
{ "Ratio", "Input to output ratio above the threshold, 2:1 to 20:1", fmtCompRatio },
|
||||
{ "Attack", "How fast the compressor reacts, 2 ms to 100 ms", fmtCompAttack },
|
||||
{ "Release", "How fast the compressor lets go, 30 ms to 500 ms", fmtCompRelease } },
|
||||
{ { "Time", "Delay time, 1 ms to 2000 ms", fmtDelayTime },
|
||||
{ "Feedback", "How much output is fed back in, 0 to 92 %", formatPercent },
|
||||
{ "Damping", "How much the repeats lose their top end, 0 to 95 %", formatPercent },
|
||||
{ "Ping-Pong", "Bounces the repeats between left and right, on or off", fmtPingPong } },
|
||||
{ { "Size", "Reverb room size, 20 to 99 %", formatPercent },
|
||||
{ "Damping", "How much the tail loses its top end, 0 to 100 %", formatPercent },
|
||||
{ "Width", "Stereo width of the tail, 0 to 100 %", formatPercent },
|
||||
{ "Predelay", "Gap before the reverb starts, 0 ms to 150 ms", fmtPredelayMs } }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,9 +301,15 @@ Knob* PluginEditor::makeKnob (juce::Component* parent, const juce::String& name,
|
||||
auto* k = control.get();
|
||||
ownedControls.push_back (std::move (control));
|
||||
parent->addAndMakeVisible (k);
|
||||
if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr)
|
||||
if (auto* param = paramId.isNotEmpty() ? processor.parameters.getParameter (paramId) : nullptr)
|
||||
{
|
||||
// A double click restores the parameter's own default. Without this a
|
||||
// double click writes the knob's built-in 0.5 and silently changes any
|
||||
// parameter whose default is not 0.5.
|
||||
k->setDefaultValue (param->convertFrom0to1 (param->getDefaultValue()));
|
||||
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
|
||||
processor.parameters, paramId, *k));
|
||||
}
|
||||
k->setTooltip (tooltip.isNotEmpty() ? tooltip : name);
|
||||
return k;
|
||||
}
|
||||
@@ -222,7 +356,7 @@ void PluginEditor::buildTopBar()
|
||||
presetCombo.onChange = [this] { processor.setCurrentProgram (presetCombo.getSelectedItemIndex()); };
|
||||
root.addAndMakeVisible (presetCombo);
|
||||
presetCombo.setBounds (200, 16, 220, 24);
|
||||
presetCombo.setTooltip ("Select a factory preset");
|
||||
presetCombo.setTooltip ("Loads a factory preset and replaces the current patch");
|
||||
|
||||
// UI scale.
|
||||
scaleCombo.addItemList ({ "75%", "100%", "125%", "150%", "200%" }, 1);
|
||||
@@ -230,25 +364,25 @@ void PluginEditor::buildTopBar()
|
||||
scaleCombo.onChange = [this] { processor.setUiScaleIndex (scaleCombo.getSelectedItemIndex()); };
|
||||
root.addAndMakeVisible (scaleCombo);
|
||||
scaleCombo.setBounds (980, 16, 70, 24);
|
||||
scaleCombo.setTooltip ("Interface scale (75% - 200%)");
|
||||
scaleCombo.setTooltip ("Interface scale, 75 % to 200 %");
|
||||
|
||||
// RAVE.
|
||||
raveButton.setLabel ("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)");
|
||||
raveButton.setTooltip ("Adds unison, width, drive, OTT and reverb to the current patch");
|
||||
// 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)" };
|
||||
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 the routing matrix.",
|
||||
"Effects rack. Tab cycles sections.",
|
||||
"Macros. Tab cycles sections." };
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
tabs[i]->setButtonText (tabNames[i]);
|
||||
@@ -262,7 +396,7 @@ void PluginEditor::buildTopBar()
|
||||
|
||||
// Master.
|
||||
masterKnob.setBounds (906, 0, 64, 60);
|
||||
masterKnob.setTooltip ("Master output volume (0-100%)");
|
||||
masterKnob.setTooltip ("Master output level, 0 % to 100 %");
|
||||
root.addAndMakeVisible (masterKnob);
|
||||
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
|
||||
processor.parameters, ids::master, masterKnob));
|
||||
@@ -274,12 +408,12 @@ void PluginEditor::buildTopBar()
|
||||
|
||||
void PluginEditor::buildOscTab()
|
||||
{
|
||||
oscView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
oscView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (oscView);
|
||||
|
||||
constexpr int colW = 545; // two equal columns filling kBaseW
|
||||
constexpr int oscH = 308;
|
||||
constexpr int subH = 150;
|
||||
constexpr int colW = layout::twoColW;
|
||||
constexpr int oscH = layout::oscPanelH;
|
||||
constexpr int subH = layout::smallPanelH;
|
||||
|
||||
oscAPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, oscH);
|
||||
oscBPanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, oscH);
|
||||
@@ -294,9 +428,9 @@ void PluginEditor::buildOscTab()
|
||||
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")
|
||||
makeToggle (&oscAPanel, "On", ids::oscAOn, "Turns oscillator A on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&oscAPanel, ids::oscAWave, wavetableItems(), "Selects the wavetable, 10 factory tables")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
|
||||
makeCombo (&oscAPanel, ids::oscAWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Selects the wavetable warp mode")
|
||||
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
|
||||
|
||||
oscAWave.setWavetables (&processor.engine.getWavetables());
|
||||
@@ -315,17 +449,19 @@ void PluginEditor::buildOscTab()
|
||||
|
||||
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%)")
|
||||
makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatCents, "Unison detune spread, 0 to 50 cents"),
|
||||
makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison stereo spread (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Phase", ids::oscAPhase, fmtDegrees, "Start phase at note on, 0 to 360 degrees"),
|
||||
makeKnob (&oscAPanel, "Rand phase", ids::oscARandPh, fmtDegrees, "Random start phase per voice, 0 to 360 degrees")
|
||||
};
|
||||
placeKnobs (rowA2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
|
||||
// Unison is a whole number of voices, so the dial should rest on a detent.
|
||||
rowA2[0]->setRange (0.0, 1.0, 1.0 / (kMaxUnison - 1));
|
||||
|
||||
// --- 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")
|
||||
makeToggle (&oscBPanel, "On", ids::oscBOn, "Turns oscillator B on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&oscBPanel, ids::oscBWave, wavetableItems(), "Selects the wavetable, 10 factory tables")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
|
||||
makeCombo (&oscBPanel, ids::oscBWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Selects the wavetable warp mode")
|
||||
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
|
||||
|
||||
oscBWave.setWavetables (&processor.engine.getWavetables());
|
||||
@@ -344,32 +480,33 @@ void PluginEditor::buildOscTab()
|
||||
|
||||
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%)")
|
||||
makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatCents, "Unison detune spread, 0 to 50 cents"),
|
||||
makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison stereo spread (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Phase", ids::oscBPhase, fmtDegrees, "Start phase at note on, 0 to 360 degrees"),
|
||||
makeKnob (&oscBPanel, "Rand phase", ids::oscBRandPh, fmtDegrees, "Random start phase per voice, 0 to 360 degrees")
|
||||
};
|
||||
placeKnobs (rowB2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
|
||||
rowB2[0]->setRange (0.0, 1.0, 1.0 / (kMaxUnison - 1));
|
||||
|
||||
// --- 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);
|
||||
makeToggle (&subPanel, "On", ids::subOn, "Turns the sub oscillator on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&subPanel, ids::subShape, { "Sine", "Triangle" }, "Sub oscillator waveform, sine or triangle")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
|
||||
makeCombo (&subPanel, ids::subOct, { "-2 oct", "-1 oct", "0 oct" }, "Sub oscillator octave, 2 below to unison")->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);
|
||||
makeToggle (&noisePanel, "On", ids::noiseOn, "Turns noise on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&noisePanel, ids::noiseType, { "White", "Pink" }, "Noise colour, white or pink")->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);
|
||||
filterView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (filterView);
|
||||
|
||||
constexpr int colW = 360;
|
||||
constexpr int panelH = 300; // retains the original panel height (content top-aligned)
|
||||
constexpr int colW = layout::threeColW;
|
||||
constexpr int panelH = layout::filterPanelH;
|
||||
|
||||
filter1Panel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, panelH);
|
||||
filter2Panel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, panelH);
|
||||
@@ -385,52 +522,52 @@ void PluginEditor::buildFilterTab()
|
||||
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);
|
||||
makeToggle (&filter1Panel, "On", ids::f1On, "Turns filter 1 on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&filter1Panel, ids::f1Type, filterTypes, "Selects the filter 1 model")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
|
||||
makeCombo (&filter1Panel, ids::f1Slope, slopes, "Slope, 6, 12 or 24 dB per octave on the ladder and diode models")->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%)")
|
||||
makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff, 20 Hz to 18 kHz"),
|
||||
makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance, 0 % to 100 %"),
|
||||
makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive, 0 % to 100 %. Damps the comb model."),
|
||||
makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 key tracking, 0 % to 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);
|
||||
makeToggle (&filter2Panel, "On", ids::f2On, "Turns filter 2 on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&filter2Panel, ids::f2Type, filterTypes, "Selects the filter 2 model")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
|
||||
makeCombo (&filter2Panel, ids::f2Slope, slopes, "Slope, 6, 12 or 24 dB per octave on the ladder and diode models")->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%)")
|
||||
makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff, 20 Hz to 18 kHz"),
|
||||
makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance, 0 % to 100 %"),
|
||||
makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive, 0 % to 100 %. Damps the comb model."),
|
||||
makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 key tracking, 0 % to 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);
|
||||
makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing, serial, parallel or split")->setBounds (layout::padding, comboY, 160, layout::comboHeight);
|
||||
makeKnob (&routingPanel, "Mix", ids::fMix, formatPercent, "Blends filter 2 over filter 1 in parallel routing, 0 % to 100 %")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
|
||||
makeKnob (&routingPanel, "Output", ids::fOut, fmtFilterOut, "Filter output gain, 0 % to 150 %")->setBounds (layout::padding + layout::knobSize + layout::gap, displayY, layout::knobSize, layout::knobSize);
|
||||
}
|
||||
|
||||
void PluginEditor::buildModTab()
|
||||
{
|
||||
modView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
modView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
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;
|
||||
constexpr int colW = layout::fourColW;
|
||||
constexpr int colGap = layout::fourColGap;
|
||||
constexpr int envH = layout::envPanelH;
|
||||
constexpr int lfoH = layout::lfoPanelH;
|
||||
|
||||
// Envelopes (4 across).
|
||||
for (int i = 0; i < kNumEnvelopes; ++i)
|
||||
@@ -449,11 +586,11 @@ void PluginEditor::buildModTab()
|
||||
const auto& idsC = paramIds::envCurve;
|
||||
|
||||
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%)")
|
||||
makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time, 0.5 ms to 12 s"),
|
||||
makeKnob (&envPanels[(size_t) i], "Decay", idsD[i], formatSeconds, "Decay time, 0.5 ms to 12 s"),
|
||||
makeKnob (&envPanels[(size_t) i], "Sustain", idsS[i], formatPercent, "Sustain level, 0 % to 100 %"),
|
||||
makeKnob (&envPanels[(size_t) i], "Release", idsR[i], formatSeconds, "Release time, 0.5 ms to 12 s"),
|
||||
makeKnob (&envPanels[(size_t) i], "Curve", idsC[i], formatPercent, "Curve shape. Low is a fast attack and slow decay, high is the reverse.")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding,
|
||||
layout::titleHeight + layout::envDisplayHeight + layout::padding,
|
||||
@@ -475,18 +612,18 @@ void PluginEditor::buildModTab()
|
||||
const auto& fade = paramIds::lfoFade;
|
||||
const auto& delay = paramIds::lfoDelay;
|
||||
|
||||
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);
|
||||
makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "Selects the LFO waveform")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight);
|
||||
makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Locks the LFO to host tempo and uses the Beat knob")->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%)")
|
||||
makeKnob (&lfoPanels[(size_t) i], "Rate", rate[i], fmtLfoRate, "LFO rate, 0.02 Hz to 30 Hz"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], fmtLfoBeat, "Cycle length, 1/32 beat to 16 beats"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], fmtDegrees, "Start phase, 0 to 360 degrees"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], fmtLfoFade, "Fade in time, 0 s to 8 s"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], fmtLfoDelay, "Start delay, 0 s to 4 s")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding,
|
||||
bodyTop() + layout::lfoDisplayHeight + layout::padding,
|
||||
@@ -500,65 +637,70 @@ void PluginEditor::buildModTab()
|
||||
|
||||
// Modulation matrix.
|
||||
const int matrixTop = lfoTop + lfoH + layout::panelSpacing;
|
||||
const int matrixH = kBaseH - 60 - matrixTop - layout::margin;
|
||||
const int matrixH = kBaseH - layout::tabBarHeight - 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
|
||||
const int mCenter = layout::headerCentreY; // centre line of the depth row
|
||||
|
||||
modSourceCombo.addItemList (modSourceItems(), 1);
|
||||
modSourceCombo.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
modSourceCombo.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 160, layout::comboHeight);
|
||||
modSourceCombo.setTooltip ("Modulation source");
|
||||
modSourceCombo.setTooltip ("Selects the 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");
|
||||
modTargetCombo.setTooltip ("Selects the 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)");
|
||||
modDepthKnob.setDefaultValue (0.0); // a double click recentres the depth
|
||||
modDepthKnob.setFormatter (fmtModDepth);
|
||||
modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, layout::depthKnobHeight);
|
||||
modDepthKnob.setTooltip ("Modulation amount for the next Add, -100 % to +100 % of the target range");
|
||||
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");
|
||||
modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, layout::toggleWide, layout::toggleHeight);
|
||||
modBipolarToggle.setTooltip ("Recentres a one-sided source so the modulation swings both ways, for the next Add");
|
||||
matrixPanel.addAndMakeVisible (modBipolarToggle);
|
||||
modBipolarToggle.setToggleState (true);
|
||||
|
||||
const int addX = depthX + layout::knobSize + layout::gap + 60 + layout::gap;
|
||||
const int addX = depthX + layout::knobSize + layout::gap + layout::toggleWide + layout::gap;
|
||||
modAddButton.setButtonText ("Add");
|
||||
modAddButton.setBounds (addX, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
|
||||
modAddButton.setTooltip ("Add modulation connection");
|
||||
modAddButton.setTooltip ("Adds the selected source and destination to the matrix");
|
||||
modAddButton.onClick = [this, targetEnums]
|
||||
{
|
||||
const ModSource src = (ModSource) modSourceCombo.getSelectedItemIndex();
|
||||
const int tid = modTargetCombo.getSelectedItemIndex();
|
||||
if (tid >= 0 && tid < (int) targetEnums.size())
|
||||
{
|
||||
bool added = false;
|
||||
{
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
|
||||
(float) modDepthKnob.getValue(),
|
||||
modBipolarToggle.getToggleState());
|
||||
added = processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
|
||||
(float) modDepthKnob.getValue(),
|
||||
modBipolarToggle.getToggleState());
|
||||
}
|
||||
updateModList();
|
||||
if (added)
|
||||
updateModList();
|
||||
else
|
||||
modAddButton.setEnabled (false); // at cap: keep list text and selection unchanged
|
||||
}
|
||||
};
|
||||
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.setTooltip ("Removes the newest matrix row, older rows stay");
|
||||
modRemoveButton.onClick = [this]
|
||||
{
|
||||
{
|
||||
@@ -573,7 +715,7 @@ void PluginEditor::buildModTab()
|
||||
|
||||
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.setTooltip ("Removes every modulation connection");
|
||||
modClearButton.onClick = [this]
|
||||
{
|
||||
{
|
||||
@@ -584,11 +726,12 @@ void PluginEditor::buildModTab()
|
||||
};
|
||||
matrixPanel.addAndMakeVisible (modClearButton);
|
||||
|
||||
const int listTop = layout::titleHeight + 60 + layout::padding;
|
||||
const int listTop = layout::titleHeight + layout::depthKnobHeight + layout::padding;
|
||||
modList.setBounds (layout::padding, listTop,
|
||||
kBaseW - 2 * layout::margin - 2 * layout::padding,
|
||||
matrixH - listTop - layout::padding);
|
||||
modList.setReadOnly (true);
|
||||
modList.setTooltip ("Every modulation connection, newest last. Depth is a fraction of the target range.");
|
||||
modList.setMultiLine (true, false);
|
||||
modList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
|
||||
modList.setColour (juce::TextEditor::textColourId, theme::text);
|
||||
@@ -598,14 +741,17 @@ void PluginEditor::buildModTab()
|
||||
|
||||
void PluginEditor::buildFxTab()
|
||||
{
|
||||
fxView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
fxView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (fxView);
|
||||
|
||||
const juce::StringArray fxTypes = { "Off", "Hyper", "Chorus", "Flanger", "Phaser",
|
||||
"Distortion", "EQ", "Compressor", "Delay", "Reverb" };
|
||||
// Built from the one label function so the item order always follows FxType,
|
||||
// whose values are used directly as the combo selection / parameter value.
|
||||
juce::StringArray fxTypes;
|
||||
for (int t = 0; t < (int) FxType::Count; ++t)
|
||||
fxTypes.add (FXProcessor::fxTypeName (t));
|
||||
|
||||
constexpr int colW = 545;
|
||||
constexpr int slotH = 150;
|
||||
constexpr int colW = layout::twoColW;
|
||||
constexpr int slotH = layout::smallPanelH;
|
||||
|
||||
for (int i = 0; i < kNumFxSlots; ++i)
|
||||
{
|
||||
@@ -637,13 +783,17 @@ void PluginEditor::buildFxTab()
|
||||
|
||||
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); };
|
||||
// The first slot cannot move up and the last cannot move down, so grey
|
||||
// the arrow out instead of leaving a click that does nothing.
|
||||
fxUp[(size_t) i]->setEnabled (i > 0);
|
||||
fxDown[(size_t) i]->setEnabled (i < kNumFxSlots - 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%)")
|
||||
makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Dry and wet mix, 0 % to 100 %"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P1", kFxP1[i], formatPercent, "Effect parameter 1, set by the effect type"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P2", kFxP2[i], formatPercent, "Effect parameter 2, set by the effect type"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P3", kFxP3[i], formatPercent, "Effect parameter 3, set by the effect type"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P4", kFxP4[i], formatPercent, "Effect parameter 4, set by the effect type")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding, bodyTop(), layout::knobSize, layout::knobSize);
|
||||
fxKnobs[(size_t) i] = knobs;
|
||||
@@ -652,12 +802,12 @@ void PluginEditor::buildFxTab()
|
||||
|
||||
void PluginEditor::buildMacroTab()
|
||||
{
|
||||
macroView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
macroView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (macroView);
|
||||
|
||||
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
|
||||
constexpr int colGap = 8;
|
||||
constexpr int macroH = 158;
|
||||
constexpr int colW = layout::fourColW;
|
||||
constexpr int colGap = layout::fourColGap;
|
||||
constexpr int macroH = layout::macroPanelH;
|
||||
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
{
|
||||
@@ -674,56 +824,68 @@ void PluginEditor::buildMacroTab()
|
||||
|
||||
// Macro assignment editor.
|
||||
const int assignTop = layout::margin + macroH + layout::panelSpacing;
|
||||
const int assignH = kBaseH - 60 - assignTop - layout::margin;
|
||||
const int assignH = kBaseH - layout::tabBarHeight - 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
|
||||
const int mCenter = layout::headerCentreY; // centre line of the depth row
|
||||
|
||||
macroAssignIndex.addItemList ({ "Macro 1", "Macro 2", "Macro 3", "Macro 4" }, 1);
|
||||
juce::StringArray macroItems;
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
macroItems.add (MacroControls::macroName (i));
|
||||
macroAssignIndex.addItemList (macroItems, 1);
|
||||
macroAssignIndex.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
macroAssignIndex.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 140, layout::comboHeight);
|
||||
macroAssignIndex.setTooltip ("Macro to assign");
|
||||
macroAssignIndex.setTooltip ("Selects the macro to edit");
|
||||
macroAssignIndex.onChange = [this] { updateMacroList(); };
|
||||
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");
|
||||
macroAssignTarget.setTooltip ("Selects the 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)");
|
||||
// Half depth, so an Assign made without touching this knob still moves the
|
||||
// destination. A depth of zero would create a silent assignment.
|
||||
macroDepthKnob.setValue (0.5, juce::dontSendNotification);
|
||||
macroDepthKnob.setDefaultValue (0.5);
|
||||
macroDepthKnob.setFormatter (formatPercent);
|
||||
macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, layout::depthKnobHeight);
|
||||
macroDepthKnob.setTooltip ("Macro amount for the next Assign, 0 % to 100 % of the target range");
|
||||
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.setTooltip ("Adds this 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())
|
||||
if (macro >= 0 && macro < kNumMacros && tid >= 0 && tid < (int) targetEnums.size())
|
||||
{
|
||||
bool added = false;
|
||||
{
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
|
||||
(float) macroDepthKnob.getValue());
|
||||
added = processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
|
||||
(float) macroDepthKnob.getValue());
|
||||
}
|
||||
updateMacroList();
|
||||
if (added)
|
||||
updateMacroList();
|
||||
else
|
||||
macroAssignButton.setEnabled (false); // at cap: keep list text and selection unchanged
|
||||
}
|
||||
};
|
||||
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.setTooltip ("Removes every macro assignment from all four macros");
|
||||
macroClearButton.onClick = [this]
|
||||
{
|
||||
{
|
||||
@@ -734,11 +896,12 @@ void PluginEditor::buildMacroTab()
|
||||
};
|
||||
macroAssignPanel.addAndMakeVisible (macroClearButton);
|
||||
|
||||
const int listTop = layout::titleHeight + 60 + layout::padding;
|
||||
const int listTop = layout::titleHeight + layout::depthKnobHeight + layout::padding;
|
||||
macroList.setBounds (layout::padding, listTop,
|
||||
kBaseW - 2 * layout::margin - 2 * layout::padding,
|
||||
assignH - listTop - layout::padding);
|
||||
macroList.setReadOnly (true);
|
||||
macroList.setTooltip ("Every macro assignment, grouped by macro. Depth is a fraction of the target range.");
|
||||
macroList.setMultiLine (true, false);
|
||||
macroList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
|
||||
macroList.setColour (juce::TextEditor::textColourId, theme::text);
|
||||
@@ -751,6 +914,7 @@ void PluginEditor::swapFxSlots (int a, int b)
|
||||
if (a < 0 || b < 0 || a >= kNumFxSlots || b >= kNumFxSlots || a == b)
|
||||
return;
|
||||
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
auto swapParam = [this] (const char* pa, const char* pb)
|
||||
{
|
||||
auto* p1 = processor.parameters.getParameter (pa);
|
||||
@@ -897,12 +1061,18 @@ void PluginEditor::updateVisuals()
|
||||
for (int p = 0; p < 4; ++p)
|
||||
{
|
||||
auto* knob = fxKnobs[(size_t) i][(size_t) p + 1];
|
||||
const juce::String name = kFxParamNames[type][p];
|
||||
const auto& spec = kFxParamDisplay[type][p];
|
||||
|
||||
// Label, tooltip and readout unit all come from the one table, so
|
||||
// a renamed knob can never keep the previous effect's tooltip or
|
||||
// show a percentage for a value the DSP reads in Hz or dB.
|
||||
const juce::String name = spec.name;
|
||||
if (knob->getName() != name)
|
||||
{
|
||||
knob->setName (name);
|
||||
knob->setTooltip (name == "Unused" ? "Not used by this effect"
|
||||
: name + " (normalised 0-100%)");
|
||||
knob->setTooltip (spec.tooltip);
|
||||
knob->setFormatter (spec.formatter);
|
||||
knob->setEnabled (name != "Unused");
|
||||
knob->repaint();
|
||||
}
|
||||
}
|
||||
@@ -926,26 +1096,33 @@ void PluginEditor::updateVisuals()
|
||||
void PluginEditor::updateModList()
|
||||
{
|
||||
juce::String text;
|
||||
int count = 0;
|
||||
{
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
const auto& cons = processor.engine.getMatrix().connections;
|
||||
count = (int) cons.size();
|
||||
for (const auto& c : cons)
|
||||
text += modSourceName (c.source) + " -> " + modTargetName (c.target)
|
||||
+ " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n";
|
||||
}
|
||||
if (modList.getText() != text)
|
||||
modList.setText (text, false);
|
||||
modAddButton.setEnabled (count < ModulationMatrix::kMaxConnections);
|
||||
}
|
||||
|
||||
void PluginEditor::updateMacroList()
|
||||
{
|
||||
juce::String text;
|
||||
int assigned = 0;
|
||||
const int macro = macroAssignIndex.getSelectedItemIndex();
|
||||
{
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
for (int m = 0; m < kNumMacros; ++m)
|
||||
{
|
||||
text += MacroControls::macroName (m) + ":\n";
|
||||
const auto& assigns = processor.engine.getMacros().assignments[(size_t) m];
|
||||
if (m == macro)
|
||||
assigned = (int) assigns.size();
|
||||
if (assigns.empty())
|
||||
text += " (none)\n";
|
||||
for (const auto& a : assigns)
|
||||
@@ -954,6 +1131,8 @@ void PluginEditor::updateMacroList()
|
||||
}
|
||||
if (macroList.getText() != text)
|
||||
macroList.setText (text, false);
|
||||
macroAssignButton.setEnabled (macro >= 0 && macro < kNumMacros
|
||||
&& assigned < MacroControls::kMaxAssignments);
|
||||
}
|
||||
|
||||
void PluginEditor::timerCallback()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "PluginProcessor.h"
|
||||
#include "GUI/SerumLookAndFeel.h"
|
||||
#include "GUI/Knob.h"
|
||||
#include "GUI/Slider.h"
|
||||
#include "GUI/ToggleButton.h"
|
||||
@@ -36,7 +35,6 @@ public:
|
||||
|
||||
private:
|
||||
SerumAltAudioProcessor& processor;
|
||||
SerumLookAndFeel laf;
|
||||
|
||||
static constexpr int kBaseW = 1120;
|
||||
static constexpr int kBaseH = 740;
|
||||
@@ -63,7 +61,6 @@ private:
|
||||
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" };
|
||||
|
||||
+39
-39
@@ -1,6 +1,7 @@
|
||||
#include "PluginProcessor.h"
|
||||
#include "PluginEditor.h"
|
||||
#include "Presets/FactoryPresets.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -98,12 +99,12 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea
|
||||
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 };
|
||||
// Envelopes 1..4 (IDs come from the shared paramIds groups)
|
||||
constexpr auto& envA = paramIds::envAttack;
|
||||
constexpr auto& envD = paramIds::envDecay;
|
||||
constexpr auto& envS = paramIds::envSustain;
|
||||
constexpr auto& envR = paramIds::envRelease;
|
||||
constexpr auto& envC = paramIds::envCurve;
|
||||
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 };
|
||||
@@ -117,14 +118,14 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea
|
||||
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 };
|
||||
// LFOs 1..4 (IDs come from the shared paramIds groups)
|
||||
constexpr auto& lfoRate = paramIds::lfoRate;
|
||||
constexpr auto& lfoSync = paramIds::lfoSync;
|
||||
constexpr auto& lfoBeat = paramIds::lfoBeat;
|
||||
constexpr auto& lfoShape = paramIds::lfoShape;
|
||||
constexpr auto& lfoPhase = paramIds::lfoPhase;
|
||||
constexpr auto& lfoFade = paramIds::lfoFade;
|
||||
constexpr auto& lfoDelay = paramIds::lfoDelay;
|
||||
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)
|
||||
{
|
||||
@@ -138,37 +139,31 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea
|
||||
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 }
|
||||
};
|
||||
// FX slots 1..8 (IDs come from the shared paramIds groups)
|
||||
constexpr auto& fxType = paramIds::fxType;
|
||||
constexpr auto& fxMix = paramIds::fxMix;
|
||||
const char* const fxP[4][8] = { { paramIds::fxP1[0], paramIds::fxP1[1], paramIds::fxP1[2], paramIds::fxP1[3],
|
||||
paramIds::fxP1[4], paramIds::fxP1[5], paramIds::fxP1[6], paramIds::fxP1[7] },
|
||||
{ paramIds::fxP2[0], paramIds::fxP2[1], paramIds::fxP2[2], paramIds::fxP2[3],
|
||||
paramIds::fxP2[4], paramIds::fxP2[5], paramIds::fxP2[6], paramIds::fxP2[7] },
|
||||
{ paramIds::fxP3[0], paramIds::fxP3[1], paramIds::fxP3[2], paramIds::fxP3[3],
|
||||
paramIds::fxP3[4], paramIds::fxP3[5], paramIds::fxP3[6], paramIds::fxP3[7] },
|
||||
{ paramIds::fxP4[0], paramIds::fxP4[1], paramIds::fxP4[2], paramIds::fxP4[3],
|
||||
paramIds::fxP4[4], paramIds::fxP4[5], paramIds::fxP4[6], paramIds::fxP4[7] } };
|
||||
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));
|
||||
params.push_back (f (fxP[0][i], n + " P1", 0.5f));
|
||||
params.push_back (f (fxP[1][i], n + " P2", 0.5f));
|
||||
params.push_back (f (fxP[2][i], n + " P3", 0.5f));
|
||||
params.push_back (f (fxP[3][i], 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));
|
||||
// Macros (display names come from MacroControls::macroName, the single source)
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
params.push_back (f (paramIds::macros[i], MacroControls::macroName (i), 0.0f));
|
||||
|
||||
return { params.begin(), params.end() };
|
||||
}
|
||||
@@ -266,6 +261,9 @@ void SerumAltAudioProcessor::loadFactoryPreset (int index)
|
||||
continue;
|
||||
|
||||
restoreLfoShapesFromState ({});
|
||||
// A preset starts from clean DSP state, so the previous preset's filter,
|
||||
// delay and reverb tails cannot bleed into the new one.
|
||||
engine.requestReset();
|
||||
currentProgram.store (index);
|
||||
}
|
||||
|
||||
@@ -356,6 +354,8 @@ void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBy
|
||||
engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX"));
|
||||
engine.getMacros().fromValueTree (state.getChildWithName ("MACROS"));
|
||||
restoreLfoShapesFromState (state);
|
||||
// Loading a saved project is a patch change too: start from clean state.
|
||||
engine.requestReset();
|
||||
|
||||
const double savedProgram = (double) state.getProperty ("currentProgram", 0);
|
||||
currentProgram.store (std::isfinite (savedProgram)
|
||||
@@ -406,7 +406,7 @@ void SerumAltAudioProcessor::restoreLfoShapesFromState (const juce::ValueTree& s
|
||||
continue;
|
||||
const double savedIndex = (double) lfo.getProperty ("index", -1);
|
||||
if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumLfos
|
||||
|| std::floor (savedIndex) != savedIndex)
|
||||
|| ! isWholeNumber (savedIndex))
|
||||
continue;
|
||||
const int index = (int) savedIndex;
|
||||
if (restored[(size_t) index])
|
||||
|
||||
@@ -30,4 +30,4 @@ pr.params = { { "oscAUnison", 0.4f } };
|
||||
pr.params = { p (ids::oscAUnison, unison (7)) };
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -31,4 +31,4 @@ g.setColour (juce::Colour (0xff4fc3f7));
|
||||
g.setColour (theme::accent);
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -3,12 +3,8 @@
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void SubOscillator::noteOn (double freqHz, int octaveShift)
|
||||
void SubOscillator::noteOn()
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -18,14 +14,14 @@ void SubOscillator::processAdd (double freqHz, int shape, float level, float& ou
|
||||
return;
|
||||
|
||||
// The octave shift is baked into freqHz by the voice; here we just phase-accumulate.
|
||||
const double inc = kTwoPi * freqHz / sr;
|
||||
const double inc = juce::MathConstants<double>::twoPi * freqHz / sr;
|
||||
phase += inc;
|
||||
phase -= std::floor (phase * (1.0 / kTwoPi)) * kTwoPi;
|
||||
phase -= std::floor (phase * (1.0 / juce::MathConstants<double>::twoPi)) * juce::MathConstants<double>::twoPi;
|
||||
|
||||
float s = 0.0f;
|
||||
if (shape == (int) SubShape::Triangle)
|
||||
{
|
||||
const float p = (float) (phase * (1.0 / kTwoPi));
|
||||
const float p = (float) (phase * (1.0 / juce::MathConstants<double>::twoPi));
|
||||
s = 1.0f - 4.0f * std::abs (p - 0.5f);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -15,7 +15,7 @@ public:
|
||||
void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; }
|
||||
void reset() { phase = 0.0; }
|
||||
|
||||
void noteOn (double freqHz, int octaveShift);
|
||||
void noteOn();
|
||||
|
||||
// Accumulate into out (mono; the caller pans/levels it).
|
||||
void processAdd (double freqHz, int shape, float level, float& out) noexcept;
|
||||
@@ -23,7 +23,6 @@ public:
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
double phase = 0.0;
|
||||
static constexpr double kTwoPi = 6.28318530717958647692;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
+18
-6
@@ -17,7 +17,7 @@ void SynthVoice::prepare (double sampleRate, int maxBlockSize)
|
||||
oscB.prepare (sampleRate);
|
||||
sub.prepare (sampleRate);
|
||||
noise.prepare (sampleRate);
|
||||
filters.prepare (sampleRate, maxBlockSize);
|
||||
filters.prepare (sampleRate);
|
||||
for (auto& e : env)
|
||||
e.prepare (sampleRate);
|
||||
scratch.setSize (2, maxBlockSize, false, false, true);
|
||||
@@ -55,7 +55,7 @@ void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce::
|
||||
juce::Random rng (noteSeed);
|
||||
noteRandom = rng.nextFloat();
|
||||
|
||||
sub.noteOn (freqHz, -1);
|
||||
sub.noteOn();
|
||||
noise.noteOn (noteSeed);
|
||||
|
||||
for (auto& e : env)
|
||||
@@ -71,8 +71,20 @@ void SynthVoice::noteOff()
|
||||
e.noteOff();
|
||||
}
|
||||
|
||||
float SynthVoice::getModulationValue (ModSource source) const noexcept
|
||||
{
|
||||
if (source >= ModSource::Env1 && source <= ModSource::Env4)
|
||||
return env[(size_t) ((int) source - (int) ModSource::Env1)].getValue();
|
||||
if (source == ModSource::Velocity) return velocity;
|
||||
if (source == ModSource::Note) return clampF ((float) note / 127.0f, 0.0f, 1.0f);
|
||||
if (source == ModSource::Random) return noteRandom;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept
|
||||
{
|
||||
jassert (numSamples <= scratch.getNumSamples());
|
||||
|
||||
if (! active || numSamples <= 0)
|
||||
return;
|
||||
|
||||
@@ -118,14 +130,14 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC
|
||||
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;
|
||||
mod[(size_t) 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;
|
||||
mod[(size_t) a.target] += ctx.macroValues[m] * a.depth;
|
||||
}
|
||||
|
||||
// 5. Modulated oscillator parameters.
|
||||
@@ -157,8 +169,8 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC
|
||||
// without resetting existing phases when modulated at control rate.
|
||||
if (oscillatorsNeedNoteOn)
|
||||
{
|
||||
oscA.noteOn (freqA, a, seed);
|
||||
oscB.noteOn (freqB, b, seed + 1);
|
||||
oscA.noteOn (a, seed);
|
||||
oscB.noteOn (b, seed + 1);
|
||||
oscillatorsNeedNoteOn = false;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -72,6 +72,7 @@ public:
|
||||
bool isReleased() const noexcept { return released; }
|
||||
int getNote() const noexcept { return note; }
|
||||
juce::uint64 getNoteId() const noexcept { return noteId; }
|
||||
float getModulationValue (ModSource source) const noexcept;
|
||||
|
||||
private:
|
||||
Oscillator oscA, oscB;
|
||||
|
||||
@@ -37,4 +37,4 @@ if (! ok) ++failures;
|
||||
return failures == 0 ? 0 : 1;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
+121
-51
@@ -9,6 +9,8 @@
|
||||
// ===========================================================================
|
||||
namespace
|
||||
{
|
||||
const int notes[3] = { 48, 60, 67 };
|
||||
|
||||
bool hasNaN (const juce::AudioBuffer<float>& b)
|
||||
{
|
||||
for (int ch = 0; ch < b.getNumChannels(); ++ch)
|
||||
@@ -32,6 +34,36 @@ namespace
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// Plays three notes through the current preset, releases them partway, and
|
||||
// reports whether the render stayed finite and non-silent overall.
|
||||
bool runPreset (serum::SerumAltAudioProcessor& processor, const juce::String& label, double maxPeakFloor)
|
||||
{
|
||||
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 > (float) maxPeakFloor;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [" << label << "]"
|
||||
<< " peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
@@ -44,64 +76,18 @@ int main()
|
||||
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";
|
||||
}
|
||||
if (! runPreset (processor, "Init", 0.001))
|
||||
++failures;
|
||||
|
||||
// Every factory preset.
|
||||
for (int preset = 0; preset < numPresets; ++preset)
|
||||
{
|
||||
processor.setCurrentProgram (preset);
|
||||
const juce::String name = processor.getProgramName (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";
|
||||
if (! runPreset (processor, name, 0.001))
|
||||
++failures;
|
||||
}
|
||||
|
||||
// RAVE toggle + parameter churn while playing (no crash / NaN).
|
||||
@@ -129,6 +115,90 @@ int main()
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [RAVE toggle / param churn]" << (nan ? " <NaN!>" : "") << "\n";
|
||||
}
|
||||
|
||||
// FX stability: a preset change selects an FX type and its parameters, so
|
||||
// every unit has to survive the extremes of its own parameter range. A unit
|
||||
// that lets its state run away keeps producing inf/NaN, which is heard as
|
||||
// grainy audio that gets no better.
|
||||
{
|
||||
const char* const typeLabels[] = { "Hyper", "Chorus", "Flanger", "Phaser",
|
||||
"Distortion", "EQ", "Compressor", "Delay", "Reverb" };
|
||||
|
||||
for (int type = 1; type <= 9; ++type)
|
||||
{
|
||||
processor.setCurrentProgram (0);
|
||||
auto set = [&processor] (const char* id, float value)
|
||||
{
|
||||
if (auto* p = processor.parameters.getParameter (id))
|
||||
p->setValueNotifyingHost (value);
|
||||
};
|
||||
set ("fx1Type", (float) type / 9.0f);
|
||||
set ("fx1Mix", 1.0f);
|
||||
set ("fx1P1", 1.0f);
|
||||
set ("fx1P2", 1.0f);
|
||||
set ("fx1P3", 1.0f);
|
||||
set ("fx1P4", 1.0f);
|
||||
|
||||
juce::MidiBuffer midi;
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, 60, (juce::uint8) 100), 0);
|
||||
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
bool nan = false;
|
||||
for (int block = 0; block < 120; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
nan = nan || hasNaN (buffer);
|
||||
}
|
||||
|
||||
const bool ok = ! nan;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [FX " << typeLabels[type - 1]
|
||||
<< " at full parameter range]" << (nan ? " <NaN!>" : "") << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// A preset change starts from clean DSP state: the tail of the preset being
|
||||
// left behind must not bleed into the one being loaded.
|
||||
{
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
processor.setCurrentProgram (9); // Glass Bell, reverb mix 0.6
|
||||
|
||||
juce::MidiBuffer midi;
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
|
||||
for (int block = 0; block < 40; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
}
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
|
||||
for (int block = 0; block < 10; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
}
|
||||
const float tail = peak (buffer);
|
||||
|
||||
processor.setCurrentProgram (0); // the change under test
|
||||
float residual = 0.0f;
|
||||
for (int block = 0; block < 2; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
juce::MidiBuffer empty;
|
||||
processor.processBlock (buffer, empty);
|
||||
residual = std::max (residual, peak (buffer));
|
||||
}
|
||||
|
||||
const bool ok = tail > 0.01f && residual < 1.0e-4f;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [preset change clears the previous patch]"
|
||||
<< " tail=" << tail << " residual=" << residual << "\n";
|
||||
}
|
||||
|
||||
std::cout << "\n" << (failures == 0 ? "ALL QA CHECKS PASSED" : "QA FAILURES DETECTED")
|
||||
<< " (" << failures << " failures)\n";
|
||||
return failures == 0 ? 0 : 1;
|
||||
|
||||
+6
-12
@@ -18,7 +18,6 @@ void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int 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)
|
||||
{
|
||||
@@ -36,8 +35,8 @@ void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int n
|
||||
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;
|
||||
phase[(size_t) h] = (a < 0.0f) ? juce::MathConstants<double>::twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base
|
||||
const double ang = juce::MathConstants<double>::twoPi * (double) h * (double) invN;
|
||||
cosStep[(size_t) h] = std::cos (ang);
|
||||
sinStep[(size_t) h] = std::sin (ang);
|
||||
maxAmp = std::max (maxAmp, m);
|
||||
@@ -153,7 +152,6 @@ void WavetableLibrary::buildTable (int index) const
|
||||
namespace
|
||||
{
|
||||
inline float normT (int frame) { return (float) frame / 255.0f; }
|
||||
constexpr float kPi = 3.14159265358979323846f;
|
||||
}
|
||||
|
||||
// sine -> saw morph
|
||||
@@ -178,8 +176,8 @@ Wavetable WavetableLibrary::makeSawPwm()
|
||||
{
|
||||
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;
|
||||
const float a = std::sin (juce::MathConstants<float>::pi * (float) h * duty);
|
||||
return (2.0f / ((float) h * juce::MathConstants<float>::pi)) * a;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
@@ -192,7 +190,7 @@ Wavetable WavetableLibrary::makeSquareSync()
|
||||
{
|
||||
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);
|
||||
const float comb = 0.5f + 0.5f * std::cos (2.0f * juce::MathConstants<float>::pi * (float) h * ratio);
|
||||
return (1.0f / std::pow ((float) h, 0.8f)) * comb;
|
||||
}, 48);
|
||||
return wt;
|
||||
@@ -211,7 +209,7 @@ Wavetable WavetableLibrary::makeTriangleFold()
|
||||
{
|
||||
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));
|
||||
tri = sign * 8.0f / (juce::MathConstants<float>::pi * juce::MathConstants<float>::pi * (float) (h * h));
|
||||
}
|
||||
const float saw = 1.0f / (float) h;
|
||||
return tri + (saw - tri) * t;
|
||||
@@ -235,15 +233,11 @@ Wavetable WavetableLibrary::makeVowel()
|
||||
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;
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
## 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.
|
||||
and `../Engine.cpp`.
|
||||
|
||||
## 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.
|
||||
- Bind APVTS pointers in `prepare()`; `v()` reads captured values without constructing parameter-name strings.
|
||||
- Build the `RenderContext` once per host block and refresh its modulation inputs for each render segment.
|
||||
- Render up to 64 samples at a time, splitting at MIDI event offsets and respecting prepared buffer capacity.
|
||||
- Size `mixBuffer` once in `prepare()`; use non-owning views for shorter segments.
|
||||
- Prebuild wavetables in the Engine constructor, before either GUI painting or audio rendering can read them.
|
||||
- Clamp the final output with the soft limiter.
|
||||
- Never call `setSize` on any buffer inside `processBlock`.
|
||||
|
||||
@@ -33,16 +33,18 @@ void processBlock (...)
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: size once, clear per block
|
||||
// GOOD: size once, then wrap a non-owning view per segment
|
||||
void prepare (double sr, int blockSize)
|
||||
{
|
||||
mixBuffer.setSize (2, blockSize, false, false, true);
|
||||
}
|
||||
void processBlock (...)
|
||||
{
|
||||
mixBuffer.clear();
|
||||
// No allocation; the view only borrows mixBuffer's channels.
|
||||
juce::AudioBuffer<float> block (mixBuffer.getArrayOfWritePointers(), 2, segmentLength);
|
||||
block.clear();
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -15,20 +15,20 @@ directory:
|
||||
- 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.
|
||||
- RaveController applies a non-destructive boost to render settings. It does not write APVTS parameters or keep a restoration snapshot.
|
||||
- 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.
|
||||
Hold `Engine::getControlLock()` while reading or editing the control-side matrix,
|
||||
macro assignments, or LFO shapes. The audio thread tries that lock once per block
|
||||
and copies into reserved audio-side storage. If the lock is busy, it keeps the
|
||||
previous settings and continues rendering. Never iterate the control-side vectors
|
||||
from the render path.
|
||||
|
||||
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.
|
||||
Master and FX-mix modulation use global sources directly. Per-voice sources for
|
||||
those destinations come from the newest active voice.
|
||||
|
||||
If `addConnection` or `addAssignment` returns false, handle the failed insert.
|
||||
|
||||
This file refines the repository-root AGENT.md for the related sources.
|
||||
This file refines the repository-root AGENTS.md for the related sources.
|
||||
@@ -9,6 +9,10 @@ and `../Params.cpp`.
|
||||
|
||||
- Use the `ids::` namespace for every APVTS parameter ID string.
|
||||
- Require one `ids::` entry per audio parameter, and one entry only.
|
||||
- Reach indexed parameters through the `paramIds::` arrays in `../Params.h`, which pair each
|
||||
ID with its slot: `paramIds::oscillators`, `paramIds::envAttack`, `paramIds::lfoRate`,
|
||||
`paramIds::fxType`, `paramIds::macros` and their siblings. Use a single `ids::` entry only
|
||||
when the parameter has no index.
|
||||
- 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.
|
||||
@@ -36,4 +40,4 @@ if (auto* p = apvts.getParameter (ids::f1Cutoff))
|
||||
return p->getValue();
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -13,7 +13,10 @@ This directory contains guidance; the implementation remains in
|
||||
- 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.
|
||||
- Hold the engine control lock across preset and state changes; preserve parameter IDs and normalized values for saved-project compatibility.
|
||||
- Factory presets reset sound parameters before applying overrides, while preserving UI scale.
|
||||
- Save one copy of each custom state section. LFO shape arrays are stored as JSON text inside XML.
|
||||
- RAVE is an ordinary parameter that enables a render-time boost; it does not rewrite other parameters.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
@@ -38,4 +41,4 @@ if (! state.isValid())
|
||||
return;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -3,8 +3,7 @@
|
||||
## 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.
|
||||
`Source/` directory.
|
||||
|
||||
- Wavetable.h, Wavetable.cpp
|
||||
- Oscillator.h, Oscillator.cpp
|
||||
@@ -26,10 +25,11 @@ not a claim that lazy wavetable allocation has already been fixed.
|
||||
- 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.
|
||||
- Keep oscillator phase when changing held-note parameters. LFO phase offsets are separate from the running phase.
|
||||
|
||||
## 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.
|
||||
- Wavetables are prebuilt during Engine construction. Audio and GUI code read them without triggering generation.
|
||||
- IF a parameter is read every block THEN snapshot it into a struct (`OscParams`, `RenderContext`) instead of reading the APVTS per sample.
|
||||
|
||||
## Examples
|
||||
@@ -40,8 +40,8 @@ const Wavetable& wt = library.getTable (wave);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: build once at prepare time, then only read const references
|
||||
library.prebuild(); // in prepareToPlay
|
||||
// GOOD: build once in the Engine constructor, then only read const references
|
||||
library.prebuild(); // in the Engine constructor
|
||||
const Wavetable& wt = library.getTable (wave); // lookup, no allocation
|
||||
```
|
||||
|
||||
@@ -55,4 +55,4 @@ const double step = 6.28318530717958647692 * freq / sr;
|
||||
const double step = juce::MathConstants<double>::twoPi * freq / sr;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
# SerumAlt UI and UX audit
|
||||
|
||||
Written from the working tree on branch `main`. The user report was "much of
|
||||
features arent correctly described and UI is bearly functional". That bundles
|
||||
two problems, and this audit keeps them apart:
|
||||
|
||||
1. Description. Controls lack names, units and plain descriptions.
|
||||
2. Function. Some controls do not reach the DSP they claim to drive.
|
||||
|
||||
The single largest finding explains the "barely functional" complaint on its
|
||||
own. No knob ever painted its name. `Knob` is a `juce::Slider` with
|
||||
`NoTextBox`, and JUCE does not draw a slider's component name, so every one of
|
||||
the roughly fifty knobs was an unlabelled arc whose only identification was a
|
||||
tooltip hover. The label strings passed to `makeKnob` were correct but
|
||||
invisible, and the runtime FX rename table in `updateVisuals` never reached a
|
||||
user's eyes.
|
||||
|
||||
---
|
||||
|
||||
## How this was produced
|
||||
|
||||
Reconnaissance read `Source/PluginEditor.{h,cpp}`, `Source/Params.{h,cpp}`,
|
||||
`Source/Resources.{h,cpp}`, `Source/PluginProcessor.cpp`, `Source/Engine.cpp`,
|
||||
the nine effects in `Source/EffectUnits/`, and every file in `Source/GUI/`.
|
||||
Four specialist passes then covered naming and descriptions, dead controls,
|
||||
interaction state, and layout plus accessibility. Findings were checked against
|
||||
`AUDIT_REPORT.md` (a codebase structure audit, no UI text coverage) and
|
||||
`JANITOR_TASKS.md` (fourteen tasks, all reported done; JT-01 removed the unused
|
||||
look-and-feel, JT-06 built the FX type list from `FXProcessor::fxTypeName`).
|
||||
Nothing in either file duplicates the findings below.
|
||||
|
||||
The two prior documents also correct the task brief in one respect.
|
||||
`Source/GUI/SerumLookAndFeel.h` no longer exists; JT-01 deleted it because it was
|
||||
never installed. The GUI has no custom look-and-feel, so "draw all controls
|
||||
through `SerumLookAndFeel`" resolves to "use `theme::` colours", which this work
|
||||
does.
|
||||
|
||||
Verification used `BUILD_DIR=/tmp/... ./build_harness.sh --run`.
|
||||
`UI_UX_AUDIT.md` is the deliverable that survives; the fixes listed as applied
|
||||
are already in the tree.
|
||||
|
||||
---
|
||||
|
||||
## Status vocabulary
|
||||
|
||||
- Functioning. The parameter is read by the DSP and changes what you hear.
|
||||
- Dead. Nothing reads it, or the thing that reads it throws the value away.
|
||||
- Conditional. Read and audible only in some modes or for some effect types.
|
||||
- Design decision. Fixing it needs a choice about behaviour or layout, so it was
|
||||
left alone and recorded for the user.
|
||||
|
||||
---
|
||||
|
||||
## Control inventory
|
||||
|
||||
Every user-visible control, by tab. "Readout" is what the knob shows while you
|
||||
drag it.
|
||||
|
||||
### Top bar
|
||||
|
||||
| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Preset combo | n/a | (preset name) | Select a factory preset | Loads one of the factory presets and replaces the whole patch | Preset | Loads a factory preset and replaces the current patch | Functioning |
|
||||
| RAVE toggle | `rave` | RAVE | RAVE one-click boost (unison, width, drive, OTT, reverb) | Render-time override: unison to 8, spread and detune to full, both filter drives to 60 %, Hyper intensity to 100 %, reverb mix +0.4 | Rave | Adds unison, width, drive, OTT and reverb to the current patch | Functioning, description was accurate, label case fixed |
|
||||
| Scale combo | `uiScale` | 75%..200% | Interface scale (75% - 200%) | Window scale, 75 % to 200 %. Index maps to a five-entry scale table | Scale | Interface scale, 75 % to 200 % | Functioning |
|
||||
| Master knob | `master` | (invisible) | Master output volume (0-100%) | Master output gain, 0 to 100 % of full scale | Master | Master output level, 0 % to 100 % | Functioning. Percent is not the engineering unit for a gain, see deferred D-8 |
|
||||
| Master readout | `master` | MASTER | none | Painted numeric readout of the same parameter | Master | none needed | Functioning |
|
||||
| Tabs | n/a | OSC, FILTER, MOD, FX, MACRO | Oscillators (Tab cycles sections) etc. | Switches the visible section. Plain Tab also cycles sections | Osc, Filter, Mod, FX, Macro | Oscillators. Tab cycles sections. | Functioning, label case fixed |
|
||||
|
||||
### Oscillator tab
|
||||
|
||||
Oscillator A and Oscillator B have identical control sets. Rows 1 and 2 are
|
||||
listed once.
|
||||
|
||||
| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| On | `oscAOn` / `oscBOn` | On | Enable oscillator A | Gates the oscillator in `Oscillator::render` | On | Turns oscillator A on or off | Functioning |
|
||||
| Wavetable | `oscAWave` / `oscBWave` | (combo, no visible label) | Oscillator A wavetable | Selects one of 10 factory wavetables | Table | Selects the wavetable, 10 factory tables | Functioning, combo shows its own item text |
|
||||
| Warp mode | `oscAWarp` / `oscBWarp` | (combo) | Oscillator A warp mode | Selects one of 8 warp modes | Warp | Selects the wavetable warp mode | Functioning |
|
||||
| Wavetable position | `oscAWtPos` / `oscBWtPos` | Wavetable Position | Wavetable position (0-100%) | Frame position in the table, 0 to 100 % | Wt position | Wavetable frame position, 0 % to 100 % | Functioning |
|
||||
| Warp amount | `oscAWarpAmt` / `oscBWarpAmt` | Warp Amount | Warp amount (0-100%) | Warp depth, 0 to 100 % | Warp amount | Warp depth, 0 % to 100 % | Functioning |
|
||||
| Coarse | `oscACoarse` / `oscBCoarse` | Coarse | Pitch coarse (-24 to +24 semitones) | Coarse pitch, -24 to +24 semitones | Coarse | Coarse pitch, -24 to +24 semitones | Functioning |
|
||||
| Fine | `oscAFine` / `oscBFine` | Fine | Pitch fine (-100 to +100 cents) | Fine pitch, -100 to +100 cents | Fine | Fine pitch, -100 to +100 cents | Functioning |
|
||||
| Level | `oscALevel` / `oscBLevel` | Level | Oscillator A level (0-100%) | Linear amplitude gain, 0 to 1 | Level | Oscillator A level, 0 % to 100 % | Functioning |
|
||||
| Pan | `oscAPan` / `oscBPan` | Pan | Oscillator A pan (L100-R100) | Stereo position, hard left to hard right | Pan | Oscillator A pan, hard left to hard right | Functioning |
|
||||
| Unison | `oscAUnison` / `oscBUnison` | Unison | Unison voices (1-16) | Voice count, 1 to 16. Now detented so the dial rests on whole voices | Unison | Unison voices, 1 to 16 | Functioning |
|
||||
| Detune | `oscADetune` / `oscBDetune` | Detune | Unison detune (0-100%) | Per-voice detune spread, -50 to +50 cents | Detune | Unison detune spread, -50 to +50 cents | Functioning. Readout used to say "50 %" for 25 cents |
|
||||
| Spread | `oscASpread` / `oscBSpread` | Spread | Unison spread (0-100%) | Stereo spread of the unison voices, 0 to 100 % | Spread | Unison stereo spread, 0 % to 100 % | Functioning |
|
||||
| Phase | `oscAPhase` / `oscBPhase` | Phase | Phase (0-100%) | Start phase at note on, 0 to 360 degrees | Phase | Start phase at note on, 0 to 360 degrees | Functioning. Readout used to say a percentage |
|
||||
| Random phase | `oscARandPh` / `oscBRandPh` | Random Phase | Random phase (0-100%) | Random start phase per voice, up to 360 degrees | Rand phase | Random start phase per voice, 0 to 360 degrees | Functioning. Readout used to say a percentage |
|
||||
| Sub On | `subOn` | On | Enable sub oscillator | Gates the sub oscillator | On | Turns the sub oscillator on or off | Functioning |
|
||||
| Sub shape | `subShape` | (combo) | Sub oscillator waveform | Sine or triangle | Shape | Sub oscillator waveform, sine or triangle | Functioning |
|
||||
| Sub octave | `subOct` | (combo) | Sub oscillator octave | -2, -1 or 0 octaves, engine reads `value - 2` | Octave | Sub oscillator octave, 2 below to unison | Functioning |
|
||||
| Sub level | `subLevel` | Level | Sub oscillator level (0-100%) | Linear amplitude gain | Level | Sub oscillator level, 0 % to 100 % | Functioning |
|
||||
| Noise On | `noiseOn` | On | Enable noise | Gates the noise source | On | Turns noise on or off | Functioning |
|
||||
| Noise type | `noiseType` | (combo) | Noise type | White or pink | Type | Noise colour, white or pink | Functioning |
|
||||
| Noise level | `noiseLevel` | Level | Noise level (0-100%) | Linear amplitude gain | Level | Noise level, 0 % to 100 % | Functioning |
|
||||
|
||||
### Filter tab
|
||||
|
||||
| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| On | `f1On` / `f2On` | On | Enable filter 1 | Gates the filter in `FilterBank` | On | Turns filter 1 on or off | Functioning |
|
||||
| Type | `f1Type` / `f2Type` | (combo) | Filter 1 type | One of 7 models: Ladder LP, Ladder HP, Ladder BP, Diode, Comb, Formant, Screamer | Type | Selects the filter 1 model | Functioning |
|
||||
| Slope | `f1Slope` / `f2Slope` | (combo) 6 dB, 12 dB, 24 dB | Filter 1 slope | One, two or four one-pole stages on the ladder and diode models. Comb, Formant and Screamer ignore it | Slope | Slope, 6, 12 or 24 dB per octave on the ladder and diode models | Conditional, see D-2 |
|
||||
| Cutoff | `f1Cutoff` / `f2Cutoff` | Cutoff | Filter 1 cutoff (20 Hz - 20 kHz) | Cutoff, 20 Hz to 20 kHz before key tracking, clamped to 18 kHz after it | Cutoff | Filter 1 cutoff, 20 Hz to 18 kHz | Functioning. Tooltip overstated the reachable top |
|
||||
| Resonance | `f1Res` / `f2Res` | Resonance | Filter 1 resonance (0-100%) | Resonance feedback, 0 to 98 % | Resonance | Filter 1 resonance, 0 % to 100 % | Functioning |
|
||||
| Drive | `f1Drive` / `f2Drive` | Drive | Filter 1 drive (0-100%) | Per-sample drive. Damps the comb model. Formant ignores it | Drive | Filter 1 drive, 0 % to 100 %. Damps the comb model. | Conditional, see D-3 |
|
||||
| Keytrack | `f1Key` / `f2Key` | Keytrack | Filter 1 keytrack (0-100%) | Cutoff follows note pitch, 100 % is full tracking | Keytrack | Filter 1 key tracking, 0 % to 100 % | Functioning |
|
||||
| Routing | `fRoute` | (combo) | Filter routing (serial / parallel / split) | Serial, parallel or split | Routing | Filter routing, serial, parallel or split | Functioning |
|
||||
| Mix | `fMix` | Mix | Filter mix (0-100%) | Crossfades filter 1 into filter 2. Read only in parallel routing | Mix | Blends filter 2 over filter 1 in parallel routing, 0 % to 100 % | Conditional, see D-1 |
|
||||
| Output | `fOut` | Output | Filter output (0-100%) | Output gain, engine multiplies by 1.5, so the 0.667 default is unity | Output | Filter output gain, 0 % to 150 % | Functioning. The old readout understated the range: 67 % is unity |
|
||||
|
||||
### Modulation tab
|
||||
|
||||
| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Attack | `env1A`..`env4A` | Attack | Attack time (0.5 ms - 12 s) | Attack time, 0.5 ms to 12 s, cubic taper | Attack | Attack time, 0.5 ms to 12 s | Functioning |
|
||||
| Decay | `env1D`..`env4D` | Decay | Decay time (0.5 ms - 12 s) | Decay time, 0.5 ms to 12 s | Decay | Decay time, 0.5 ms to 12 s | Functioning |
|
||||
| Sustain | `env1S`..`env4S` | Sustain | Sustain level (0-100%) | Sustain level, 0 to 100 % | Sustain | Sustain level, 0 % to 100 % | Functioning |
|
||||
| Release | `env1R`..`env4R` | Release | Release time (0.5 ms - 12 s) | Release time, 0.5 ms to 12 s | Release | Release time, 0.5 ms to 12 s | Functioning |
|
||||
| Curve | `env1Curve`..`env4Curve` | Curve | Envelope curve (0-100%) | Shape exponent, mapped to 0.3 to 3.0 | Curve | Curve shape. Low is a fast attack and slow decay, high is the reverse. | Functioning. The readout is still a percentage of an exponent, see D-9 |
|
||||
| Envelope graph | n/a | n/a | none | ADSR curve preview, drawn from the same shape functions as the DSP | n/a | Add a tooltip naming the envelope | Functioning |
|
||||
| Shape | `lfo1Shape`..`lfo4Shape` | (combo) | LFO shape | Sine, Triangle, Saw, Square, S&H, Step, Freehand | Shape | Selects the LFO waveform | Functioning |
|
||||
| Sync | `lfo1Sync`..`lfo4Sync` | Sync | Tempo-sync LFO | Locks the rate to host tempo and uses Beat instead of Rate | Sync | Locks the LFO to host tempo and uses the Beat knob | Functioning |
|
||||
| Rate | `lfo1Rate`..`lfo4Rate` | Rate | LFO rate (0.02 Hz - 30 Hz) | Rate, 0.02 Hz to 30 Hz, log taper | Rate | LFO rate, 0.02 Hz to 30 Hz | Functioning. Readout used to say "50 %" for about 0.77 Hz |
|
||||
| Beat | `lfo1Beat`..`lfo4Beat` | Beat | Beat division (1/32 - 4 bars) | Period of one cycle, one of 16 divisions from 1/32 beat to 16 beats | Beat | Cycle length, 1/32 beat to 16 beats | Functioning. Readout used to say a percentage, and the old tooltip was wrong at the fast end |
|
||||
| Phase | `lfo1Phase`..`lfo4Phase` | Phase | Phase (0-100%) | Start phase offset, 0 to 360 degrees | Phase | Start phase, 0 to 360 degrees | Functioning. Readout used to say a percentage |
|
||||
| Fade | `lfo1Fade`..`lfo4Fade` | Fade | Fade in (0-100%) | Fade in time, 0 to 8 seconds | Fade | Fade in time, 0 s to 8 s | Functioning. Readout and tooltip both said percent for seconds |
|
||||
| Delay | `lfo1Delay`..`lfo4Delay` | Delay | Start delay (0-100%) | Start delay, 0 to 4 seconds | Delay | Start delay, 0 s to 4 s | Functioning. Readout and tooltip both said percent for seconds |
|
||||
| LFO graph | n/a | n/a | none | Shape preview. Step and Freehand are drag-editable | n/a | Describe the drag gesture | Functioning, discoverability is D-11 |
|
||||
| Source | n/a | (combo) | Modulation source | One of 17 sources | Source | Selects the modulation source | Functioning |
|
||||
| Target | n/a | (combo) | Modulation destination | One of 35 destinations | Target | Selects the modulation destination | Functioning |
|
||||
| Depth | n/a | (invisible) | Modulation depth (-1 to +1) | Amount applied to the next Add, -100 % to +100 % of the target range. Negative inverts | Depth | Modulation amount for the next Add, -100 % to +100 % of the target range | Functioning. Readout and tooltip rewritten |
|
||||
| Bipolar | n/a | Bipolar | Bipolar modulation depth | Recentres a one-sided source for the next Add | Bipolar | Recentres a one-sided source so the modulation swings both ways, for the next Add | Functioning |
|
||||
| Add | n/a | Add | Add modulation connection | Appends a connection, capped at 32 | Add | Adds the selected source and destination to the matrix | Functioning |
|
||||
| Remove last | n/a | Remove Last | Remove last modulation connection | Removes the newest row only | Remove last | Removes the newest matrix row, older rows stay | Functioning |
|
||||
| Clear | n/a | Clear | Clear all modulation connections | Removes every connection | Clear all | Removes every modulation connection | Functioning |
|
||||
| Connection list | n/a | (read-only text) | none | Source, target, depth and polarity per row | n/a | Every modulation connection, newest last | Functioning |
|
||||
|
||||
### Effects tab
|
||||
|
||||
Each of the eight slots has the same shape: a type combo, up and down arrows,
|
||||
and five knobs. The knob names, tooltips and readout units follow the selected
|
||||
effect, and that table was rebuilt from the DSP each effect actually runs.
|
||||
|
||||
| Control | Parameter | Current label | What it does and its unit | Readout unit | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| Type | `fx1Type`..`fx8Type` | (combo) | Off, Hyper, Chorus, Flanger, Phaser, Distortion, EQ, Compressor, Delay, Reverb | n/a | Functioning |
|
||||
| Up / Down | n/a | arrow glyphs | Swaps this slot's six parameters with the neighbour | n/a | Functioning. The end arrows are now disabled instead of doing nothing |
|
||||
| Mix | `fx1Mix`..`fx8Mix` | Mix | Dry and wet mix, 0 to 100 % | percent | Functioning |
|
||||
| Off P1..P4 | `fx*P1`..`fx*P4` | Unused | The rack skips an Off slot, so all four do nothing | n/a | Dead by design, now labelled and greyed |
|
||||
| Hyper | | Intensity, Low Amount, High Amount, Output | 0-100 %, 40-100 %, 40-100 %, 0.5x-1.5x | percent, percent, percent, x | Functioning |
|
||||
| Chorus | | Rate, Depth, Width, Unused | 0.1-5 Hz, 0.5-6 ms, 0-100 %, nothing | Hz, ms, percent | Rate and Depth used to read as percent; P4 is genuinely dead |
|
||||
| Flanger | | Rate, Depth, Feedback, Unused | 0.05-2.05 Hz, 0.3-5 ms, 0-90 %, nothing | Hz, ms, percent | Rate and Depth used to read as percent; P4 is genuinely dead |
|
||||
| Phaser | | Rate, Depth, Feedback, Stages | 0.05-2.05 Hz, 30-90 %, 0-70 %, 2-6 | Hz, percent, percent, count | Rate used to read as percent; Stages used to read "50 %" |
|
||||
| Distortion | | Drive, Shape, Tone, Output | 1x-25x, 0-100 %, 800 Hz-16 kHz, 0.4x-1.6x | x, percent, Hz, x | All four used to read as percent |
|
||||
| EQ | | Low Gain, Mid Gain, High Gain, Mid Freq | +/-15 dB, +/-12 dB, +/-15 dB, 200 Hz-5 kHz | dB, dB, dB, Hz | All four used to read as percent |
|
||||
| Compressor | | Threshold, Ratio, Attack, Release | -40-0 dB, 2:1-20:1, 2-100 ms, 30-500 ms | dB, ratio, ms, ms | All four used to read as percent, and two of them share a name with a different unit on the Modulation tab |
|
||||
| Delay | | Time, Feedback, Damping, Ping-Pong | 1-2000 ms, 0-92 %, 0-95 %, on or off | ms, percent, percent, on/off | Time used to read as percent; Ping-Pong used to read "50 %" |
|
||||
| Reverb | | Size, Damping, Width, Predelay | 20-99 %, 0-100 %, 0-100 %, 0-150 ms | percent, percent, percent, ms | Predelay used to read as percent |
|
||||
|
||||
The old tooltips were worse than the readouts. They were only rewritten when a
|
||||
knob's name changed, so two effects sharing a name (Rate, Damping, Feedback,
|
||||
Output) left the previous effect's tooltip on screen, and every one of them
|
||||
claimed "normalised 0-100%".
|
||||
|
||||
### Macro tab
|
||||
|
||||
| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Macro knobs | `macro1`..`macro4` | Energy, Width, Drive, Atmosphere | (name) macro (0-100%) | Macro amount, 0 to 100 %, drives its assigned destinations | Energy / Width / Drive / Atmosphere | Energy macro, 0 % to 100 %, drives its assigned destinations | Functioning. Names come from `MacroControls::macroName`; the macro panels have no title of their own, so the knob label is the only identifier |
|
||||
| Macro | n/a | (combo) | Macro to assign | Selects which of the four macros to edit | Macro | Selects the macro to edit | Functioning |
|
||||
| Target | n/a | (combo) | Destination parameter | One of 35 destinations | Target | Selects the destination parameter | Functioning |
|
||||
| Depth | n/a | (invisible) | Macro assignment depth (0 to 1) | Amount applied to the next Assign, 0 to 100 % of the target range | Depth | Macro amount for the next Assign, 0 % to 100 % of the target range | Functioning. Started at zero, so a default Assign was silent; now starts at half |
|
||||
| Assign | n/a | Assign | Assign destination to the selected macro | Appends an assignment, capped at 8 per macro | Assign | Adds this destination to the selected macro | Functioning |
|
||||
| Clear all | n/a | Clear All | Clear all macro assignments | Empties all four macros | Clear all | Removes every macro assignment from all four macros | Functioning |
|
||||
| Assignment list | n/a | (read-only text) | none | Target and depth per macro | n/a | Every macro assignment, grouped by macro | Functioning |
|
||||
|
||||
---
|
||||
|
||||
## Applied mechanical fixes
|
||||
|
||||
Every item below was applied, compiles, and passes the harness.
|
||||
|
||||
### Description fixes
|
||||
|
||||
- A1. Knobs now paint their name. New `Source/GUI/KnobLookAndFeel.{h,cpp}` draws
|
||||
the dial from `theme::` colours and reserves a 16 px strip at the foot of the
|
||||
knob for the label, which `Knob::paint` fills. This is the change that makes
|
||||
every other label in the editor visible for the first time.
|
||||
- A2. All eight FX parameter knobs are named, tooltipped and unit-formatted per
|
||||
effect type from one table (`kFxParamDisplay` in `PluginEditor.cpp`), replacing
|
||||
the two tables that disagreed with each other.
|
||||
- A3. Unit formatters added: LFO rate in Hz, LFO beat as a cycle length, LFO fade
|
||||
and delay in seconds, oscillator detune and both phases in cents and degrees,
|
||||
filter output as a percentage of its real 0-150 % range, modulation depth as
|
||||
-100 % to +100 %, macro depth as 0-100 %, compressor threshold and ratio,
|
||||
filter and EQ gains in dB, chorus and flanger rate in Hz and depth in ms,
|
||||
distortion drive and output as multiples, phaser stage count, ping-pong as on
|
||||
or off, reverb predelay in ms.
|
||||
- A4. Tooltips rewritten for every control that had a vague one: all eight
|
||||
effect types, both filters (cutoff now says 18 kHz, which is the real ceiling),
|
||||
all four envelopes, all four LFOs, the modulation matrix buttons, the macro
|
||||
buttons, the filters' drive and slope, both lists, the preset and scale combos,
|
||||
and the RAVE toggle.
|
||||
- A5. Labels corrected where the name was wrong or abbreviated: tab labels are
|
||||
now Osc/Filter/Mod/FX/Macro, "Random Phase" is "Rand phase" to fit its 76 px
|
||||
knob, and the RAVE toggle reads "Rave".
|
||||
|
||||
### Function fixes that were one line each
|
||||
|
||||
- B1. Double click restored a hardcoded 0.5 for every knob. Forty-nine knob
|
||||
parameters have a different default, so the gesture silently wrote the wrong
|
||||
value. `makeKnob` now reads the parameter's own default through
|
||||
`AudioProcessorParameter::convertFrom0to1`. The modulation depth knob double
|
||||
clicks to its neutral zero, and the macro depth knob to its new half.
|
||||
- B2. The macro depth knob started at zero, so an Assign made without touching
|
||||
it created an inaudible assignment. It now starts at half.
|
||||
- B3. Unison had no detent, so the dial rested between voices. Both unison knobs
|
||||
now step in whole voices.
|
||||
- B4. The first FX slot's up arrow and the last slot's down arrow were enabled
|
||||
but did nothing. They are now disabled.
|
||||
- B5. An FX parameter the selected effect ignores stayed live and editable. Those
|
||||
knobs are now disabled and labelled Unused.
|
||||
- B6. The knob value popup was never clamped to the window, so it was cut off for
|
||||
knobs near an edge. It is now clamped on both axes.
|
||||
- B7. The popup is parented to the editor while the interface scale is applied to
|
||||
a child, so it ignored the 75-200 % scale. It now measures the scale from its
|
||||
own transform and sizes itself to match.
|
||||
- B8. Switching section mid-drag could strand the popup on screen, because it is
|
||||
not a child of the hidden view. `Knob::visibilityChanged` now takes it down.
|
||||
|
||||
### Interaction and accessibility fixes
|
||||
|
||||
- C1. Toggle buttons repainted on mouse activity but never read the hover state,
|
||||
so every On, Sync and Bipolar LED looked identical under the cursor. `paint`
|
||||
now brightens the LED and the name on hover and dims both when disabled.
|
||||
- C2. `ToggleButton::paint` ignored `isEnabled()`. It no longer does.
|
||||
- C3. Knobs now take keyboard focus. `juce::Slider` disables focus in its own
|
||||
constructor, which left every knob unreachable without a mouse and cut off the
|
||||
accessible value interface.
|
||||
|
||||
### Layout constant fixes
|
||||
|
||||
`Source/GUI/AGENTS.md` requires every position to be derived from the `layout`
|
||||
namespace. Each tab declared its own private `colW`, `colGap` and panel heights,
|
||||
and the tab-bar height was the bare literal `60` in six places. Those metrics now
|
||||
live in `layout`, and the per-tab locals alias them, so the value has one home:
|
||||
|
||||
- L1. Named the tab-bar height as `layout::tabBarHeight` and used it in all five
|
||||
tab views plus the matrix and macro panel height calculations.
|
||||
- L2. Hoisted the panel grids: `twoColW`, `threeColW`, `fourColW`, `fourColGap`,
|
||||
and the panel heights `oscPanelH`, `smallPanelH`, `filterPanelH`, `envPanelH`,
|
||||
`lfoPanelH`, `macroPanelH`.
|
||||
- L3. Named `depthKnobHeight` for the matrix and macro depth knob, which was the
|
||||
literal `60` in two `setBounds` calls and again inside both list tops.
|
||||
- L4. Named `toggleWide` for the Bipolar toggle width, which was the literal `60`
|
||||
in its `setBounds` and again in the `addX` offset that had to stay in step
|
||||
with it.
|
||||
- L5. Named `headerCentreY` for the header row centre line, which was
|
||||
`titleHeight + 30` in two places with the `30` unexplained.
|
||||
|
||||
The first attempt at L5 derived the centre from `headerHeight`, which is the
|
||||
36 px toggle, and produced 40 instead of 52. That would have shifted the whole
|
||||
matrix and macro header row by 12 px. It is derived from `depthKnobHeight` now,
|
||||
and a separate check compiles the namespace and asserts all fourteen new
|
||||
constants equal the literals they replaced.
|
||||
|
||||
Two layout reports were left in place rather than acted on. The top bar still
|
||||
positions its controls with literals (`presetCombo` at 200, 16, 220, 24;
|
||||
`masterKnob` at 906, 0, 64, 60; the tab pitch of 92, and so on), because naming
|
||||
those means deciding what the top bar's own grid is, which is a design choice
|
||||
rather than a rename. The `layout::labelHeight` constant was deleted: it was
|
||||
declared and never read.
|
||||
|
||||
---
|
||||
|
||||
## Deferred items needing a design decision
|
||||
|
||||
None of these were applied. Each changes behaviour, layout or sound, and each
|
||||
needs the user's call.
|
||||
|
||||
- D-1. Filter Mix does nothing in serial and split routing. `FilterBank` reads
|
||||
`p.mix` only in the parallel branch. Either define a crossfade for the other
|
||||
two routes or disable the knob in them. Real DSP work.
|
||||
- D-2. Filter Slope does nothing for Comb, Formant and Screamer, and on
|
||||
Ladder BP the 6 dB and 12 dB settings produce the same two-stage filter.
|
||||
Making the label honest needs DSP work; the tooltip now at least names the
|
||||
models it affects.
|
||||
- D-3. Filter Drive does nothing for Formant. `Filter::formant` is called
|
||||
without the drive argument.
|
||||
- D-4. The Modulation and Macro depth knobs and the Bipolar toggle only affect
|
||||
the next Add or Assign. They look like live controls for an existing
|
||||
connection. Relabel them as defaults, or make them edit the selected row.
|
||||
- D-5. The first FX slot's down arrow moves an effect earlier in the chain while
|
||||
pointing down. In the two-column grid the reading order wraps right to left
|
||||
between rows, so slot 2 sits to the right of slot 3. Number the slots, or
|
||||
relabel the arrows for a two-column grid.
|
||||
- D-6. The Filter tab leaves about 370 px of empty space below three 300 px
|
||||
panels. Nothing breaks, but the tab looks unfinished.
|
||||
- D-7. The OSC tab puts Sub and Noise in a second row beneath both oscillators.
|
||||
The reading order is fine; the geometry suggests Sub belongs to Osc A and
|
||||
Noise to Osc B without saying so.
|
||||
- D-8. Master and the oscillator, sub and noise Level knobs show a percentage
|
||||
for a linear gain. dB is the engineering unit. Changing it changes what every
|
||||
readout says, so it was left as percent.
|
||||
- D-9. Envelope Curve shows a percentage of a shape exponent that runs 0.3 to
|
||||
3.0. "Amount" or a named curve would be better than a percentage.
|
||||
- D-10. No control marks itself as changed from its default. Nothing reads
|
||||
`getDefaultValue()`.
|
||||
- D-11. Step and Freehand LFO shapes are drag-editable, but nothing says so: no
|
||||
tooltip on an editable graph, no cursor change, no hover state, no undo, and a
|
||||
preset load silently discards the drawn shape. `LFODisplay` is not a
|
||||
`SettableTooltipClient` today.
|
||||
- D-12. Plain Tab switches section, which means JUCE's focus traversal never
|
||||
runs and a keyboard-only user cannot reach any control except a combo box or a
|
||||
text editor. This is a convention choice: keep Tab for sections and move it to
|
||||
Ctrl+Tab, or keep Tab for controls and use function keys for sections.
|
||||
- D-13. `ToggleButton` derives from `juce::Component`, so it has no accessible
|
||||
role, name or state, and cannot be activated from the keyboard. Deriving from
|
||||
`juce::Button` would fix all of that at once but changes the class contract.
|
||||
- D-14. The waveform, filter, envelope and LFO graphs have no text or numeric
|
||||
equivalent, so a screen reader announces nothing for any of them. Each needs a
|
||||
description string.
|
||||
- D-15. Knobs have no focus outline. `hasKeyboardFocus` is read nowhere.
|
||||
- D-16. No group is marked, so a group that the selected shape ignores keeps its
|
||||
old values.
|
||||
|
||||
---
|
||||
|
||||
## Verified as functioning, with evidence
|
||||
|
||||
The following were suspected dead and are not. Recorded so the check is not
|
||||
repeated.
|
||||
|
||||
- Modulation depth and polarity reach `SynthVoice` and `Engine`, and the bipolar
|
||||
flag changes the modulation math.
|
||||
- Macro depth reaches both the global and per-voice modulation sums.
|
||||
- Remove Last removes the newest matrix row, matching its label, with no hidden
|
||||
ordering rule.
|
||||
- The master readout and the master knob drive the same parameter the output
|
||||
stage uses.
|
||||
- The filter type and LFO shape combo indices agree between the combo, the
|
||||
stored value, the DSP and the display, for all 7 and all 7 values.
|
||||
- The UI scale combo's five items map exactly onto the five-entry scale table.
|
||||
- All six On toggles gate their DSP. All eight warps, all four sub and noise
|
||||
controls, all four envelopes, all four LFOs, and the matrix and macro editors
|
||||
are live.
|
||||
- RAVE's tooltip matched its implementation exactly.
|
||||
- No knob can write outside its parameter range, and the Unison readout and the
|
||||
DSP round identically.
|
||||
|
||||
---
|
||||
|
||||
## Janitor cleanups made beyond UI text
|
||||
|
||||
Kept separate from the fixes above.
|
||||
|
||||
- J-1. Deleted the now-dead formatter `fmtDepth`, which the modulation and macro
|
||||
depth knobs were the only users of.
|
||||
- J-2. Deleted `layout::labelHeight`, which was declared and never read. Toggle
|
||||
and readout label sizes are hardcoded, so the constant described nothing.
|
||||
- J-3. Replaced the duplicated per-effect label table with the single
|
||||
`kFxParamDisplay` table, which removes the latent stale-tooltip bug rather than
|
||||
only fixing its symptoms.
|
||||
- J-4. `Source/GUI/Slider.h` and `Slider.cpp` define a horizontal slider that no
|
||||
code constructs. Reported only: deleting it means editing `CMakeLists.txt` and
|
||||
the GUI module's owned-file list, which is wider than this change. Recommend
|
||||
it as a follow-up.
|
||||
- J-5. `MacroControls::removeAssignment` is defined and never called. The Macro
|
||||
tab has no control that would call it, so a single macro assignment cannot be
|
||||
deleted. Reported only: adding the control is a new feature.
|
||||
|
||||
---
|
||||
|
||||
## What could not be verified
|
||||
|
||||
There is no headless way to check a visual change in this repository. The
|
||||
harness drives `SerumAltAudioProcessor` and never constructs the editor, so it
|
||||
proves the code compiles and the audio path still renders every preset, and it
|
||||
proves nothing about the pixels. The knob label, the toggle hover state, the
|
||||
disabled FX arrows and the popup clamping were reasoned from the JUCE 7.0.12
|
||||
sources in `third_party/JUCE` and checked against them line by line, but they
|
||||
have not been seen on screen. Do not treat a passing harness run as visual
|
||||
proof.
|
||||
|
||||
One consequence worth stating plainly: the new knob look-and-feel replaces
|
||||
`LookAndFeel_V4::drawRotarySlider` for knobs only. Every other control still uses
|
||||
the shared default look-and-feel, exactly as before.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- `BUILD_DIR=/tmp/sa-verify ./build_harness.sh --run` reaches `100% tests passed`
|
||||
with zero first-party warnings.
|
||||
- The parameter surface is unchanged, checked at runtime rather than by reading
|
||||
the source. A temporary build printed every parameter of the live processor as
|
||||
`id | name | default`. It reported 155 parameters (the 96 declared in
|
||||
`createParameterLayout` plus the 59 generated entries), and the sorted list had
|
||||
fingerprint `62b4556078a261a5b1aaa8ba34081f44` both before and after the UI
|
||||
edits. No ID, name, range or default moved. The only parameter interaction the
|
||||
fix added is a read of `getDefaultValue()` and `convertFrom0to1()`.
|
||||
- Nothing under `Source/Params.h`, `Source/Params.cpp` or
|
||||
`createParameterLayout` in `Source/PluginProcessor.cpp` was edited.
|
||||
- `third_party/JUCE` is untouched. The untracked `third_party/JUCE/DemoRunner`
|
||||
and `third_party/JUCE/Projucer` files are upstream binaries dated 2024-04-15,
|
||||
not build products of this work.
|
||||
- No checked-in `build_*` directory was written to. Every build used a directory
|
||||
under `/tmp`.
|
||||
- The pre-existing uncommitted work in the tree was preserved.
|
||||
+43
-6
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_windows.sh — cross-compile SerumAlt VST3 for Windows using MinGW-w64
|
||||
# build_windows.sh, cross-compile SerumAlt VST3 for Windows using MinGW-w64
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
@@ -23,7 +23,8 @@ fi
|
||||
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
|
||||
x86_64-w64-mingw32-ranlib x86_64-w64-mingw32-strip \
|
||||
x86_64-w64-mingw32-objdump; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: $tool not found. See README.md prerequisites." >&2
|
||||
exit 1
|
||||
@@ -51,15 +52,51 @@ cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -G Ninja \
|
||||
echo "Building Windows VST3..."
|
||||
cmake --build "$BUILD_DIR" --config "$BUILD_TYPE" --target SerumAlt_VST3
|
||||
|
||||
# 5. Inject the VST3 moduleinfo.json.
|
||||
# 5. Verify the linked module can load on a stock Windows host.
|
||||
# The MinGW C++/threading runtimes are not part of this bundle and do not exist
|
||||
# on a stock Windows machine, so a module that still imports one of them fails
|
||||
# to load and the host then omits the plugin from its scan list. The static
|
||||
# -runtime link options in cmake/Plugin.cmake are meant to prevent that; check
|
||||
# it here so a stale build tree or a dropped flag fails the build loudly
|
||||
# instead of shipping a bundle that appears nowhere in the host.
|
||||
VST3_BUNDLE="$BUILD_DIR/${PLUGIN_NAME}_artefacts/$BUILD_TYPE/VST3/$PLUGIN_NAME.vst3"
|
||||
VST3_BINARY="$VST3_BUNDLE/Contents/x86_64-win/$PLUGIN_NAME.vst3"
|
||||
if [[ ! -f "$VST3_BINARY" ]]; then
|
||||
echo "ERROR: cross-build produced no module at $VST3_BINARY" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! x86_64-w64-mingw32-objdump -f "$VST3_BINARY" | grep -q 'i386:x86-64'; then
|
||||
echo "ERROR: $VST3_BINARY is not an x86-64 module" >&2
|
||||
exit 1
|
||||
fi
|
||||
VST3_DUMP="$(x86_64-w64-mingw32-objdump -p "$VST3_BINARY")"
|
||||
for runtime in libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll; do
|
||||
if grep -qF "DLL Name: $runtime" <<<"$VST3_DUMP"; then
|
||||
echo "ERROR: $VST3_BINARY still imports $runtime." >&2
|
||||
echo " A stock Windows host cannot resolve it and will not list the" >&2
|
||||
echo " plugin. Restore the static-runtime link options in" >&2
|
||||
echo " cmake/Plugin.cmake and rebuild with a fresh BUILD_DIR." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# JUCE implements only these three module entry points on Windows; bundleEntry and
|
||||
# bundleExit are macOS-only, and the VST3 SDK's Windows loader requires only
|
||||
# GetPluginFactory, treating InitDll/ExitDll as optional.
|
||||
for entry in GetPluginFactory InitDll ExitDll; do
|
||||
if ! grep -qE "[[:space:]]${entry}\$" <<<"$VST3_DUMP"; then
|
||||
echo "ERROR: $VST3_BINARY does not export $entry" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 6. 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
|
||||
# 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'
|
||||
@@ -122,7 +159,7 @@ cat > "$VST3_BUNDLE/Contents/Resources/moduleinfo.json" <<'EOF'
|
||||
}
|
||||
EOF
|
||||
|
||||
# 6. Copy the resulting .vst3 bundle to SerumAlt_Windows/ without deleting files.
|
||||
# 7. 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"
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"graft": {
|
||||
"type": "local",
|
||||
|
||||
Reference in new issue
Block a user