Compare commits

..
4 Commits
Author SHA1 Message Date
biggy f69316a454 docs: record the round 2 janitor sweep
What changed in each phase, the verification evidence and its commands, the
findings that were reported rather than fixed, and the limits of what could be
checked on this machine. The graft graph was rebuilt after the source moves.
2026-10-06 09:18:37 +02:00
biggy 4b0db50271 refactor(source): move every implementation into its module directory
- 36 files move into the module whose AGENTS.md already claimed them:
  Engine/, Synth/, Modulation/, Params/, Plugin/, GUI/, EffectUnits/, Resources/
- Source/engine, modulation, params, plugin and synth become Engine,
  Modulation, Params, Plugin and Synth, matching the directories that already
  hold code
- ExactCompare.h stays at the Source/ root as the one shared header
- SERUMALT_INCLUDE_DIRS adds one include directory per module, so the 31
  parent-relative includes become plain names with no path prefix
- the module AGENTS.md files list owned files instead of describing a move
  that had not happened, and README.md and AGENTS.md describe the real tree

Verified: the harness passes CTest, the Linux VST3 and Standalone link, the
Windows cross-build passes its module checks, each configure exposes only its
own targets, no first-party warning line appears, and the generated Windows
manifest is unchanged.
2026-10-06 09:17:49 +02:00
biggy facd5c5b5e chore: move build output under build/ and scripts into scripts/
Layout:
- scripts/ holds the four build scripts plus the new clean.sh
- cmake/ holds mingw64-toolchain.cmake
- the scripts default to build/linux, build/macos, build/windows, build/harness
- docs/ holds the audit documents, with the round 1 task list archived

Cleanup:
- scripts/clean.sh removes generated output, with --dry-run, --staging,
  --legacy and --all, and refuses tracked, protected or outside paths
- the Windows VST3 manifest now comes from cmake/moduleinfo.json.in, so its
  version, vendor and class IDs follow cmake/Plugin.cmake instead of a
  hand-written heredoc that could drift
- deleted the unused Source/GUI/Slider, which no code constructed
- fixed the hidden double processBlock overload: three warning lines to zero
- unslopped the build scripts, the two .cjs helpers and the CMake comments
- .gitignore covers the upstream JUCE Projucer and DemoRunner binaries

Verified: five scripts pass bash -n; the harness passes CTest with zero
warning lines; the Linux VST3 and Standalone link; the Windows cross-build
passes its x86-64, runtime-import and entry-point checks; each configure
exposes only its own targets; and a full build leaves Source/ and
third_party/JUCE unchanged. The generated manifest is identical to the
previous hand-written one.
2026-10-06 09:06:53 +02:00
biggy e4cea300db 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
2026-10-06 08:50:45 +02:00
105 changed files with 3067 additions and 1179 deletions

No files matched your search

+8 -8
View File
@@ -6,7 +6,7 @@ const { execFileSync } = require('child_process');
const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const BAKED = "/usr/lib/node_modules/@nanonets/graft/dist/claude"; const BAKED = "/usr/lib/node_modules/@nanonets/graft/dist/claude";
// The dist/claude dir of @nanonets/graft resolved from a base whose node_modules is searched. // dist/claude inside @nanonets/graft, resolved from a directory npm searches.
function fromPkg(base) { function fromPkg(base) {
try { try {
const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] }); const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] });
@@ -14,7 +14,7 @@ function fromPkg(base) {
} catch { return null; } } catch { return null; }
} }
// The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand. // The global node_modules directory, as npm reports it. Queried only when needed.
function globalRoot() { function globalRoot() {
try { try {
const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }).trim(); const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }).trim();
@@ -22,14 +22,14 @@ function globalRoot() {
} catch { return null; /* npm unavailable */ } } catch { return null; /* npm unavailable */ }
} }
// The version of the package a dist/claude dir belongs to, or null if unreadable. // The version of the package that owns a dist/claude directory, or null.
function versionOf(distClaude) { function versionOf(distClaude) {
try { try {
return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null; return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null;
} catch { return null; } } catch { return null; }
} }
// Numeric-dotted compare of the release part; an unreadable version loses to any known one. // Compares dotted version numbers. An unreadable version counts as older.
function newer(a, b) { function newer(a, b) {
if (!a) return false; if (!a) return false;
if (!b) return true; if (!b) return true;
@@ -42,7 +42,7 @@ function newer(a, b) {
return false; return false;
} }
// The highest-versioned dir in `dirs` that actually contains `name`, or null. // The highest-versioned entry in `dirs` that contains `name`, or null.
function best(dirs, name) { function best(dirs, name) {
let bestDir = null, bestVer = null; let bestDir = null, bestVer = null;
for (const d of dirs) { for (const d of dirs) {
@@ -54,14 +54,14 @@ function best(dirs, name) {
} }
function entry(name) { function entry(name) {
// Cheap candidates first, and only shell out to npm when every one of them misses. // Check the cheap candidates first, and run npm only if none of them match.
const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))]; const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))];
const hit = best(cheap, name); const hit = best(cheap, name);
if (hit) return path.join(hit, name); if (hit) return path.join(hit, name);
const gr = globalRoot(); const gr = globalRoot();
const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude'); const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude');
if (global && fs.existsSync(path.join(global, name))) return path.join(global, name); if (global && fs.existsSync(path.join(global, name))) return path.join(global, name);
return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent return path.join(dir, 'dist', 'claude', name); // fallback; the import does nothing when the file is 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 is unavailable: do nothing */ });
+8 -8
View File
@@ -6,7 +6,7 @@ const { execFileSync } = require('child_process');
const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const BAKED = "/usr/lib/node_modules/@nanonets/graft/dist/claude"; const BAKED = "/usr/lib/node_modules/@nanonets/graft/dist/claude";
// The dist/claude dir of @nanonets/graft resolved from a base whose node_modules is searched. // dist/claude inside @nanonets/graft, resolved from a directory npm searches.
function fromPkg(base) { function fromPkg(base) {
try { try {
const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] }); const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] });
@@ -14,7 +14,7 @@ function fromPkg(base) {
} catch { return null; } } catch { return null; }
} }
// The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand. // The global node_modules directory, as npm reports it. Queried only when needed.
function globalRoot() { function globalRoot() {
try { try {
const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }).trim(); const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }).trim();
@@ -22,14 +22,14 @@ function globalRoot() {
} catch { return null; /* npm unavailable */ } } catch { return null; /* npm unavailable */ }
} }
// The version of the package a dist/claude dir belongs to, or null if unreadable. // The version of the package that owns a dist/claude directory, or null.
function versionOf(distClaude) { function versionOf(distClaude) {
try { try {
return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null; return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null;
} catch { return null; } } catch { return null; }
} }
// Numeric-dotted compare of the release part; an unreadable version loses to any known one. // Compares dotted version numbers. An unreadable version counts as older.
function newer(a, b) { function newer(a, b) {
if (!a) return false; if (!a) return false;
if (!b) return true; if (!b) return true;
@@ -42,7 +42,7 @@ function newer(a, b) {
return false; return false;
} }
// The highest-versioned dir in `dirs` that actually contains `name`, or null. // The highest-versioned entry in `dirs` that contains `name`, or null.
function best(dirs, name) { function best(dirs, name) {
let bestDir = null, bestVer = null; let bestDir = null, bestVer = null;
for (const d of dirs) { for (const d of dirs) {
@@ -54,14 +54,14 @@ function best(dirs, name) {
} }
function entry(name) { function entry(name) {
// Cheap candidates first, and only shell out to npm when every one of them misses. // Check the cheap candidates first, and run npm only if none of them match.
const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))]; const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))];
const hit = best(cheap, name); const hit = best(cheap, name);
if (hit) return path.join(hit, name); if (hit) return path.join(hit, name);
const gr = globalRoot(); const gr = globalRoot();
const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude'); const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude');
if (global && fs.existsSync(path.join(global, name))) return path.join(global, name); if (global && fs.existsSync(path.join(global, name))) return path.join(global, name);
return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent return path.join(dir, 'dist', 'claude', name); // fallback; the import does nothing when the file is 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 is unavailable: do nothing */ });
+40 -38
View File
@@ -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. 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. 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 There are six of them. Pick the one that fits the task, run it, and act on the
answer; don't chain tools hoping for more. Most tasks need one call.** answer. Do not chain tools hoping for more. Most tasks need one call.
## The tools ## The tools
### 1 · `graft ask "<question>" --source`: locate + understand (the default) ### 1 · `graft ask "<question>" --source`: locate + understand (the default)
Ranked retrieval over the graph, routed automatically between prose nodes and Ranked retrieval over the graph, routed automatically between prose nodes and
the wiring graph, returning the top hits with exact `file:line`. 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 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. `--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). - `--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 - Use it when the question is conceptual or locational, for example "how does
work", "where is rate-limiting handled", "what assembles the request pipeline". 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 - 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 distinct sub-aspect, never the same question reworded. Few or weak hits mean
switch tool (grep / skeleton / callers), don't re-ask. switch tool (grep / skeleton / callers), don't re-ask.
### 2 · `graft grep "<pattern>"`: exhaustive find ### 2 · `graft grep "<pattern>"`: exhaustive find
Regex (or `--fixed` for a literal) over every indexed file, hits **grouped by 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. 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 - 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; constant, all providers. `ask` is ranked top-N and can miss instances;
grep won't. One grep replaces a spray of asks. grep does not. One grep replaces a spray of asks.
- Search a **short symbol name or literal**, not a full guessed signature: an - Search a short symbol name or literal, not a full guessed signature: an
over-specific regex (`func (s *Server) GenerateHandler`) returns nothing even over-specific regex (`func (s *Server) GenerateHandler`) returns nothing even
when the code is indexed. If a grep misses, **loosen it** (drop the receiver 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 and signature, keep the bare name) and retry `graft grep`. Do not switch to
raw `grep -rn`, which is slower and unranked. raw `grep -rn`, which is slower and unranked.
- `-i` case-insensitive; `--in <path>` scopes to a subtree. Raw `grep -rn` is - `-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). 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 ### 3 · `graft skeleton <file>`: a file's API at a glance
Signatures-only view of one file (every function / method / type with its span) Signatures-only view of one file (every function / method / type with its span)
in ~200 tokens, ~10x cheaper than reading the file. 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 - 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 editing or wiring into it. One skeleton is the whole answer for a file; do not
re-skeleton the same file, and don't skeleton every file `map` already named. re-skeleton the same file, and do not skeleton every file `map` already named.
### 4 · `graft callers <symbol>`: the exact edges ### 4 · `graft callers <symbol>`: the exact edges
Precomputed call/reference edges, not a text search. Symbol can be bare Precomputed call/reference edges, not a text search. Symbol can be bare
(`Foo`), qualified (`Class.method`), or package-qualified (`pkg.Fn`). (`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. rename, delete, or change its signature.
- `--direction out`: **what this symbol itself calls/depends on** (the old `callees`). - `--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 - `--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". `impact`); `--depth 2` is the usual "what breaks if I touch this".
- `--depth all`: the **entire connected closure** — every source reachable - `--depth all`: the entire connected closure, meaning every source reachable
through the edges. Reach for this before a **refactor, rename, or any through the edges. Reach for this before a refactor, rename, or any
multi-file change**: it surfaces the sibling and downstream files (platform 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. variants, a module you must split out) that a single-file edit would miss.
### 5 · `graft map`: orientation for an unfamiliar repo or area ### 5 · `graft map`: orientation for an unfamiliar repo or area
A token-budgeted tour: directory clusters, per-directory hubs, and global A token-budgeted tour: directory clusters, per-directory hubs, and global
hotspots, straight from the wiring graph. hotspots, straight from the wiring graph.
- **Use it when** you land in a repo cold or are asked for "the architecture". - 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 `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. or ask your way through every subsystem it lists. `--max-dirs N` widens it.
### 6 · Lifecycle: `graft build` / `graft check` ### 6 · Lifecycle: `graft build` / `graft check`
Every tool above refreshes the graph itself before answering, so what those tools 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 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. 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 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. stale if you have edited that file this turn.
`build` is for the LLM layer (`--deep` adds a concept map; skip unless asked); `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 | | 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 | | Understanding a flow ("how does X work") | `graft ask "<flow>" --source` | 1 |
| Finding where a change belongs | `graft ask "where is <behavior>" --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 | | 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 | | "What does this depend on?" | `graft callers <sym> --direction out` | 1 |
| Finding every occurrence of a pattern | `graft grep "<literal>"` | 1 | | Finding every occurrence of a pattern | `graft grep "<literal>"` | 1 |
| "What's the API of this file?" | `graft skeleton <file>` | 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". 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: - 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 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` `graft ask` for when you do not yet know where the code lives, because an
round-trip is wasted on a target you can already name. `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 - 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. 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 - 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` (a branch switch or unpulled move). Do not read the missing file. Instead
the symbol to find where it lives now, or run `graft build` to refresh. `graft grep` the symbol to find where it lives now, or run `graft build` to
refresh.
## Report what graft saved, every turn ## 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. 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 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. 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 `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 (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 skip it. This is the per-turn figure; the statusline carries the running
session total. 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 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 asked for, and it silently drops the savings line the statusline's running
total is parsed from. total is parsed from.
+10 -4
View File
@@ -1,9 +1,10 @@
# CMake build directories # Build output. /build/ holds the default trees; the other two patterns cover a
# BUILD_DIR override and the trees that earlier script versions left at the root.
/build/ /build/
/build_*/ /build_*/
/cmake-build-*/ /cmake-build-*/
# Windows output staging # Windows distribution staging
/SerumAlt_Windows/ /SerumAlt_Windows/
# CMake generated files # CMake generated files
@@ -12,10 +13,15 @@ compile_commands.json
# IDE and OS files # IDE and OS files
.vscode/ .vscode/
.idea/ .idea
*.user *.user
.DS_Store .DS_Store
.cache/ .cache/
# graft's local graph cache — regenerable, not committed (run `graft build`). # Upstream JUCE release binaries. They ship with the vendored copy and are not
# built here; committing them would add 22 MB of Linux executables.
/third_party/JUCE/DemoRunner
/third_party/JUCE/Projucer
# graft's local graph cache, regenerable and not committed (run `graft build`).
/graft/ /graft/
-83
View File
@@ -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.
+102
View File
@@ -39,3 +39,105 @@ re-read whole files.
After big code changes, refresh the graph with `graft build` (deterministic, After big code changes, refresh the graph with `graft build` (deterministic,
no API key, $0). no API key, $0).
<!-- graft:end --> <!-- 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: `./scripts/build_linux.sh`.
- macOS production, on a Mac: `./scripts/build_macos.sh`.
- Windows x64 VST3, cross-compiled on Linux: `./scripts/build_windows.sh`.
- Native QA build and execution: `./scripts/build_harness.sh --run`.
- Shell syntax: `for script in scripts/*.sh; do bash -n "$script"; done`.
- Build output removal: `./scripts/clean.sh --dry-run`, then the flags you need.
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. Each script defaults to a
directory under `build/`; 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
`cmake/mingw64-toolchain.cmake` is the single source of cross-compiler configuration.
## Current source layout
`CMakeLists.txt` owns the explicit `SERUMALT_SOURCES` list, the module include
directories 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.
Every implementation lives in a module directory under `Source/`, and the
`AGENTS.md` in that directory owns its rules:
- `Engine/` the voice pool, LFOs, wavetable library, FX rack, matrix and macros.
- `Synth/` wavetables, oscillators, filters, envelopes, LFOs and the voice.
- `Modulation/` the modulation matrix, macros, and `RAVEButton`, which defines
`RaveController`.
- `Params/` parameter IDs, indexed groups, enums and unit mappings.
- `Plugin/` the JUCE `AudioProcessor` boundary.
- `GUI/` the editor with its controls and previews.
- `EffectUnits/` the effect rack and the effect units.
- `Presets/`, `Resources/` and `Tests/`, as their names suggest.
`Source/ExactCompare.h` is the one shared header, and several modules include it.
`SERUMALT_INCLUDE_DIRS` lists one include directory per module, so a source names a
header without a path prefix. No two headers share a basename; check that before
adding one. `docs/AUDIT_REPORT.md` is a historical audit and proposed refactor, not
a pending instruction. Update `SERUMALT_SOURCES` and `SERUMALT_INCLUDE_DIRS` when
files move.
## 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.
+35 -25
View File
@@ -2,9 +2,6 @@ cmake_minimum_required(VERSION 3.22)
project(SerumAlt VERSION 1.0.0 LANGUAGES CXX) project(SerumAlt VERSION 1.0.0 LANGUAGES CXX)
# ---------------------------------------------------------------------------
# C++ standard
# ---------------------------------------------------------------------------
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
@@ -16,33 +13,30 @@ if(NOT SERUMALT_BUILD_PLUGIN AND NOT SERUMALT_BUILD_TESTS)
message(FATAL_ERROR "Enable SERUMALT_BUILD_PLUGIN or SERUMALT_BUILD_TESTS") message(FATAL_ERROR "Enable SERUMALT_BUILD_PLUGIN or SERUMALT_BUILD_TESTS")
endif() endif()
# ---------------------------------------------------------------------------
# JUCE
# ---------------------------------------------------------------------------
if(NOT DEFINED JUCE_ROOT) if(NOT DEFINED JUCE_ROOT)
set(JUCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/third_party/JUCE") set(JUCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/third_party/JUCE")
endif() endif()
add_subdirectory("${JUCE_ROOT}" "${CMAKE_CURRENT_BINARY_DIR}/third_party/JUCE") add_subdirectory("${JUCE_ROOT}" "${CMAKE_CURRENT_BINARY_DIR}/third_party/JUCE")
set(SERUMALT_SOURCES set(SERUMALT_SOURCES
Source/PluginProcessor.cpp Source/Plugin/PluginProcessor.cpp
Source/PluginEditor.cpp Source/GUI/PluginEditor.cpp
Source/Params.cpp Source/Params/Params.cpp
Source/Wavetable.cpp Source/Synth/Wavetable.cpp
Source/Oscillator.cpp Source/Synth/Oscillator.cpp
Source/SubOscillator.cpp Source/Synth/SubOscillator.cpp
Source/NoiseOscillator.cpp Source/Synth/NoiseOscillator.cpp
Source/Filter.cpp Source/Synth/Filter.cpp
Source/FilterBank.cpp Source/Synth/FilterBank.cpp
Source/Envelope.cpp Source/Synth/Envelope.cpp
Source/LFO.cpp Source/Synth/LFO.cpp
Source/ModulationMatrix.cpp Source/Modulation/ModulationMatrix.cpp
Source/FXProcessor.cpp Source/EffectUnits/FXProcessor.cpp
Source/MacroControls.cpp Source/Modulation/MacroControls.cpp
Source/RAVEButton.cpp Source/Modulation/RAVEButton.cpp
Source/SynthVoice.cpp Source/Synth/SynthVoice.cpp
Source/Engine.cpp Source/Engine/Engine.cpp
Source/Resources.cpp Source/Resources/Resources.cpp
Source/EffectUnits/Hyper.cpp Source/EffectUnits/Hyper.cpp
Source/EffectUnits/Chorus.cpp Source/EffectUnits/Chorus.cpp
Source/EffectUnits/Flanger.cpp Source/EffectUnits/Flanger.cpp
@@ -53,7 +47,7 @@ set(SERUMALT_SOURCES
Source/EffectUnits/Delay.cpp Source/EffectUnits/Delay.cpp
Source/EffectUnits/Reverb.cpp Source/EffectUnits/Reverb.cpp
Source/GUI/Knob.cpp Source/GUI/Knob.cpp
Source/GUI/Slider.cpp Source/GUI/KnobLookAndFeel.cpp
Source/GUI/ToggleButton.cpp Source/GUI/ToggleButton.cpp
Source/GUI/Display.cpp Source/GUI/Display.cpp
Source/GUI/WaveformDisplay.cpp Source/GUI/WaveformDisplay.cpp
@@ -63,9 +57,25 @@ set(SERUMALT_SOURCES
Source/GUI/Panel.cpp Source/GUI/Panel.cpp
Source/Presets/FactoryPresets.cpp) Source/Presets/FactoryPresets.cpp)
# One include directory per module, so a source names a header without a path
# prefix. No two headers in this tree share a basename.
set(SERUMALT_INCLUDE_DIRS
Source
Source/EffectUnits
Source/Engine
Source/GUI
Source/Modulation
Source/Params
Source/Plugin
Source/Presets
Source/Resources
Source/Synth
Source/Tests)
function(serumalt_configure_target target) function(serumalt_configure_target target)
juce_generate_juce_header(${target}) juce_generate_juce_header(${target})
target_sources(${target} PRIVATE ${SERUMALT_SOURCES}) target_sources(${target} PRIVATE ${SERUMALT_SOURCES})
target_include_directories(${target} PRIVATE ${SERUMALT_INCLUDE_DIRS})
target_compile_definitions(${target} target_compile_definitions(${target}
PUBLIC PUBLIC
JUCE_WEB_BROWSER=0 JUCE_WEB_BROWSER=0
+105 -87
View File
@@ -1,81 +1,77 @@
# SerumAlt # SerumAlt
A modern, commercial-quality wavetable synthesiser plugin (VST3 / AU / Standalone) A wavetable synthesiser experiment built with JUCE 7, CMake and C++17. It targets
built with **JUCE 7**, **CMake** and **C++17**. SerumAlt is a self-contained synth VST3, AU and Standalone formats, with a workflow inspired by Serum and Vital.
inspired by the workflow of Serum/Vital: two morphing wavetable oscillators, a full This is a fun proof of concept, not a commercial release. Development favors
filter section, four envelopes and LFOs, a modulation matrix, a reorderable FX rack, working builds and hands-on listening over elaborate test infrastructure.
four macros and a one-click **RAVE** button.
![SerumAlt](Source/Resources/logo.svg) ![SerumAlt](Source/Resources/logo.svg)
## Features ## Features
- **Two wavetable oscillators** — 256 frames × 2048 samples, bilinear morphing, - Two wavetable oscillators, each with 256 frames of 2048 samples, bilinear
8 warp modes (Bend+/-, Sync, PWM, Asym, Mirror, Fold), unison 1–16 with phase morphing, seven warp effects plus bypass, and up to 16 unison voices.
spread/detune/width. - Ten factory wavetables, built before audio rendering and kept in memory.
- **10 factory wavetables** (Basic Shapes, Saw PWM, Square Sync, Triangle Fold, - Sine/triangle sub oscillator and white/pink noise.
Vowel, Organ, Warm Saw, Digital, Glass, Bass), generated lazily and RAM-resident. - Two filter slots with seven models, drive, key tracking and serial, parallel
- **Sub oscillator** (sine/triangle, −2/−1/0 oct) and **noise** (white/pink). or split routing.
- **Filter section** — two slots, 7 models (Ladder LP/HP/BP, Diode, Comb, Formant, - Four ADSR envelopes and four free-running or tempo-synced LFOs, including
Screamer), 6/12/24 dB slopes, drive, keytrack, and serial/parallel/split routing. editable step and freehand shapes.
- **4 ADSR envelopes** with curve tension and live preview. - A modulation matrix and four assignable macros.
- **4 LFOs** — tempo-sync, 7 shapes (sine/tri/saw/square/S&H/step/freehand), - Nine effect types in eight reorderable slots, with independent state per slot.
editable step-sequencer and freehand drawing, fade-in and start delay. - A non-destructive RAVE boost for unison, spread, drive, Hyper and reverb.
- **Modulation matrix** — direct source→destination connections with bipolar depth. - Sixteen factory presets and a scalable vector GUI with waveform, envelope,
- **FX rack** — 9 effects (Hyper/OTT, Chorus, Flanger, Phaser, Distortion, EQ, LFO and schematic filter previews.
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.
## Architecture ## Architecture
``` ```
Source/ Source/
PluginProcessor.{h,cpp} APVTS, presets, state, RAVE/UI-scale glue Engine/ Voice pool, MIDI timing, control snapshots, FX rack
PluginEditor.{h,cpp} Tabbed dark GUI, scaling, visualisations Synth/ Wavetables, oscillators, filters, envelopes, LFOs, voice
Params.{h,cpp} Parameter IDs, enums, mod sources/targets, mappings Modulation/ Matrix, macros, RAVE
Wavetable.{h,cpp} 256×2048 tables + lazy library Params/ Parameter IDs, indexed groups, enums, mappings
Oscillator.{h,cpp} Morph/warp/unison oscillator Plugin/ AudioProcessor: APVTS, presets, state, RAVE/UI-scale glue
SubOscillator.{h,cpp} Sine/triangle sub GUI/ Editor, controls and previews
NoiseOscillator.{h,cpp} White/pink noise EffectUnits/ FX rack and the nine effect units
Filter.{h,cpp} 7 filter models Presets/ Factory preset definitions
FilterBank.{h,cpp} 2 slots + routing Resources/ Theme, formatters and SVG assets
Envelope.{h,cpp} ADSR with curve Tests/ Headless QA harness
LFO.{h,cpp} Synced LFO + shape editing ExactCompare.h Shared exact float comparison helpers
ModulationMatrix.{h,cpp} Connections
FXProcessor.{h,cpp} Reorderable rack
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
Resources/ SVG assets
``` ```
Key architectural decisions: a fixed 32-voice pool with pre-allocated unison The engine splits rendering at MIDI event positions and into segments of at most
sub-voices (max 16/osc), scalar processing (SIMD deferred), control-rate modulation 64 samples. Buffers and effect instances are prepared ahead of time. GUI edits
(one level, no modulation-of-modulation), and a `juce::AudioProcessorValueTreeState` use a control-side copy; audio tries to capture that copy once per host block and
backed parameter store that survives preset save/load together with the mod matrix, keeps its previous settings if an edit is still in progress.
macro assignments and LFO shape data.
### 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 ## Building
Each script configures and builds its own directory. Production builds disable the The scripts live in `scripts/`. Each configures and builds its own directory under
QA harness explicitly; the harness script creates no plugin or standalone targets. `build/`. Production builds disable the QA harness explicitly; the harness script
JUCE 7.0.12 is vendored under `third_party/JUCE` and is not downloaded by the scripts. creates no plugin or standalone targets. JUCE 7.0.12 is vendored under
`third_party/JUCE` and is not downloaded by the scripts.
### Prerequisites ### Prerequisites
All scripts require Bash, CMake 3.22+, Ninja, and C/C++17 compilers. The harness The build scripts require Bash, CMake 3.22+, Ninja, and C/C++17 compilers. The
also uses CTest, included with CMake, when invoked with `--run`. harness script also uses CTest, included with CMake, when invoked with `--run`.
On Linux, install GCC or Clang, `pkg-config`, and the ALSA, FreeType, X11 and OpenGL On Linux, install GCC or Clang, `pkg-config`, and the ALSA, FreeType, X11 and OpenGL
development libraries. For Debian/Ubuntu: development libraries. For Debian/Ubuntu:
@@ -114,37 +110,42 @@ sudo apt install gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 binutils-mingw-w64-x8
On Arch/CachyOS, use `sudo pacman -S --needed mingw-w64-gcc` instead. Keep the On Arch/CachyOS, use `sudo pacman -S --needed mingw-w64-gcc` instead. Keep the
native Linux tools and libraries installed: JUCE builds `juceaide` for the host native Linux tools and libraries installed: JUCE builds `juceaide` for the host
before compiling the Windows plugin. The checked-in `mingw64-toolchain.cmake` before compiling the Windows plugin. The checked-in `cmake/mingw64-toolchain.cmake`
selects the `x86_64-w64-mingw32-*` tools and isolates target library searches. selects the `x86_64-w64-mingw32-*` tools and isolates target library searches.
### Production builds ### Production builds
| Host | Command | Build directory | Formats | | Host | Command | Build directory | Formats |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Linux | `./build_linux.sh` | `build_linux/` | VST3, Standalone | | Linux | `./scripts/build_linux.sh` | `build/linux/` | VST3, Standalone |
| macOS | `./build_macos.sh` | `build_macos/` | VST3, AU, Standalone | | macOS | `./scripts/build_macos.sh` | `build/macos/` | VST3, AU, Standalone |
| Linux targeting Windows x64 | `./build_windows.sh` | `build_windows/` | VST3 only | | Linux targeting Windows x64 | `./scripts/build_windows.sh` | `build/windows/` | VST3 only |
Default Release artifacts: Default Release artifacts:
- Linux: `build_linux/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3` and - Linux: `build/linux/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3` and
`build_linux/SerumAlt_artefacts/Release/Standalone/SerumAlt`. `build/linux/SerumAlt_artefacts/Release/Standalone/SerumAlt`.
- macOS: `build_macos/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3`, - macOS: `build/macos/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3`,
`build_macos/SerumAlt_artefacts/Release/AU/SerumAlt.component`, and `build/macos/SerumAlt_artefacts/Release/AU/SerumAlt.component`, and
`build_macos/SerumAlt_artefacts/Release/Standalone/SerumAlt.app`. `build/macos/SerumAlt_artefacts/Release/Standalone/SerumAlt.app`.
- Windows: `build_windows/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3`, also - Windows: `build/windows/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3`, also
copied to `SerumAlt_Windows/SerumAlt.vst3`. copied to `SerumAlt_Windows/SerumAlt.vst3`.
The Windows script reuses the toolchain file without rewriting it. It injects The Windows script reuses the toolchain file without rewriting it. A cross-build
`moduleinfo.json` because the cross-compiled JUCE manifest helper cannot run on cannot run JUCE's manifest helper, because that helper is a host tool, so the
Linux. Keep this manifest's metadata in sync with `CMakeLists.txt`, script copies the `moduleinfo.json` that CMake writes from
`cmake/Plugin.cmake`, and the vendored VST3 SDK. MinGW links its C++ and threading `cmake/moduleinfo.json.in`. The plugin name, version, vendor, class IDs and VST3
runtimes statically via `-static-libgcc -static-libstdc++ -static`. SDK version come from variables in `cmake/Plugin.cmake`, so a version bump reaches
the manifest on its own. MinGW links its C++ and threading 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: macOS builds target the host architecture by default. To request a universal binary:
```bash ```bash
CMAKE_OSX_ARCHITECTURES='arm64;x86_64' ./build_macos.sh CMAKE_OSX_ARCHITECTURES='arm64;x86_64' ./scripts/build_macos.sh
``` ```
`CMAKE_OSX_DEPLOYMENT_TARGET` can also be passed to that script. Architecture and `CMAKE_OSX_DEPLOYMENT_TARGET` can also be passed to that script. Architecture and
@@ -157,22 +158,22 @@ and do not install plugins into a DAW's plugin directories.
On Linux or macOS, build the console harness without production targets: On Linux or macOS, build the console harness without production targets:
```bash ```bash
./build_harness.sh ./scripts/build_harness.sh
``` ```
Build and run the checks: Build and run the checks:
```bash ```bash
./build_harness.sh --run ./scripts/build_harness.sh --run
``` ```
Or rerun an already-built Release harness: Or rerun an already-built Release harness:
```bash ```bash
ctest --test-dir build_harness --build-config Release --output-on-failure --no-tests=error ctest --test-dir build/harness --build-config Release --output-on-failure --no-tests=error
``` ```
The executable is `build_harness/SerumAltTest_artefacts/Release/SerumAltTest`. The executable is `build/harness/SerumAltTest_artefacts/Release/SerumAltTest`.
It renders the initial state and every factory preset, checks for non-finite It renders the initial state and every factory preset, checks for non-finite
samples and aggregate non-silence, and exercises the RAVE toggle. A failed check samples and aggregate non-silence, and exercises the RAVE toggle. A failed check
returns a nonzero exit status. This is a processor integration smoke test, not a returns a nonzero exit status. This is a processor integration smoke test, not a
@@ -195,8 +196,8 @@ library. It is built and run natively, not as a Windows cross-compiled executabl
For example: For example:
```bash ```bash
BUILD_TYPE=Debug BUILD_DIR=build_harness_debug ./build_harness.sh --run BUILD_TYPE=Debug BUILD_DIR=build/harness_debug ./scripts/build_harness.sh --run
CMAKE_BUILD_PARALLEL_LEVEL=4 ./build_linux.sh CMAKE_BUILD_PARALLEL_LEVEL=4 ./scripts/build_linux.sh
``` ```
`CMakeLists.txt` owns the shared source list and target configuration. `CMakeLists.txt` owns the shared source list and target configuration.
@@ -207,9 +208,9 @@ For direct CMake use, `SERUMALT_BUILD_PLUGIN` defaults to `ON` and
`SERUMALT_BUILD_TESTS` defaults to `OFF`. A harness-only configure is: `SERUMALT_BUILD_TESTS` defaults to `OFF`. A harness-only configure is:
```bash ```bash
cmake -S . -B build_harness -G Ninja -DCMAKE_BUILD_TYPE=Release \ cmake -S . -B build/harness -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DSERUMALT_BUILD_PLUGIN=OFF -DSERUMALT_BUILD_TESTS=ON -DSERUMALT_BUILD_PLUGIN=OFF -DSERUMALT_BUILD_TESTS=ON
cmake --build build_harness --target SerumAltTest cmake --build build/harness --target SerumAltTest
``` ```
An existing CMake cache retains its old option values; changing the defaults does An existing CMake cache retains its old option values; changing the defaults does
@@ -220,12 +221,11 @@ when configuring directly with CMake.
### Fresh builds ### Fresh builds
The scripts reuse their build directories without deleting files, resetting Git, The scripts reuse their build directories without deleting files, resetting Git,
or installing packages. Generated `build/`, `build_*/`, and `SerumAlt_Windows/` or installing packages. `build/` and `SerumAlt_Windows/` are git-ignored. Leave
directories are git-ignored. Leave existing builds intact and select an unused existing builds intact and select an unused build directory for a fresh configure:
build directory for a fresh configure:
```bash ```bash
BUILD_DIR=build_linux_fresh ./build_linux.sh BUILD_DIR=build/linux_fresh ./scripts/build_linux.sh
``` ```
Windows staging updates generated files without deleting extra files already in Windows staging updates generated files without deleting extra files already in
@@ -233,9 +233,27 @@ the destination. For a clean distribution bundle, choose unused build and output
paths together: paths together:
```bash ```bash
BUILD_DIR=build_windows_fresh OUTPUT_DIR=build_windows_fresh/staged ./build_windows.sh BUILD_DIR=build/windows_fresh OUTPUT_DIR=build/windows_fresh/staged ./scripts/build_windows.sh
``` ```
### Cleaning
`scripts/clean.sh` removes generated output. A plain run removes the build trees
under `build/`; `--staging` adds `SerumAlt_Windows/`, `--legacy` adds the
`build_*` and `cmake-build-*` trees that earlier versions of the scripts left in
the repository root, and `--all` is both. `--dry-run` prints every path with its
size and removes nothing.
```bash
./scripts/clean.sh --dry-run --all
./scripts/clean.sh --all
```
The script never removes a tracked file, anything under `Source/`, `third_party/`
or `.git`, or a path outside the repository. It refuses to run when `BUILD_DIR` or
`OUTPUT_DIR` names a directory outside the repository, because such a directory
belongs to another checkout.
## License ## License
JUCE is licensed separately (see `third_party/JUCE/LICENSE.md`). This project is JUCE is licensed separately (see `third_party/JUCE/LICENSE.md`). This project is
@@ -2,7 +2,7 @@
## Owned files ## Owned files
- Related rack implementation: `../FXProcessor.h`, `../FXProcessor.cpp` - FXProcessor.h, FXProcessor.cpp
- Biquad.h - Biquad.h
- Hyper.h, Hyper.cpp - Hyper.h, Hyper.cpp
- Chorus.h, Chorus.cpp - Chorus.h, Chorus.cpp
@@ -27,7 +27,7 @@
## IF-THEN ## IF-THEN
- IF a unit needs a delay line THEN allocate it in `prepare()` at the full required length and only read and write it in `process`. - IF a unit needs a delay line THEN allocate it in `prepare()` at the full required length and only read and write it in `process`.
- Include the existing rack interface via `../FXProcessor.h`; no source relocation is implied by this guidance. - Include the rack interface via `FXProcessor.h`.
## Examples ## Examples
@@ -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.
+5 -5
View File
@@ -28,7 +28,7 @@ public:
void setLowpass (double freq, double q) 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 cw = std::cos (w0);
const double sw = std::sin (w0); const double sw = std::sin (w0);
const double alpha = sw / (2.0 * q); const double alpha = sw / (2.0 * q);
@@ -42,7 +42,7 @@ public:
void setHighpass (double freq, double q) 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 cw = std::cos (w0);
const double sw = std::sin (w0); const double sw = std::sin (w0);
const double alpha = sw / (2.0 * q); const double alpha = sw / (2.0 * q);
@@ -57,7 +57,7 @@ public:
void setLowShelf (double freq, double gainDb) void setLowShelf (double freq, double gainDb)
{ {
const double a = std::pow (10.0, gainDb / 40.0); 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 cw = std::cos (w0);
const double sw = std::sin (w0); const double sw = std::sin (w0);
const double alpha = sw / 2.0 * std::sqrt (2.0); const double alpha = sw / 2.0 * std::sqrt (2.0);
@@ -72,7 +72,7 @@ public:
void setHighShelf (double freq, double gainDb) void setHighShelf (double freq, double gainDb)
{ {
const double a = std::pow (10.0, gainDb / 40.0); 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 cw = std::cos (w0);
const double sw = std::sin (w0); const double sw = std::sin (w0);
const double alpha = sw / 2.0 * std::sqrt (2.0); const double alpha = sw / 2.0 * std::sqrt (2.0);
@@ -87,7 +87,7 @@ public:
void setPeak (double freq, double gainDb, double q) void setPeak (double freq, double gainDb, double q)
{ {
const double a = std::pow (10.0, gainDb / 40.0); 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 cw = std::cos (w0);
const double sw = std::sin (w0); const double sw = std::sin (w0);
const double alpha = sw / (2.0 * q); const double alpha = sw / (2.0 * q);
+3 -3
View File
@@ -37,13 +37,13 @@ void ChorusUnit::process (float* l, float* r, int numSamples, const float p[4])
delayL[(size_t) writePos] = inL; delayL[(size_t) writePos] = inL;
delayR[(size_t) writePos] = inR; delayR[(size_t) writePos] = inR;
lfoPhase += 6.28318530717958647692 * rate / sr; lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692; if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
float sumL = 0.0f, sumR = 0.0f; float sumL = 0.0f, sumR = 0.0f;
for (int v = 0; v < 2; ++v) 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); const double d = base + mod + (v == 1 ? width * depth * 0.5 : 0.0);
double readPos = writePos - d; double readPos = writePos - d;
if (readPos < 0.0) readPos += len; if (readPos < 0.0) readPos += len;
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
namespace serum namespace serum
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
namespace serum namespace serum
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
namespace serum namespace serum
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
#include "Biquad.h" #include "Biquad.h"
namespace serum namespace serum
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
#include "Biquad.h" #include "Biquad.h"
namespace serum namespace serum
@@ -33,17 +33,18 @@ FXProcessor::~FXProcessor() = default;
void FXProcessor::prepare (double sampleRate, int maxBlockSize) void FXProcessor::prepare (double sampleRate, int maxBlockSize)
{ {
for (auto& slotUnits : units) for (auto& slotUnits : units)
for (auto& u : slotUnits) for (auto& unit : slotUnits)
u->prepare (sampleRate, maxBlockSize); unit->prepare (sampleRate, maxBlockSize);
wet.setSize (2, maxBlockSize, false, false, true); wet.setSize (2, maxBlockSize, false, false, true);
reset(); reset();
} }
void FXProcessor::reset() void FXProcessor::reset()
{ {
for (auto& u : units) for (auto& slotUnits : units)
u->reset(); for (auto& unit : slotUnits)
dry.clear(); unit->reset();
activeTypes.fill ((int) FxType::Off);
wet.clear(); wet.clear();
} }
@@ -61,52 +62,50 @@ juce::String FXProcessor::fxTypeName (int type)
case FxType::Compressor: return "Compressor"; case FxType::Compressor: return "Compressor";
case FxType::Delay: return "Delay"; case FxType::Delay: return "Delay";
case FxType::Reverb: return "Reverb"; case FxType::Reverb: return "Reverb";
case FxType::Count: return {};
default: return {}; default: return {};
} }
} }
void FXProcessor::process (juce::AudioBuffer<float>& buffer, const FxSlotParams* slots, int numSlots) 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(); const int n = buffer.getNumSamples();
jassert (n <= wet.getNumSamples());
if (n <= 0) if (n <= 0)
return; return;
for (int s = 0; s < numSlots; ++s) for (int s = 0; s < juce::jmin (numSlots, kNumFxSlots); ++s)
{ {
const FxSlotParams& slot = slots[s]; const FxSlotParams& slot = slots[s];
if (slot.type == (int) FxType::Off)
continue;
const int unitIdx = slot.type - 1; 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; continue;
}
dry.copyFrom (0, 0, buffer, 0, 0, n); auto& unit = *units[(size_t) s][(size_t) unitIdx];
dry.copyFrom (1, 1, buffer, 1, 0, n); 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 (0, 0, buffer, 0, 0, n);
wet.copyFrom (1, 1, buffer, 1, 0, n); wet.copyFrom (1, 0, buffer, 1, 0, n);
unit.process (wet.getWritePointer (0), wet.getWritePointer (1), n, slot.p);
units[(size_t) unitIdx]->process (wet.getWritePointer (0), wet.getWritePointer (1), n, slot.p);
const float mix = juce::jlimit (0.0f, 1.0f, slot.mix); 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); float* out = buffer.getWritePointer (ch);
buffer.copyFrom (1, 0, wet, 1, 0, n); const float* effect = wet.getReadPointer (ch);
}
else if (mix > 0.0f)
{
const float* dL = dry.getReadPointer (0);
const float* dR = dry.getReadPointer (1);
const float* wL = wet.getReadPointer (0);
const float* wR = wet.getReadPointer (1);
float* oL = buffer.getWritePointer (0);
float* oR = buffer.getWritePointer (1);
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i)
{ out[i] = out[i] * (1.0f - mix) + effect[i] * mix;
oL[i] = dL[i] * (1.0f - mix) + wL[i] * mix;
oR[i] = dR[i] * (1.0f - mix) + wR[i] * mix;
}
} }
} }
} }
File renamed without changes.
+6 -2
View File
@@ -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]; const float inL = l[i], inR = r[i];
lfoPhase += 6.28318530717958647692 * rate / sr; lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692; if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
const double mod = std::sin (lfoPhase) * depth; 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); double readPos = writePos - (base + mod);
if (readPos < 0.0) readPos += len; if (readPos < 0.0) readPos += len;
if (readPos >= (double) len) readPos -= len;
const int i0 = (int) readPos; const int i0 = (int) readPos;
const int i1 = (i0 + 1) % len; const int i1 = (i0 + 1) % len;
const float frac = (float) (readPos - std::floor (readPos)); const float frac = (float) (readPos - std::floor (readPos));
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
namespace serum namespace serum
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
#include "Biquad.h" #include "Biquad.h"
namespace serum namespace serum
+7 -3
View File
@@ -27,11 +27,15 @@ void PhaserUnit::process (float* l, float* r, int numSamples, const float p[4])
for (int i = 0; i < numSamples; ++i) for (int i = 0; i < numSamples; ++i)
{ {
lfoPhase += 6.28318530717958647692 * rate / sr; lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692; if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
const float sweep = (float) (0.5 + 0.5 * std::sin (lfoPhase)); 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 outL = l[i] + yL[5] * feedback;
float outR = r[i] + yR[5] * feedback; float outR = r[i] + yR[5] * feedback;
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
namespace serum namespace serum
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "../FXProcessor.h" #include "FXProcessor.h"
namespace serum namespace serum
{ {
+49
View File
@@ -0,0 +1,49 @@
# engine module
## Owned files
- Engine.h, Engine.cpp
## Rules
- Own the voice pool, LFOs, wavetable library, FX rack, matrix and macros by value.
- 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`.
## IF-THEN
- IF a per-block buffer is needed THEN allocate it in `prepare()` and clear it per block.
- IF a module needs the matrix, macros or wavetables THEN go through Engine accessors rather than reaching into another module.
## Examples
```cpp
// BAD: per-callback buffer reconfiguration on the audio thread
void processBlock (...)
{
mixBuffer.setSize (2, n, false, false, true);
...
}
```
```cpp
// GOOD: size once, then wrap a non-owning view per segment
void prepare (double sr, int blockSize)
{
mixBuffer.setSize (2, blockSize, false, false, true);
}
void processBlock (...)
{
// No allocation; the view only borrows mixBuffer's channels.
juce::AudioBuffer<float> block (mixBuffer.getArrayOfWritePointers(), 2, segmentLength);
block.clear();
...
}
```
This file overrides /AGENTS.md where they conflict.
+15 -6
View File
@@ -56,16 +56,30 @@ void Engine::reset()
fx.reset(); fx.reset();
pitchBend = 0.0f; pitchBend = 0.0f;
modWheel = 0.0f; modWheel = 0.0f;
activeVoiceCount.store (0, std::memory_order_relaxed);
mixBuffer.clear(); mixBuffer.clear();
} }
void Engine::requestReset() noexcept
{
const juce::ScopedLock lock (controlLock);
resetRequested = true;
}
void Engine::captureControls() void Engine::captureControls()
{ {
const juce::ScopedTryLock lock (controlLock); const juce::ScopedTryLock lock (controlLock);
if (! lock.isLocked()) if (! lock.isLocked())
return; 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(), audioMatrix.connections.assign (matrix.connections.begin(),
matrix.connections.begin() + juce::jmin (matrix.size(), ModulationMatrix::kMaxConnections)); matrix.connections.begin() + juce::jmin (matrix.size(), ModulationMatrix::kMaxConnections));
for (int i = 0; i < kNumMacros; ++i) 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; modWheel = message.getControllerValue() / 127.0f;
} }
renderUntil (n); renderUntil (n);
int active = 0;
for (const auto& voice : voices)
active += voice.isActive() ? 1 : 0;
activeVoiceCount.store (active, std::memory_order_relaxed);
} }
} // namespace serum } // namespace serum
+7 -3
View File
@@ -25,6 +25,12 @@ public:
void prepare (double sampleRate, int blockSize, juce::AudioProcessorValueTreeState& apvts); void prepare (double sampleRate, int blockSize, juce::AudioProcessorValueTreeState& apvts);
void reset(); 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, void processBlock (juce::AudioBuffer<float>& buffer,
juce::MidiBuffer& midi, juce::MidiBuffer& midi,
juce::AudioProcessorValueTreeState& apvts, juce::AudioProcessorValueTreeState& apvts,
@@ -43,8 +49,6 @@ public:
const WavetableLibrary& getWavetables() const { return wavetables; } const WavetableLibrary& getWavetables() const { return wavetables; }
void setLfoShapeData (int index, const std::vector<float>& data, int steps); void setLfoShapeData (int index, const std::vector<float>& data, int steps);
int getActiveVoiceCount() const { return activeVoiceCount.load (std::memory_order_relaxed); }
private: private:
std::array<SynthVoice, kNumVoices> voices; std::array<SynthVoice, kNumVoices> voices;
std::array<LFO, kNumLfos> lfos, controlLfos; std::array<LFO, kNumLfos> lfos, controlLfos;
@@ -53,6 +57,7 @@ private:
ModulationMatrix matrix, audioMatrix; ModulationMatrix matrix, audioMatrix;
MacroControls macros, audioMacros; MacroControls macros, audioMacros;
juce::CriticalSection controlLock; juce::CriticalSection controlLock;
bool resetRequested = false; // guarded by controlLock
struct ParameterValue struct ParameterValue
{ {
@@ -67,7 +72,6 @@ private:
float pitchBend = 0.0f; float pitchBend = 0.0f;
float modWheel = 0.0f; float modWheel = 0.0f;
juce::uint64 noteCounter = 0; juce::uint64 noteCounter = 0;
std::atomic<int> activeVoiceCount { 0 };
juce::AudioBuffer<float> mixBuffer; juce::AudioBuffer<float> mixBuffer;
+42
View File
@@ -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
+3 -5
View File
@@ -2,10 +2,9 @@
## Owned files ## Owned files
- Related editor implementation: `../PluginEditor.h`, `../PluginEditor.cpp` - PluginEditor.h, PluginEditor.cpp
- SerumLookAndFeel.h
- Knob.h, Knob.cpp - Knob.h, Knob.cpp
- Slider.h, Slider.cpp - KnobLookAndFeel.h, KnobLookAndFeel.cpp
- ToggleButton.h, ToggleButton.cpp - ToggleButton.h, ToggleButton.cpp
- Display.h, Display.cpp - Display.h, Display.cpp
- Panel.h, Panel.cpp - Panel.h, Panel.cpp
@@ -16,7 +15,6 @@
## Rules ## Rules
- Draw all controls through `SerumLookAndFeel`.
- Use `theme::` colours from Resources.h instead of hardcoded colour values. - Use `theme::` colours from Resources.h instead of hardcoded colour values.
- Own GUI attachments with `std::unique_ptr`. - 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. - 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; : 0.0f;
``` ```
This file overrides /AGENT.md where they conflict. This file overrides /AGENTS.md where they conflict.
+1 -1
View File
@@ -1,7 +1,7 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "../Resources.h" #include "Resources.h"
namespace serum namespace serum
{ {
+6 -3
View File
@@ -1,11 +1,14 @@
#include "EnvelopeDisplay.h" #include "EnvelopeDisplay.h"
#include "Envelope.h"
#include "ExactCompare.h"
namespace serum namespace serum
{ {
void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c) 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; return;
attack = a; decay = d; sustain = s; release = r; curve = c; attack = a; decay = d; sustain = s; release = r; curve = c;
repaint(); repaint();
@@ -19,8 +22,8 @@ void EnvelopeDisplay::paint (juce::Graphics& g)
g.setColour (theme::outline); g.setColour (theme::outline);
g.drawRoundedRectangle (b.expanded (4.0f).reduced (0.5f), 4.0f, 1.0f); g.drawRoundedRectangle (b.expanded (4.0f).reduced (0.5f), 4.0f, 1.0f);
const float atkShape = 0.3f + curve * 2.7f; const float atkShape = attackShapeFor (curve);
const float decShape = 3.0f - curve * 2.7f; const float decShape = decayShapeFor (curve);
// Normalise durations for display, allowing a short sustain plateau. // Normalise durations for display, allowing a short sustain plateau.
const float aSec = maps::toSeconds (attack); const float aSec = maps::toSeconds (attack);
+2 -2
View File
@@ -1,8 +1,8 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "../Resources.h" #include "Resources.h"
#include "../Params.h" #include "Params.h"
namespace serum namespace serum
{ {
+8 -1
View File
@@ -1,8 +1,12 @@
#include "FilterDisplay.h" #include "FilterDisplay.h"
#include "ExactCompare.h"
namespace serum 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) float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int type)
{ {
const float w = (cutoffHz > 1.0f) ? freqHz / cutoffHz : 1.0f; 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; return m / 3.0f;
} }
case FilterModel::LadderLP:
case FilterModel::Diode: case FilterModel::Diode:
case FilterModel::Count:
default: default:
return (denom > 1e-6f) ? 1.0f / denom : 0.0f; 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) 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; return;
type = t; cutoff = c; res = r; drive = d; slope = s; type = t; cutoff = c; res = r; drive = d; slope = s;
repaint(); repaint();
+2 -2
View File
@@ -1,8 +1,8 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "../Resources.h" #include "Resources.h"
#include "../Params.h" #include "Params.h"
namespace serum namespace serum
{ {
+75 -7
View File
@@ -3,6 +3,17 @@
namespace serum 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) Knob::Knob (const juce::String& name, std::function<juce::String (float)> fmt)
: formatter (std::move (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); setDoubleClickReturnValue (true, 0.5);
setName (name); setName (name);
setComponentID (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) juce::String Knob::getTextFromValue (double value)
@@ -21,6 +36,32 @@ juce::String Knob::getTextFromValue (double value)
return formatPercent ((float) 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) void Knob::mouseDown (const juce::MouseEvent& e)
{ {
juce::Slider::mouseDown (e); juce::Slider::mouseDown (e);
@@ -56,6 +97,24 @@ void Knob::showValuePopup (const juce::MouseEvent& e)
if (topLevel == nullptr) if (topLevel == nullptr)
return; 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) if (valuePopup == nullptr)
{ {
valuePopup = std::make_unique<juce::Label>(); valuePopup = std::make_unique<juce::Label>();
@@ -63,19 +122,20 @@ void Knob::showValuePopup (const juce::MouseEvent& e)
valuePopup->setInterceptsMouseClicks (false, false); valuePopup->setInterceptsMouseClicks (false, false);
valuePopup->setColour (juce::Label::backgroundColourId, juce::Colours::transparentBlack); valuePopup->setColour (juce::Label::backgroundColourId, juce::Colours::transparentBlack);
valuePopup->setColour (juce::Label::textColourId, theme::text); valuePopup->setColour (juce::Label::textColourId, theme::text);
valuePopup->setFont (juce::Font (14.0f, juce::Font::bold));
valuePopup->setJustificationType (juce::Justification::centred); valuePopup->setJustificationType (juce::Justification::centred);
topLevel->addAndMakeVisible (valuePopup.get()); topLevel->addAndMakeVisible (valuePopup.get());
} }
valuePopup->setFont (juce::Font (14.0f * scale, juce::Font::bold));
valuePopup->setText (getTextFromValue (getValue()), juce::dontSendNotification); valuePopup->setText (getTextFromValue (getValue()), juce::dontSendNotification);
constexpr int w = 84; // Keep the readout inside the window so a knob near an edge still shows it.
constexpr int h = 24; const auto popupPos = topLevel->getLocalPoint (nullptr, e.getScreenPosition());
constexpr int gap = 10; valuePopup->setBounds (juce::jlimit (0, juce::jmax (0, topLevel->getWidth() - w),
popupPos.getX() - w / 2),
const auto cursor = topLevel->getLocalPoint (nullptr, e.getScreenPosition()); juce::jlimit (0, juce::jmax (0, topLevel->getHeight() - h),
valuePopup->setBounds (cursor.getX() - w / 2, cursor.getY() - h - gap, w, h); popupPos.getY() - h - gap),
w, h);
} }
void Knob::hideValuePopup() void Knob::hideValuePopup()
@@ -83,4 +143,12 @@ void Knob::hideValuePopup()
valuePopup.reset(); 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 } // namespace serum
+15 -3
View File
@@ -2,14 +2,17 @@
#include <JuceHeader.h> #include <JuceHeader.h>
#include <memory> #include <memory>
#include "../Resources.h" #include "Resources.h"
#include "KnobLookAndFeel.h"
namespace serum namespace serum
{ {
// =========================================================================== // ===========================================================================
// Rotary knob — a juce::Slider styled by SerumLookAndFeel. The value readout is // Rotary knob — a juce::Slider drawn by KnobLookAndFeel. The knob paints its
// formatted by a user-provided function via getTextFromValue(). // 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 // 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 // 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); } 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; 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 mouseDown (const juce::MouseEvent& e) override;
void mouseDrag (const juce::MouseEvent& e) override; void mouseDrag (const juce::MouseEvent& e) override;
void mouseUp (const juce::MouseEvent& e) override; void mouseUp (const juce::MouseEvent& e) override;
@@ -36,6 +47,7 @@ private:
std::function<juce::String (float)> formatter; std::function<juce::String (float)> formatter;
std::unique_ptr<juce::Label> valuePopup; std::unique_ptr<juce::Label> valuePopup;
juce::Rectangle<int> textArea; // name strip under the dial
}; };
} // namespace serum } // namespace serum
+47
View File
@@ -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
+34
View File
@@ -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
View File
@@ -1,52 +1,28 @@
#include "LFODisplay.h" #include "LFODisplay.h"
#include "ExactCompare.h"
namespace serum namespace serum
{ {
void LFODisplay::setShapeData (const std::vector<float>& data, int s) void LFODisplay::setShapeData (const std::vector<float>& data, int s)
{ {
const int newSteps = juce::jlimit (2, 64, s); const int newSteps = juce::jlimit (2, LFO::kShapePoints, s);
const size_t dataSize = std::min (data.size(), size_t (64)); const auto dataSize = (std::ptrdiff_t) std::min (data.size(), size_t (LFO::kShapePoints));
if (steps == newSteps && shapeData.size() == 64 if (steps == newSteps && (int) shapeData.size() == LFO::kShapePoints
&& std::equal (data.begin(), data.begin() + dataSize, shapeData.begin()) && 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; return;
steps = newSteps; steps = newSteps;
shapeData.assign (data.begin(), data.begin() + dataSize); shapeData.assign (data.begin(), data.begin() + dataSize);
shapeData.resize (64, 0.0f); shapeData.resize ((size_t) LFO::kShapePoints, 0.0f);
repaint(); repaint();
} }
float LFODisplay::valueAt (float phase) const noexcept float LFODisplay::valueAt (float phase) const noexcept
{ {
switch ((LfoShape) shape) return lfoShapeValue (phase, (LfoShape) shape, steps,
{ shapeData.data(), (int) shapeData.size(), kSampleHoldLevel);
case LfoShape::Sine: return std::sin (phase * 6.2831853f);
case LfoShape::Triangle: return 1.0f - 4.0f * std::abs (phase - 0.5f);
case LfoShape::Saw: return 2.0f * phase - 1.0f;
case LfoShape::Square: return (phase < 0.5f) ? 1.0f : -1.0f;
case LfoShape::SampleHold:
{
const int step = juce::jlimit (0, 15, (int) (phase * 16.0f));
return ((step * 2654435761u) & 0xffffu) / 32768.0f - 1.0f;
}
case LfoShape::StepSeq:
{
const int idx = juce::jlimit (0, steps - 1, (int) (phase * steps));
return shapeData.empty() ? 0.0f : shapeData[(size_t) idx];
}
case LfoShape::Freehand:
{
if (shapeData.empty()) return 0.0f;
const float pos = phase * (float) (steps - 1);
const int i0 = (int) pos;
const int i1 = juce::jmin (i0 + 1, steps - 1);
const float frac = pos - (float) i0;
return shapeData[(size_t) i0] * (1.0f - frac) + shapeData[(size_t) i1] * frac;
}
default: return 0.0f;
}
} }
void LFODisplay::paint (juce::Graphics& g) 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; juce::Path path;
path.startNewSubPath (b.getX() + 4.0f, midY); path.startNewSubPath (b.getX() + 4.0f, midY);
const int n = 256; 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)); const float v = juce::jlimit (-1.0f, 1.0f, (midY - y) / (b.getHeight() * 0.42f));
if (shapeData.empty()) if (shapeData.empty())
shapeData.assign (64, 0.0f); shapeData.assign ((size_t) LFO::kShapePoints, 0.0f);
if (shape == (int) LfoShape::StepSeq) if (shape == (int) LfoShape::StepSeq)
{ {
+16 -3
View File
@@ -1,14 +1,19 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "../Resources.h" #include "Resources.h"
#include "../Params.h" #include "Params.h"
#include "LFO.h"
namespace serum namespace serum
{ {
// =========================================================================== // ===========================================================================
// LFO shape view. Step-sequencer and freehand shapes are click/drag editable. // 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 class LFODisplay : public juce::Component
{ {
@@ -21,13 +26,21 @@ public:
void mouseDown (const juce::MouseEvent& e) override; void mouseDown (const juce::MouseEvent& e) override;
void mouseDrag (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: private:
int shape = 0; int shape = 0;
std::vector<float> shapeData; std::vector<float> shapeData;
int steps = 16; int steps = 16;
std::function<void (const std::vector<float>&, int)> onEdited; std::function<void (const std::vector<float>&, int)> onEdited;
float valueAt (float phase) const noexcept;
void editAt (float x, float y); void editAt (float x, float y);
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "../Resources.h" #include "Resources.h"
namespace serum namespace serum
{ {
@@ -19,7 +19,6 @@ namespace
constexpr int knobSmall = 44; // small knob width (env / LFO rows) constexpr int knobSmall = 44; // small knob width (env / LFO rows)
constexpr int rowHeight = knobSize + padding; // vertical pitch of knob rows constexpr int rowHeight = knobSize + padding; // vertical pitch of knob rows
constexpr int panelSpacing = 10; // gap between adjacent panels 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 // Derived from the visual style (panel title bar and control sizes) so the
// content grid stays aligned across every tab. // content grid stays aligned across every tab.
@@ -28,7 +27,12 @@ namespace
constexpr int buttonHeight = 26; // text button height constexpr int buttonHeight = 26; // text button height
constexpr int toggleWidth = 40; // "On"/"Sync" toggle width constexpr int toggleWidth = 40; // "On"/"Sync" toggle width
constexpr int toggleHeight = 36; // toggle height (LED + visible label) 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 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 displayHeight = 66; // waveform / filter display height
constexpr int envDisplayHeight = 56; // envelope display height constexpr int envDisplayHeight = 56; // envelope display height
constexpr int lfoDisplayHeight = 64; // LFO 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 smallGap = 3; // horizontal spacing between small knobs
constexpr int arrowWidth = 26; // FX up/down arrow button width constexpr int arrowWidth = 26; // FX up/down arrow button width
constexpr int macroKnobSize = 120; // macro knob footprint 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). // 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 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) 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& kFxP3 = paramIds::fxP3;
constexpr auto& kFxP4 = paramIds::fxP4; constexpr auto& kFxP4 = paramIds::fxP4;
constexpr const char* kFxParamNames[(int) FxType::Count][4] = { // How one FX slot parameter is presented: the name under the knob, the text
{ "P1", "P2", "P3", "P4" }, // that converts its normalised value to physical units, and the tooltip that
{ "Intensity", "Low Amount", "High Amount", "Output" }, // states what it does and its range.
{ "Rate", "Depth", "Width", "Unused" }, struct FxParamDisplay
{ "Rate", "Depth", "Feedback", "Unused" }, {
{ "Rate", "Depth", "Feedback", "Stages" }, const char* name;
{ "Drive", "Shape", "Tone", "Output" }, const char* tooltip;
{ "Low Gain", "Mid Gain", "High Gain", "Mid Freq" }, std::function<juce::String (float)> formatter;
{ "Threshold", "Ratio", "Attack", "Release" }, };
{ "Time", "Feedback", "Damping", "Ping-Pong" },
{ "Size", "Damping", "Width", "Predelay" } // 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(); auto* k = control.get();
ownedControls.push_back (std::move (control)); ownedControls.push_back (std::move (control));
parent->addAndMakeVisible (k); 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> ( sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
processor.parameters, paramId, *k)); processor.parameters, paramId, *k));
}
k->setTooltip (tooltip.isNotEmpty() ? tooltip : name); k->setTooltip (tooltip.isNotEmpty() ? tooltip : name);
return k; return k;
} }
@@ -222,7 +356,7 @@ void PluginEditor::buildTopBar()
presetCombo.onChange = [this] { processor.setCurrentProgram (presetCombo.getSelectedItemIndex()); }; presetCombo.onChange = [this] { processor.setCurrentProgram (presetCombo.getSelectedItemIndex()); };
root.addAndMakeVisible (presetCombo); root.addAndMakeVisible (presetCombo);
presetCombo.setBounds (200, 16, 220, 24); 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. // UI scale.
scaleCombo.addItemList ({ "75%", "100%", "125%", "150%", "200%" }, 1); scaleCombo.addItemList ({ "75%", "100%", "125%", "150%", "200%" }, 1);
@@ -230,25 +364,25 @@ void PluginEditor::buildTopBar()
scaleCombo.onChange = [this] { processor.setUiScaleIndex (scaleCombo.getSelectedItemIndex()); }; scaleCombo.onChange = [this] { processor.setUiScaleIndex (scaleCombo.getSelectedItemIndex()); };
root.addAndMakeVisible (scaleCombo); root.addAndMakeVisible (scaleCombo);
scaleCombo.setBounds (980, 16, 70, 24); scaleCombo.setBounds (980, 16, 70, 24);
scaleCombo.setTooltip ("Interface scale (75% - 200%)"); scaleCombo.setTooltip ("Interface scale, 75 % to 200 %");
// RAVE. // RAVE.
raveButton.setLabel ("RAVE"); raveButton.setLabel ("Rave");
raveButton.setOnColour (theme::raveGlow); raveButton.setOnColour (theme::raveGlow);
raveButton.setBounds (1062, 2, 50, 56); raveButton.setBounds (1062, 2, 50, 56);
root.addAndMakeVisible (raveButton); 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. // RAVE uses a callback rather than a direct parameter attachment.
raveButton.setOnClick ([this] (bool on) { processor.setRaveEnabled (on); }); raveButton.setOnClick ([this] (bool on) { processor.setRaveEnabled (on); });
// Tab bar. // Tab bar.
juce::TextButton* tabs[5] = { &tabOsc, &tabFilter, &tabMod, &tabFx, &tabMacro }; juce::TextButton* tabs[5] = { &tabOsc, &tabFilter, &tabMod, &tabFx, &tabMacro };
const char* tabNames[5] = { "OSC", "FILTER", "MOD", "FX", "MACRO" }; const char* tabNames[5] = { "Osc", "Filter", "Mod", "FX", "Macro" };
const char* tabTips[5] = { "Oscillators (Tab cycles sections)", const char* tabTips[5] = { "Oscillators. Tab cycles sections.",
"Filters (Tab cycles sections)", "Filters. Tab cycles sections.",
"Modulation: envelopes, LFOs and matrix (Tab cycles sections)", "Modulation. Envelopes, LFOs and the routing matrix.",
"Effects rack (Tab cycles sections)", "Effects rack. Tab cycles sections.",
"Macros (Tab cycles sections)" }; "Macros. Tab cycles sections." };
for (int i = 0; i < 5; ++i) for (int i = 0; i < 5; ++i)
{ {
tabs[i]->setButtonText (tabNames[i]); tabs[i]->setButtonText (tabNames[i]);
@@ -262,7 +396,7 @@ void PluginEditor::buildTopBar()
// Master. // Master.
masterKnob.setBounds (906, 0, 64, 60); masterKnob.setBounds (906, 0, 64, 60);
masterKnob.setTooltip ("Master output volume (0-100%)"); masterKnob.setTooltip ("Master output level, 0 % to 100 %");
root.addAndMakeVisible (masterKnob); root.addAndMakeVisible (masterKnob);
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> ( sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
processor.parameters, ids::master, masterKnob)); processor.parameters, ids::master, masterKnob));
@@ -274,12 +408,12 @@ void PluginEditor::buildTopBar()
void PluginEditor::buildOscTab() void PluginEditor::buildOscTab()
{ {
oscView.setBounds (0, 60, kBaseW, kBaseH - 60); oscView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
root.addChildComponent (oscView); root.addChildComponent (oscView);
constexpr int colW = 545; // two equal columns filling kBaseW constexpr int colW = layout::twoColW;
constexpr int oscH = 308; constexpr int oscH = layout::oscPanelH;
constexpr int subH = 150; constexpr int subH = layout::smallPanelH;
oscAPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, oscH); oscAPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, oscH);
oscBPanel.setBounds (gridX (1, 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; const int row2Y = row1Y + layout::rowHeight;
// --- Oscillator A --- // --- Oscillator A ---
makeToggle (&oscAPanel, "On", ids::oscAOn, "Enable oscillator A")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); 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(), "Oscillator A wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight); 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" }, "Oscillator A warp mode") 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); ->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
oscAWave.setWavetables (&processor.engine.getWavetables()); oscAWave.setWavetables (&processor.engine.getWavetables());
@@ -315,17 +449,19 @@ void PluginEditor::buildOscTab()
std::vector<Knob*> rowA2 = { std::vector<Knob*> rowA2 = {
makeKnob (&oscAPanel, "Unison", ids::oscAUnison, fmtUnison, "Unison voices (1-16)"), makeKnob (&oscAPanel, "Unison", ids::oscAUnison, fmtUnison, "Unison voices (1-16)"),
makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatPercent, "Unison detune (0-100%)"), makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatCents, "Unison detune spread, 0 to 50 cents"),
makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison spread (0-100%)"), makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison stereo spread (0-100%)"),
makeKnob (&oscAPanel, "Phase", ids::oscAPhase, formatPercent, "Phase (0-100%)"), makeKnob (&oscAPanel, "Phase", ids::oscAPhase, fmtDegrees, "Start phase at note on, 0 to 360 degrees"),
makeKnob (&oscAPanel, "Random Phase", ids::oscARandPh, formatPercent, "Random phase (0-100%)") 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); 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 --- // --- Oscillator B ---
makeToggle (&oscBPanel, "On", ids::oscBOn, "Enable oscillator B")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); 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(), "Oscillator B wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight); 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" }, "Oscillator B warp mode") 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); ->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
oscBWave.setWavetables (&processor.engine.getWavetables()); oscBWave.setWavetables (&processor.engine.getWavetables());
@@ -344,32 +480,33 @@ void PluginEditor::buildOscTab()
std::vector<Knob*> rowB2 = { std::vector<Knob*> rowB2 = {
makeKnob (&oscBPanel, "Unison", ids::oscBUnison, fmtUnison, "Unison voices (1-16)"), makeKnob (&oscBPanel, "Unison", ids::oscBUnison, fmtUnison, "Unison voices (1-16)"),
makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatPercent, "Unison detune (0-100%)"), makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatCents, "Unison detune spread, 0 to 50 cents"),
makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison spread (0-100%)"), makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison stereo spread (0-100%)"),
makeKnob (&oscBPanel, "Phase", ids::oscBPhase, formatPercent, "Phase (0-100%)"), makeKnob (&oscBPanel, "Phase", ids::oscBPhase, fmtDegrees, "Start phase at note on, 0 to 360 degrees"),
makeKnob (&oscBPanel, "Random Phase", ids::oscBRandPh, formatPercent, "Random phase (0-100%)") 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); placeKnobs (rowB2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
rowB2[0]->setRange (0.0, 1.0, 1.0 / (kMaxUnison - 1));
// --- Sub --- // --- Sub ---
makeToggle (&subPanel, "On", ids::subOn, "Enable sub oscillator")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); 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")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight); 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")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 130 + layout::gap, comboY, 90, 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); makeKnob (&subPanel, "Level", ids::subLevel, formatPercent, "Sub oscillator level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
// --- Noise --- // --- Noise ---
makeToggle (&noisePanel, "On", ids::noiseOn, "Enable noise")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); 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 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight); 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); makeKnob (&noisePanel, "Level", ids::noiseLevel, formatPercent, "Noise level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
} }
void PluginEditor::buildFilterTab() void PluginEditor::buildFilterTab()
{ {
filterView.setBounds (0, 60, kBaseW, kBaseH - 60); filterView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
root.addChildComponent (filterView); root.addChildComponent (filterView);
constexpr int colW = 360; constexpr int colW = layout::threeColW;
constexpr int panelH = 300; // retains the original panel height (content top-aligned) constexpr int panelH = layout::filterPanelH;
filter1Panel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, panelH); filter1Panel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, panelH);
filter2Panel.setBounds (gridX (1, 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 const int knobY = displayY + layout::displayHeight + layout::padding; // aligns with OSC row 1
// Filter 1 // Filter 1
makeToggle (&filter1Panel, "On", ids::f1On, "Enable filter 1")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); 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, "Filter 1 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight); 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, "Filter 1 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, 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); filter1Panel.addAndMakeVisible (filter1Display);
filter1Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight); filter1Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> f1 = { std::vector<Knob*> f1 = {
makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff (20 Hz - 20 kHz)"), makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff, 20 Hz to 18 kHz"),
makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance (0-100%)"), makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance, 0 % to 100 %"),
makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive (0-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 keytrack (0-100%)") makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 key tracking, 0 % to 100 %")
}; };
placeKnobs (f1, layout::padding, knobY, layout::knobSize, layout::knobSize); placeKnobs (f1, layout::padding, knobY, layout::knobSize, layout::knobSize);
// Filter 2 // Filter 2
makeToggle (&filter2Panel, "On", ids::f2On, "Enable filter 2")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); 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, "Filter 2 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight); 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, "Filter 2 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, 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); filter2Panel.addAndMakeVisible (filter2Display);
filter2Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight); filter2Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> f2 = { std::vector<Knob*> f2 = {
makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff (20 Hz - 20 kHz)"), makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff, 20 Hz to 18 kHz"),
makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance (0-100%)"), makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance, 0 % to 100 %"),
makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive (0-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 keytrack (0-100%)") makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 key tracking, 0 % to 100 %")
}; };
placeKnobs (f2, layout::padding, knobY, layout::knobSize, layout::knobSize); placeKnobs (f2, layout::padding, knobY, layout::knobSize, layout::knobSize);
// Routing // Routing
makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing (serial / parallel / split)")->setBounds (layout::padding, comboY, 160, layout::comboHeight); 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, "Filter mix (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize); 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, formatPercent, "Filter output (0-100%)")->setBounds (layout::padding + layout::knobSize + layout::gap, 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() void PluginEditor::buildModTab()
{ {
modView.setBounds (0, 60, kBaseW, kBaseH - 60); modView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
root.addChildComponent (modView); root.addChildComponent (modView);
const juce::StringArray lfoShapes = { "Sine", "Triangle", "Saw", "Square", "S&H", "Step", "Freehand" }; 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 colW = layout::fourColW;
constexpr int colGap = 8; constexpr int colGap = layout::fourColGap;
constexpr int envH = 154; constexpr int envH = layout::envPanelH;
constexpr int lfoH = 206; constexpr int lfoH = layout::lfoPanelH;
// Envelopes (4 across). // Envelopes (4 across).
for (int i = 0; i < kNumEnvelopes; ++i) for (int i = 0; i < kNumEnvelopes; ++i)
@@ -449,11 +586,11 @@ void PluginEditor::buildModTab()
const auto& idsC = paramIds::envCurve; const auto& idsC = paramIds::envCurve;
std::vector<Knob*> knobs = { 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], "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 - 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-100%)"), 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 - 12 s)"), 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, "Envelope curve (0-100%)") 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, placeKnobs (knobs, layout::padding,
layout::titleHeight + layout::envDisplayHeight + layout::padding, layout::titleHeight + layout::envDisplayHeight + layout::padding,
@@ -475,18 +612,18 @@ void PluginEditor::buildModTab()
const auto& fade = paramIds::lfoFade; const auto& fade = paramIds::lfoFade;
const auto& delay = paramIds::lfoDelay; const auto& delay = paramIds::lfoDelay;
makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "LFO shape")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight); 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], "Tempo-sync LFO")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); 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]); lfoPanels[(size_t) i].addAndMakeVisible (lfoDisplays[(size_t) i]);
lfoDisplays[(size_t) i].setBounds (layout::padding, bodyTop(), colW - 2 * layout::padding, layout::lfoDisplayHeight); lfoDisplays[(size_t) i].setBounds (layout::padding, bodyTop(), colW - 2 * layout::padding, layout::lfoDisplayHeight);
std::vector<Knob*> knobs = { 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], "Rate", rate[i], fmtLfoRate, "LFO rate, 0.02 Hz to 30 Hz"),
makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], formatPercent, "Beat division (1/32 - 4 bars)"), 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], formatPercent, "Phase (0-100%)"), makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], fmtDegrees, "Start phase, 0 to 360 degrees"),
makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], formatPercent, "Fade in (0-100%)"), 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], formatPercent, "Start delay (0-100%)") makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], fmtLfoDelay, "Start delay, 0 s to 4 s")
}; };
placeKnobs (knobs, layout::padding, placeKnobs (knobs, layout::padding,
bodyTop() + layout::lfoDisplayHeight + layout::padding, bodyTop() + layout::lfoDisplayHeight + layout::padding,
@@ -500,65 +637,70 @@ void PluginEditor::buildModTab()
// Modulation matrix. // Modulation matrix.
const int matrixTop = lfoTop + lfoH + layout::panelSpacing; 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); matrixPanel.setBounds (layout::margin, matrixTop, kBaseW - 2 * layout::margin, matrixH);
modView.addAndMakeVisible (matrixPanel); modView.addAndMakeVisible (matrixPanel);
std::vector<ModTarget> targetEnums; std::vector<ModTarget> targetEnums;
const juce::StringArray targetItems = modTargetItems (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.addItemList (modSourceItems(), 1);
modSourceCombo.setSelectedItemIndex (0, juce::dontSendNotification); modSourceCombo.setSelectedItemIndex (0, juce::dontSendNotification);
modSourceCombo.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 160, layout::comboHeight); modSourceCombo.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 160, layout::comboHeight);
modSourceCombo.setTooltip ("Modulation source"); modSourceCombo.setTooltip ("Selects the modulation source");
matrixPanel.addAndMakeVisible (modSourceCombo); matrixPanel.addAndMakeVisible (modSourceCombo);
modTargetCombo.addItemList (targetItems, 1); modTargetCombo.addItemList (targetItems, 1);
modTargetCombo.setSelectedItemIndex (0, juce::dontSendNotification); modTargetCombo.setSelectedItemIndex (0, juce::dontSendNotification);
modTargetCombo.setBounds (layout::padding + 160 + layout::gap, mCenter - layout::comboHeight / 2, 200, layout::comboHeight); 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); matrixPanel.addAndMakeVisible (modTargetCombo);
const int depthX = layout::padding + 160 + layout::gap + 200 + layout::gap; const int depthX = layout::padding + 160 + layout::gap + 200 + layout::gap;
modDepthKnob.setRange (-1.0, 1.0); modDepthKnob.setRange (-1.0, 1.0);
modDepthKnob.setValue (0.5, juce::dontSendNotification); modDepthKnob.setValue (0.5, juce::dontSendNotification);
modDepthKnob.setFormatter (fmtDepth); modDepthKnob.setDefaultValue (0.0); // a double click recentres the depth
modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60); modDepthKnob.setFormatter (fmtModDepth);
modDepthKnob.setTooltip ("Modulation depth (-1 to +1)"); 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); matrixPanel.addAndMakeVisible (modDepthKnob);
modBipolarToggle.setLabel ("Bipolar"); modBipolarToggle.setLabel ("Bipolar");
modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, 60, layout::toggleHeight); modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, layout::toggleWide, layout::toggleHeight);
modBipolarToggle.setTooltip ("Bipolar modulation depth"); modBipolarToggle.setTooltip ("Recentres a one-sided source so the modulation swings both ways, for the next Add");
matrixPanel.addAndMakeVisible (modBipolarToggle); matrixPanel.addAndMakeVisible (modBipolarToggle);
modBipolarToggle.setToggleState (true); 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.setButtonText ("Add");
modAddButton.setBounds (addX, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight); 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] modAddButton.onClick = [this, targetEnums]
{ {
const ModSource src = (ModSource) modSourceCombo.getSelectedItemIndex(); const ModSource src = (ModSource) modSourceCombo.getSelectedItemIndex();
const int tid = modTargetCombo.getSelectedItemIndex(); const int tid = modTargetCombo.getSelectedItemIndex();
if (tid >= 0 && tid < (int) targetEnums.size()) if (tid >= 0 && tid < (int) targetEnums.size())
{ {
bool added = false;
{ {
const juce::ScopedLock lock (processor.engine.getControlLock()); const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid], added = processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
(float) modDepthKnob.getValue(), (float) modDepthKnob.getValue(),
modBipolarToggle.getToggleState()); modBipolarToggle.getToggleState());
} }
if (added)
updateModList(); updateModList();
else
modAddButton.setEnabled (false); // at cap: keep list text and selection unchanged
} }
}; };
matrixPanel.addAndMakeVisible (modAddButton); matrixPanel.addAndMakeVisible (modAddButton);
modRemoveButton.setButtonText ("Remove Last"); modRemoveButton.setButtonText ("Remove Last");
modRemoveButton.setBounds (addX + 70 + layout::gap, mCenter - layout::buttonHeight / 2, 110, layout::buttonHeight); 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] modRemoveButton.onClick = [this]
{ {
{ {
@@ -573,7 +715,7 @@ void PluginEditor::buildModTab()
modClearButton.setButtonText ("Clear"); modClearButton.setButtonText ("Clear");
modClearButton.setBounds (addX + 70 + layout::gap + 110 + layout::gap, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight); 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] modClearButton.onClick = [this]
{ {
{ {
@@ -584,11 +726,12 @@ void PluginEditor::buildModTab()
}; };
matrixPanel.addAndMakeVisible (modClearButton); 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, modList.setBounds (layout::padding, listTop,
kBaseW - 2 * layout::margin - 2 * layout::padding, kBaseW - 2 * layout::margin - 2 * layout::padding,
matrixH - listTop - layout::padding); matrixH - listTop - layout::padding);
modList.setReadOnly (true); modList.setReadOnly (true);
modList.setTooltip ("Every modulation connection, newest last. Depth is a fraction of the target range.");
modList.setMultiLine (true, false); modList.setMultiLine (true, false);
modList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack); modList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
modList.setColour (juce::TextEditor::textColourId, theme::text); modList.setColour (juce::TextEditor::textColourId, theme::text);
@@ -598,14 +741,17 @@ void PluginEditor::buildModTab()
void PluginEditor::buildFxTab() void PluginEditor::buildFxTab()
{ {
fxView.setBounds (0, 60, kBaseW, kBaseH - 60); fxView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
root.addChildComponent (fxView); root.addChildComponent (fxView);
const juce::StringArray fxTypes = { "Off", "Hyper", "Chorus", "Flanger", "Phaser", // Built from the one label function so the item order always follows FxType,
"Distortion", "EQ", "Compressor", "Delay", "Reverb" }; // 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 colW = layout::twoColW;
constexpr int slotH = 150; constexpr int slotH = layout::smallPanelH;
for (int i = 0; i < kNumFxSlots; ++i) 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); }; 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); }; 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 = { std::vector<Knob*> knobs = {
makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Effect mix (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 (0-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 (0-100%)"), 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 (0-100%)"), 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 (0-100%)") 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); placeKnobs (knobs, layout::padding, bodyTop(), layout::knobSize, layout::knobSize);
fxKnobs[(size_t) i] = knobs; fxKnobs[(size_t) i] = knobs;
@@ -652,12 +802,12 @@ void PluginEditor::buildFxTab()
void PluginEditor::buildMacroTab() void PluginEditor::buildMacroTab()
{ {
macroView.setBounds (0, 60, kBaseW, kBaseH - 60); macroView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
root.addChildComponent (macroView); root.addChildComponent (macroView);
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100 constexpr int colW = layout::fourColW;
constexpr int colGap = 8; constexpr int colGap = layout::fourColGap;
constexpr int macroH = 158; constexpr int macroH = layout::macroPanelH;
for (int i = 0; i < kNumMacros; ++i) for (int i = 0; i < kNumMacros; ++i)
{ {
@@ -674,56 +824,68 @@ void PluginEditor::buildMacroTab()
// Macro assignment editor. // Macro assignment editor.
const int assignTop = layout::margin + macroH + layout::panelSpacing; 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); macroAssignPanel.setBounds (layout::margin, assignTop, kBaseW - 2 * layout::margin, assignH);
macroView.addAndMakeVisible (macroAssignPanel); macroView.addAndMakeVisible (macroAssignPanel);
std::vector<ModTarget> targetEnums; std::vector<ModTarget> targetEnums;
const juce::StringArray targetItems = modTargetItems (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.setSelectedItemIndex (0, juce::dontSendNotification);
macroAssignIndex.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 140, layout::comboHeight); 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); macroAssignPanel.addAndMakeVisible (macroAssignIndex);
macroAssignTarget.addItemList (targetItems, 1); macroAssignTarget.addItemList (targetItems, 1);
macroAssignTarget.setSelectedItemIndex (0, juce::dontSendNotification); macroAssignTarget.setSelectedItemIndex (0, juce::dontSendNotification);
macroAssignTarget.setBounds (layout::padding + 140 + layout::gap, mCenter - layout::comboHeight / 2, 220, layout::comboHeight); 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); macroAssignPanel.addAndMakeVisible (macroAssignTarget);
const int depthX = layout::padding + 140 + layout::gap + 220 + layout::gap; const int depthX = layout::padding + 140 + layout::gap + 220 + layout::gap;
macroDepthKnob.setFormatter (fmtDepth); // Half depth, so an Assign made without touching this knob still moves the
macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60); // destination. A depth of zero would create a silent assignment.
macroDepthKnob.setTooltip ("Macro assignment depth (0 to 1)"); 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); macroAssignPanel.addAndMakeVisible (macroDepthKnob);
const int assignBtnX = depthX + layout::knobSize + layout::gap; const int assignBtnX = depthX + layout::knobSize + layout::gap;
macroAssignButton.setButtonText ("Assign"); macroAssignButton.setButtonText ("Assign");
macroAssignButton.setBounds (assignBtnX, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight); 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] macroAssignButton.onClick = [this, targetEnums]
{ {
const int macro = macroAssignIndex.getSelectedItemIndex(); const int macro = macroAssignIndex.getSelectedItemIndex();
const int tid = macroAssignTarget.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()); const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid], added = processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
(float) macroDepthKnob.getValue()); (float) macroDepthKnob.getValue());
} }
if (added)
updateMacroList(); updateMacroList();
else
macroAssignButton.setEnabled (false); // at cap: keep list text and selection unchanged
} }
}; };
macroAssignPanel.addAndMakeVisible (macroAssignButton); macroAssignPanel.addAndMakeVisible (macroAssignButton);
macroClearButton.setButtonText ("Clear All"); macroClearButton.setButtonText ("Clear All");
macroClearButton.setBounds (assignBtnX + 80 + layout::gap, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight); 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] macroClearButton.onClick = [this]
{ {
{ {
@@ -734,11 +896,12 @@ void PluginEditor::buildMacroTab()
}; };
macroAssignPanel.addAndMakeVisible (macroClearButton); 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, macroList.setBounds (layout::padding, listTop,
kBaseW - 2 * layout::margin - 2 * layout::padding, kBaseW - 2 * layout::margin - 2 * layout::padding,
assignH - listTop - layout::padding); assignH - listTop - layout::padding);
macroList.setReadOnly (true); macroList.setReadOnly (true);
macroList.setTooltip ("Every macro assignment, grouped by macro. Depth is a fraction of the target range.");
macroList.setMultiLine (true, false); macroList.setMultiLine (true, false);
macroList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack); macroList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
macroList.setColour (juce::TextEditor::textColourId, theme::text); 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) if (a < 0 || b < 0 || a >= kNumFxSlots || b >= kNumFxSlots || a == b)
return; return;
const juce::ScopedLock lock (processor.engine.getControlLock());
auto swapParam = [this] (const char* pa, const char* pb) auto swapParam = [this] (const char* pa, const char* pb)
{ {
auto* p1 = processor.parameters.getParameter (pa); auto* p1 = processor.parameters.getParameter (pa);
@@ -897,12 +1061,18 @@ void PluginEditor::updateVisuals()
for (int p = 0; p < 4; ++p) for (int p = 0; p < 4; ++p)
{ {
auto* knob = fxKnobs[(size_t) i][(size_t) p + 1]; 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) if (knob->getName() != name)
{ {
knob->setName (name); knob->setName (name);
knob->setTooltip (name == "Unused" ? "Not used by this effect" knob->setTooltip (spec.tooltip);
: name + " (normalised 0-100%)"); knob->setFormatter (spec.formatter);
knob->setEnabled (name != "Unused");
knob->repaint(); knob->repaint();
} }
} }
@@ -926,26 +1096,33 @@ void PluginEditor::updateVisuals()
void PluginEditor::updateModList() void PluginEditor::updateModList()
{ {
juce::String text; juce::String text;
int count = 0;
{ {
const juce::ScopedLock lock (processor.engine.getControlLock()); const juce::ScopedLock lock (processor.engine.getControlLock());
const auto& cons = processor.engine.getMatrix().connections; const auto& cons = processor.engine.getMatrix().connections;
count = (int) cons.size();
for (const auto& c : cons) for (const auto& c : cons)
text += modSourceName (c.source) + " -> " + modTargetName (c.target) text += modSourceName (c.source) + " -> " + modTargetName (c.target)
+ " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n"; + " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n";
} }
if (modList.getText() != text) if (modList.getText() != text)
modList.setText (text, false); modList.setText (text, false);
modAddButton.setEnabled (count < ModulationMatrix::kMaxConnections);
} }
void PluginEditor::updateMacroList() void PluginEditor::updateMacroList()
{ {
juce::String text; juce::String text;
int assigned = 0;
const int macro = macroAssignIndex.getSelectedItemIndex();
{ {
const juce::ScopedLock lock (processor.engine.getControlLock()); const juce::ScopedLock lock (processor.engine.getControlLock());
for (int m = 0; m < kNumMacros; ++m) for (int m = 0; m < kNumMacros; ++m)
{ {
text += MacroControls::macroName (m) + ":\n"; text += MacroControls::macroName (m) + ":\n";
const auto& assigns = processor.engine.getMacros().assignments[(size_t) m]; const auto& assigns = processor.engine.getMacros().assignments[(size_t) m];
if (m == macro)
assigned = (int) assigns.size();
if (assigns.empty()) if (assigns.empty())
text += " (none)\n"; text += " (none)\n";
for (const auto& a : assigns) for (const auto& a : assigns)
@@ -954,6 +1131,8 @@ void PluginEditor::updateMacroList()
} }
if (macroList.getText() != text) if (macroList.getText() != text)
macroList.setText (text, false); macroList.setText (text, false);
macroAssignButton.setEnabled (macro >= 0 && macro < kNumMacros
&& assigned < MacroControls::kMaxAssignments);
} }
void PluginEditor::timerCallback() void PluginEditor::timerCallback()
@@ -2,9 +2,7 @@
#include <JuceHeader.h> #include <JuceHeader.h>
#include "PluginProcessor.h" #include "PluginProcessor.h"
#include "GUI/SerumLookAndFeel.h"
#include "GUI/Knob.h" #include "GUI/Knob.h"
#include "GUI/Slider.h"
#include "GUI/ToggleButton.h" #include "GUI/ToggleButton.h"
#include "GUI/Display.h" #include "GUI/Display.h"
#include "GUI/Panel.h" #include "GUI/Panel.h"
@@ -36,7 +34,6 @@ public:
private: private:
SerumAltAudioProcessor& processor; SerumAltAudioProcessor& processor;
SerumLookAndFeel laf;
static constexpr int kBaseW = 1120; static constexpr int kBaseW = 1120;
static constexpr int kBaseH = 740; static constexpr int kBaseH = 740;
@@ -63,7 +60,6 @@ private:
Panel oscAPanel { "Oscillator A" }, oscBPanel { "Oscillator B" }; Panel oscAPanel { "Oscillator A" }, oscBPanel { "Oscillator B" };
Panel subPanel { "Sub" }, noisePanel { "Noise" }; Panel subPanel { "Sub" }, noisePanel { "Noise" };
WaveformDisplay oscAWave, oscBWave; WaveformDisplay oscAWave, oscBWave;
std::vector<juce::Component*> oscAComponents, oscBComponents;
// --- FILTER --- // --- FILTER ---
Panel filter1Panel { "Filter 1" }, filter2Panel { "Filter 2" }, routingPanel { "Routing" }; Panel filter1Panel { "Filter 1" }, filter2Panel { "Filter 2" }, routingPanel { "Routing" };
-156
View File
@@ -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
-13
View File
@@ -1,13 +0,0 @@
#include "Slider.h"
namespace serum
{
Slider::Slider (const juce::String& name, std::function<juce::String (float)> formatter)
: Knob (name, std::move (formatter))
{
setSliderStyle (juce::Slider::LinearHorizontal);
setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
}
} // namespace serum
-18
View File
@@ -1,18 +0,0 @@
#pragma once
#include "Knob.h"
namespace serum
{
// ===========================================================================
// Horizontal slider (same formatting machinery as Knob).
// ===========================================================================
class Slider : public Knob
{
public:
explicit Slider (const juce::String& name,
std::function<juce::String (float)> formatter = {});
};
} // namespace serum
+13 -5
View File
@@ -33,21 +33,29 @@ void ToggleButton::paint (juce::Graphics& g)
const float ledSize = juce::jmin (bounds.getHeight() - 16.0f, 16.0f); 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 juce::Rectangle<float> led ((bounds.getWidth() - ledSize) * 0.5f, 4.0f, ledSize, ledSize);
const bool live = isEnabled();
const bool hovered = live && isMouseOverOrDragging();
if (state) 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.fillEllipse (led.expanded (6.0f));
g.setColour (onColour); g.setColour (live ? onColour : onColour.withAlpha (0.4f));
} }
else else
{ {
g.setColour (theme::outline); g.setColour (hovered ? theme::textDim : theme::outline);
} }
g.fillEllipse (led); g.fillEllipse (led);
g.setColour (state ? juce::Colours::black : theme::textDim); 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.setFont (juce::Font (11.0f));
g.drawText (label, 0, (int) (led.getBottom() + 2), getWidth(), 14, juce::Justification::centred, false); g.drawText (label, 0, (int) (led.getBottom() + 2), getWidth(), 14, juce::Justification::centred, false);
} }
+1 -1
View File
@@ -1,7 +1,7 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "../Resources.h" #include "Resources.h"
namespace serum namespace serum
{ {
+4 -3
View File
@@ -1,8 +1,9 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "../Resources.h" #include "ExactCompare.h"
#include "../Wavetable.h" #include "Resources.h"
#include "Wavetable.h"
namespace serum namespace serum
{ {
@@ -15,7 +16,7 @@ class WaveformDisplay : public juce::Component
public: public:
void setWavetables (const WavetableLibrary* lib) { if (wtLib != lib) { wtLib = lib; repaint(); } } void setWavetables (const WavetableLibrary* lib) { if (wtLib != lib) { wtLib = lib; repaint(); } }
void setWaveIndex (int index) { if (wave != index) { wave = index; 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 setEnabled (bool e) { if (enabled != e) { enabled = e; repaint(); } }
void paint (juce::Graphics& g) override; void paint (juce::Graphics& g) override;
+31
View File
@@ -0,0 +1,31 @@
# modulation module
## Owned files
- ModulationMatrix.h, ModulationMatrix.cpp
- MacroControls.h, MacroControls.cpp
- RAVEButton.h, RAVEButton.cpp, which define `RaveController`
## Rules
- Use `bool` return codes for bounded inserts (`addConnection`, `addAssignment`).
- Require a cap on connection and assignment counts (`kMaxConnections`, `kMaxAssignments`).
- Serialise and restore state through `juce::ValueTree` so presets survive save and load.
- Never accept a `ModTarget::None` connection; reject it and return false.
- 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
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.
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 AGENTS.md for the related sources.
@@ -1,4 +1,5 @@
#include "MacroControls.h" #include "MacroControls.h"
#include "ExactCompare.h"
namespace serum namespace serum
{ {
@@ -79,7 +80,7 @@ void MacroControls::fromValueTree (const juce::ValueTree& tree)
continue; continue;
const double savedIndex = (double) mac.getProperty ("index", -1); const double savedIndex = (double) mac.getProperty ("index", -1);
if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumMacros if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumMacros
|| std::floor (savedIndex) != savedIndex) || ! isWholeNumber (savedIndex))
continue; continue;
const int index = (int) savedIndex; const int index = (int) savedIndex;
for (const auto& asg : mac) for (const auto& asg : mac)
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,14 +1,17 @@
# params module # params module
## Related source files ## Owned files
This directory contains guidance; the implementation remains in `../Params.h` - Params.h, Params.cpp
and `../Params.cpp`.
## Rules ## Rules
- Use the `ids::` namespace for every APVTS parameter ID string. - Use the `ids::` namespace for every APVTS parameter ID string.
- Require one `ids::` entry per audio parameter, and one entry only. - 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 `enum class` for every enum. Give each a trailing `Count` enumerator.
- Use `inline constexpr` for parameter ID strings and `k`-prefixed integer constants. - Use `inline constexpr` for parameter ID strings and `k`-prefixed integer constants.
- Keep all 0..1 to physical unit conversions in the `maps::` namespace. - Keep all 0..1 to physical unit conversions in the `maps::` namespace.
@@ -36,4 +39,4 @@ if (auto* p = apvts.getParameter (ids::f1Cutoff))
return p->getValue(); return p->getValue();
``` ```
This file overrides /AGENT.md where they conflict. This file overrides /AGENTS.md where they conflict.
@@ -24,6 +24,7 @@ juce::String modSourceName (ModSource s)
case ModSource::Macro3: return "Macro 3"; case ModSource::Macro3: return "Macro 3";
case ModSource::Macro4: return "Macro 4"; case ModSource::Macro4: return "Macro 4";
case ModSource::Random: return "Random"; case ModSource::Random: return "Random";
case ModSource::Count: return {};
default: return {}; default: return {};
} }
} }
@@ -41,6 +42,7 @@ juce::String modSourceToString (ModSource s)
case ModSource::Macro1: return "macro1"; case ModSource::Macro2: return "macro2"; case ModSource::Macro1: return "macro1"; case ModSource::Macro2: return "macro2";
case ModSource::Macro3: return "macro3"; case ModSource::Macro4: return "macro4"; case ModSource::Macro3: return "macro3"; case ModSource::Macro4: return "macro4";
case ModSource::Random: return "random"; case ModSource::Random: return "random";
case ModSource::Count: return {};
default: 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::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::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::Fx7Mix: return "FX 7 Mix"; case ModTarget::Fx8Mix: return "FX 8 Mix";
case ModTarget::None: return {};
case ModTarget::Count: return {};
default: 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::Fx3Mix: return ids::fx3Mix; case ModTarget::Fx4Mix: return ids::fx4Mix;
case ModTarget::Fx5Mix: return ids::fx5Mix; case ModTarget::Fx6Mix: return ids::fx6Mix; 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::Fx7Mix: return ids::fx7Mix; case ModTarget::Fx8Mix: return ids::fx8Mix;
case ModTarget::None: return {};
case ModTarget::Count: return {};
default: return {}; default: return {};
} }
} }
File renamed without changes.
@@ -1,9 +1,8 @@
# plugin module # plugin module
## Related source files ## Owned files
This directory contains guidance; the implementation remains in - PluginProcessor.h, PluginProcessor.cpp
`../PluginProcessor.h` and `../PluginProcessor.cpp`.
## Rules ## Rules
@@ -13,7 +12,10 @@ This directory contains guidance; the implementation remains in
- Guard `getRawParameterValue` and `getParameter` results before dereferencing them. - Guard `getRawParameterValue` and `getParameter` results before dereferencing them.
- Return silently on null or invalid state XML and invalid ValueTrees. - Return silently on null or invalid state XML and invalid ValueTrees.
- Use raw `new` only for JUCE-owned objects (`createEditor`, `createPluginFilter`). - 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 ## IF-THEN
@@ -38,4 +40,4 @@ if (! state.isValid())
return; return;
``` ```
This file overrides /AGENT.md where they conflict. This file overrides /AGENTS.md where they conflict.
@@ -1,6 +1,7 @@
#include "PluginProcessor.h" #include "PluginProcessor.h"
#include "PluginEditor.h" #include "PluginEditor.h"
#include "Presets/FactoryPresets.h" #include "Presets/FactoryPresets.h"
#include "ExactCompare.h"
namespace serum 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::fMix, "Filter Mix", 0.5f));
params.push_back (f (ids::fOut, "Filter Out", 0.667f)); params.push_back (f (ids::fOut, "Filter Out", 0.667f));
// Envelopes 1..4 // Envelopes 1..4 (IDs come from the shared paramIds groups)
const char* envA[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A }; constexpr auto& envA = paramIds::envAttack;
const char* envD[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D }; constexpr auto& envD = paramIds::envDecay;
const char* envS[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S }; constexpr auto& envS = paramIds::envSustain;
const char* envR[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R }; constexpr auto& envR = paramIds::envRelease;
const char* envC[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve }; constexpr auto& envC = paramIds::envCurve;
const float envADef[4] = { 0.05f, 0.1f, 0.2f, 0.2f }; 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 envDDef[4] = { 0.25f, 0.3f, 0.3f, 0.3f };
const float envSDef[4] = { 0.8f, 0.5f, 0.5f, 0.5f }; 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)); params.push_back (f (envC[i], juce::String ("Env ") + juce::String (i + 1) + " Curve", 0.5f));
} }
// LFOs 1..4 // LFOs 1..4 (IDs come from the shared paramIds groups)
const char* lfoRate[4] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate }; constexpr auto& lfoRate = paramIds::lfoRate;
const char* lfoSync[4] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync }; constexpr auto& lfoSync = paramIds::lfoSync;
const char* lfoBeat[4] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat }; constexpr auto& lfoBeat = paramIds::lfoBeat;
const char* lfoShape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape }; constexpr auto& lfoShape = paramIds::lfoShape;
const char* lfoPhase[4] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase }; constexpr auto& lfoPhase = paramIds::lfoPhase;
const char* lfoFade[4] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade }; constexpr auto& lfoFade = paramIds::lfoFade;
const char* lfoDelay[4] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay }; constexpr auto& lfoDelay = paramIds::lfoDelay;
const float lfoShapeDef[4] = { 0.0f, 1.0f / 6.0f, 2.0f / 6.0f, 3.0f / 6.0f }; 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) 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)); params.push_back (f (lfoDelay[i], n + " Delay", 0.0f));
} }
// FX slots 1..8 // FX slots 1..8 (IDs come from the shared paramIds groups)
const char* fxType[8] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type, constexpr auto& fxType = paramIds::fxType;
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type }; constexpr auto& fxMix = paramIds::fxMix;
const char* fxMix[8] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix, const char* const fxP[4][8] = { { paramIds::fxP1[0], paramIds::fxP1[1], paramIds::fxP1[2], paramIds::fxP1[3],
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix }; paramIds::fxP1[4], paramIds::fxP1[5], paramIds::fxP1[6], paramIds::fxP1[7] },
const char* fxP[8][4] = { { paramIds::fxP2[0], paramIds::fxP2[1], paramIds::fxP2[2], paramIds::fxP2[3],
{ ids::fx1P1, ids::fx1P2, ids::fx1P3, ids::fx1P4 }, paramIds::fxP2[4], paramIds::fxP2[5], paramIds::fxP2[6], paramIds::fxP2[7] },
{ ids::fx2P1, ids::fx2P2, ids::fx2P3, ids::fx2P4 }, { paramIds::fxP3[0], paramIds::fxP3[1], paramIds::fxP3[2], paramIds::fxP3[3],
{ ids::fx3P1, ids::fx3P2, ids::fx3P3, ids::fx3P4 }, paramIds::fxP3[4], paramIds::fxP3[5], paramIds::fxP3[6], paramIds::fxP3[7] },
{ ids::fx4P1, ids::fx4P2, ids::fx4P3, ids::fx4P4 }, { paramIds::fxP4[0], paramIds::fxP4[1], paramIds::fxP4[2], paramIds::fxP4[3],
{ ids::fx5P1, ids::fx5P2, ids::fx5P3, ids::fx5P4 }, paramIds::fxP4[4], paramIds::fxP4[5], paramIds::fxP4[6], paramIds::fxP4[7] } };
{ ids::fx6P1, ids::fx6P2, ids::fx6P3, ids::fx6P4 },
{ ids::fx7P1, ids::fx7P2, ids::fx7P3, ids::fx7P4 },
{ ids::fx8P1, ids::fx8P2, ids::fx8P3, ids::fx8P4 }
};
for (int i = 0; i < 8; ++i) for (int i = 0; i < 8; ++i)
{ {
const juce::String n = juce::String ("FX ") + juce::String (i + 1); 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 (fxType[i], n + " Type", 0.0f));
params.push_back (f (fxMix[i], n + " Mix", 0.5f)); 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[0][i], n + " P1", 0.5f));
params.push_back (f (fxP[i][1], n + " P2", 0.5f)); params.push_back (f (fxP[1][i], n + " P2", 0.5f));
params.push_back (f (fxP[i][2], n + " P3", 0.5f)); params.push_back (f (fxP[2][i], n + " P3", 0.5f));
params.push_back (f (fxP[i][3], n + " P4", 0.5f)); params.push_back (f (fxP[3][i], n + " P4", 0.5f));
} }
// Macros // Macros (display names come from MacroControls::macroName, the single source)
params.push_back (f (ids::macro1, "Macro 1", 0.0f)); for (int i = 0; i < kNumMacros; ++i)
params.push_back (f (ids::macro2, "Macro 2", 0.0f)); params.push_back (f (paramIds::macros[i], MacroControls::macroName (i), 0.0f));
params.push_back (f (ids::macro3, "Macro 3", 0.0f));
params.push_back (f (ids::macro4, "Macro 4", 0.0f));
return { params.begin(), params.end() }; return { params.begin(), params.end() };
} }
@@ -266,6 +261,9 @@ void SerumAltAudioProcessor::loadFactoryPreset (int index)
continue; continue;
restoreLfoShapesFromState ({}); 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); currentProgram.store (index);
} }
@@ -356,6 +354,8 @@ void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBy
engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX")); engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX"));
engine.getMacros().fromValueTree (state.getChildWithName ("MACROS")); engine.getMacros().fromValueTree (state.getChildWithName ("MACROS"));
restoreLfoShapesFromState (state); 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); const double savedProgram = (double) state.getProperty ("currentProgram", 0);
currentProgram.store (std::isfinite (savedProgram) currentProgram.store (std::isfinite (savedProgram)
@@ -406,7 +406,7 @@ void SerumAltAudioProcessor::restoreLfoShapesFromState (const juce::ValueTree& s
continue; continue;
const double savedIndex = (double) lfo.getProperty ("index", -1); const double savedIndex = (double) lfo.getProperty ("index", -1);
if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumLfos if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumLfos
|| std::floor (savedIndex) != savedIndex) || ! isWholeNumber (savedIndex))
continue; continue;
const int index = (int) savedIndex; const int index = (int) savedIndex;
if (restored[(size_t) index]) if (restored[(size_t) index])
@@ -22,6 +22,8 @@ public:
void prepareToPlay (double sampleRate, int samplesPerBlock) override; void prepareToPlay (double sampleRate, int samplesPerBlock) override;
void releaseResources() override; void releaseResources() override;
void processBlock (juce::AudioBuffer<float>&, juce::MidiBuffer&) override; void processBlock (juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
// Keeps the base class double-precision overload visible, so it is not hidden.
using juce::AudioProcessor::processBlock;
juce::AudioProcessorEditor* createEditor() override; juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override { return true; } bool hasEditor() const override { return true; }
@@ -30,4 +30,4 @@ pr.params = { { "oscAUnison", 0.4f } };
pr.params = { p (ids::oscAUnison, unison (7)) }; pr.params = { p (ids::oscAUnison, unison (7)) };
``` ```
This file overrides /AGENT.md where they conflict. This file overrides /AGENTS.md where they conflict.
+2 -2
View File
@@ -1,8 +1,8 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "../Params.h" #include "Params.h"
#include "../ModulationMatrix.h" #include "ModulationMatrix.h"
namespace serum namespace serum
{ {
@@ -2,7 +2,7 @@
## Owned files ## Owned files
- Related embedded assets and helpers: `../Resources.h`, `../Resources.cpp` - Resources.h, Resources.cpp
- logo.svg - logo.svg
- background.svg - background.svg
@@ -31,4 +31,4 @@ g.setColour (juce::Colour (0xff4fc3f7));
g.setColour (theme::accent); g.setColour (theme::accent);
``` ```
This file overrides /AGENT.md where they conflict. This file overrides /AGENTS.md where they conflict.
File renamed without changes.
File renamed without changes.
@@ -1,10 +1,6 @@
# synth module # synth module
## Related source files ## Owned files
This directory contains guidance. All files listed below remain in the parent
`Source/` directory. The preparation rules below are requirements for changes,
not a claim that lazy wavetable allocation has already been fixed.
- Wavetable.h, Wavetable.cpp - Wavetable.h, Wavetable.cpp
- Oscillator.h, Oscillator.cpp - Oscillator.h, Oscillator.cpp
@@ -26,10 +22,11 @@ not a claim that lazy wavetable allocation has already been fixed.
- Use `(size_t)` casts when indexing containers with int loop variables. - Use `(size_t)` casts when indexing containers with int loop variables.
- Pass global per-block state into voices as a `const RenderContext&`. - Pass global per-block state into voices as a `const RenderContext&`.
- Never allocate memory in the audio path. - 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-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. - IF a parameter is read every block THEN snapshot it into a struct (`OscParams`, `RenderContext`) instead of reading the APVTS per sample.
## Examples ## Examples
@@ -40,8 +37,8 @@ const Wavetable& wt = library.getTable (wave);
``` ```
```cpp ```cpp
// GOOD: build once at prepare time, then only read const references // GOOD: build once in the Engine constructor, then only read const references
library.prebuild(); // in prepareToPlay library.prebuild(); // in the Engine constructor
const Wavetable& wt = library.getTable (wave); // lookup, no allocation const Wavetable& wt = library.getTable (wave); // lookup, no allocation
``` ```
@@ -55,4 +52,4 @@ const double step = 6.28318530717958647692 * freq / sr;
const double step = juce::MathConstants<double>::twoPi * 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.
@@ -41,8 +41,8 @@ void Envelope::setParams (float attackSec, float decaySec, float sustainLevel, f
releaseSamples = std::max (1.0, (double) releaseSec * sr); releaseSamples = std::max (1.0, (double) releaseSec * sr);
// curve 0 -> fast attack / long release curve; curve 1 -> slow attack / fast decay // curve 0 -> fast attack / long release curve; curve 1 -> slow attack / fast decay
attackShape = 0.3 + curve * 2.7; // 0.3 .. 3.0 attackShape = (double) attackShapeFor (curve);
decayShape = 3.0 - curve * 2.7; // 3.0 .. 0.3 decayShape = (double) decayShapeFor (curve);
shape.attack = attackSec; shape.attack = attackSec;
shape.decay = decaySec; shape.decay = decaySec;
@@ -5,6 +5,11 @@
namespace serum 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 // ADSR envelope with curve tension. Durations are in seconds; the sustain
// level is 0..1. Retriggering (noteOn while active) starts from the current // level is 0..1. Retriggering (noteOn while active) starts from the current
+22 -31
View File
@@ -1,4 +1,5 @@
#include "Filter.h" #include "Filter.h"
#include "ExactCompare.h"
namespace serum namespace serum
{ {
@@ -18,14 +19,13 @@ namespace
} }
} }
void Filter::prepare (double sampleRate, int maxBlockSize) void Filter::prepare (double sampleRate)
{ {
sr = sampleRate; sr = sampleRate;
combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay
combWrite = 0; combWrite = 0;
combDamp = 0.0; combDamp = 0.0;
reset(); reset();
(void) maxBlockSize;
} }
void Filter::reset() void Filter::reset()
@@ -38,14 +38,22 @@ void Filter::reset()
a1 = a2 = a3 = 0.0; a1 = a2 = a3 = 0.0;
stage.fill (0.0); stage.fill (0.0);
for (auto& s : formantState) s.fill (0.0); for (auto& s : formantState) s.fill (0.0);
// 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); std::fill (combLine.begin(), combLine.end(), 0.0f);
combDirty = false;
}
combWrite = 0; combWrite = 0;
combDamp = 0.0; combDamp = 0.0;
} }
void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept 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; return;
lastCutoffHz = cutoffHz; lastCutoffHz = cutoffHz;
lastRes = res; lastRes = res;
@@ -89,6 +97,8 @@ void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
updateSvf (g, k); updateSvf (g, k);
break; break;
} }
case FilterModel::Comb:
case FilterModel::Count:
default: default:
break; break;
} }
@@ -96,7 +106,8 @@ void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
void Filter::updateSvf (double g, double k) 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; return;
lastG = g; lastG = g;
lastK = k; lastK = k;
@@ -105,17 +116,6 @@ void Filter::updateSvf (double g, double k) noexcept
a3 = g * a2; 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 double Filter::svfBand (double in, double g, double k) noexcept
{ {
updateSvf (g, k); updateSvf (g, k);
@@ -127,17 +127,6 @@ double Filter::svfBand (double in, double g, double k) noexcept
return v1; 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 double Filter::ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept
{ {
stages = juce::jlimit (1, 4, stages); 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); const float feedback = clampF ((float) res * 0.9f, 0.0f, 0.98f);
combLine[(size_t) combWrite] = (float) in + (float) combDamp * feedback; combLine[(size_t) combWrite] = (float) in + (float) combDamp * feedback;
combWrite = (combWrite + 1) % len; combWrite = (combWrite + 1) % len;
combDirty = true;
return (double) y; return (double) y;
} }
@@ -204,12 +194,12 @@ double Filter::formant (double in) noexcept
for (int i = 0; i < 3; ++i) for (int i = 0; i < 3; ++i)
{ {
const auto& c = formantCoefficients[(size_t) i]; const auto& c = formantCoefficients[(size_t) i];
const double a1 = c[0]; const double c0 = c[0];
const double a2 = c[1]; const double c1 = c[1];
const double a3 = c[2]; const double c2 = c[2];
const double v3 = in - formantState[(size_t) i][1]; const double v3 = in - formantState[(size_t) i][1];
const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3; const double v1 = c0 * formantState[(size_t) i][0] + c1 * v3;
const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * 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][0] = 2.0 * v1 - formantState[(size_t) i][0];
formantState[(size_t) i][1] = 2.0 * v2 - formantState[(size_t) i][1]; formantState[(size_t) i][1] = 2.0 * v2 - formantState[(size_t) i][1];
out += v1 * gains[i]; out += v1 * gains[i];
@@ -269,6 +259,7 @@ float Filter::processSample (float in, float cutoffHz, float res, float drive, i
case FilterModel::Screamer: case FilterModel::Screamer:
out = screamer (in, drive); out = screamer (in, drive);
break; break;
case FilterModel::Count:
default: default:
break; break;
} }
+2 -3
View File
@@ -14,7 +14,7 @@ namespace serum
class Filter class Filter
{ {
public: public:
void prepare (double sampleRate, int maxBlockSize); void prepare (double sampleRate);
void reset(); void reset();
// Process a mono buffer in-place. cutoffNorm is 0..1 (mapped to 20Hz..20kHz). // Process a mono buffer in-place. cutoffNorm is 0..1 (mapped to 20Hz..20kHz).
@@ -43,6 +43,7 @@ private:
std::vector<float> combLine; std::vector<float> combLine;
int combWrite = 0; int combWrite = 0;
double combDamp = 0.0; double combDamp = 0.0;
bool combDirty = false; // set once comb() has written to combLine
// Formant: three parallel bandpass SVFs (state pairs). // 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 } } } }; 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 updateCoefficients (float cutoffHz, float res, int type) noexcept;
void updateSvf (double g, double k) 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 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 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 comb (double in, double freqHz, double res, double drive) noexcept;
double formant (double in) noexcept; double formant (double in) noexcept;
@@ -1,14 +1,15 @@
#include "FilterBank.h" #include "FilterBank.h"
#include "ExactCompare.h"
namespace serum namespace serum
{ {
void FilterBank::prepare (double sampleRate, int maxBlockSize) void FilterBank::prepare (double sampleRate)
{ {
f1L.prepare (sampleRate, maxBlockSize); f1L.prepare (sampleRate);
f1R.prepare (sampleRate, maxBlockSize); f1R.prepare (sampleRate);
f2L.prepare (sampleRate, maxBlockSize); f2L.prepare (sampleRate);
f2R.prepare (sampleRate, maxBlockSize); f2R.prepare (sampleRate);
} }
void FilterBank::reset() 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); applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
} }
// Post-filter output level. // Post-filter output level. Skip the multiply only at exact unity so the
if (p.out != 1.0f) // bypass never changes the rendered samples.
if (! exactly (p.out, 1.0f))
{ {
for (int i = 0; i < numSamples; ++i) for (int i = 0; i < numSamples; ++i)
{ {
@@ -30,7 +30,7 @@ struct FilterBankParams
class FilterBank class FilterBank
{ {
public: public:
void prepare (double sampleRate, int maxBlockSize); void prepare (double sampleRate);
void reset(); void reset();
void process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept; void process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept;
+29 -16
View File
@@ -1,4 +1,5 @@
#include "LFO.h" #include "LFO.h"
#include "ExactCompare.h"
namespace serum 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, // Only restart the delay/fade timing when those knobs actually change,
// so repeated block-rate setParams calls don't keep resetting the LFO. // 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; delayCounter = delaySeconds * sr;
fadeCounter = 0.0; 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; ? 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(); const int numSteps = juce::jlimit (2, LFO::kShapePoints, steps);
switch ((LfoShape) shape) 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: case LfoShape::Sine:
return std::sin (p * juce::MathConstants<float>::twoPi); return std::sin (phase * juce::MathConstants<float>::twoPi);
case LfoShape::Triangle: 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: case LfoShape::Saw:
return 2.0f * p - 1.0f; return 2.0f * phase - 1.0f;
case LfoShape::Square: case LfoShape::Square:
return (p < 0.5f) ? 1.0f : -1.0f; return (phase < 0.5f) ? 1.0f : -1.0f;
case LfoShape::SampleHold: case LfoShape::SampleHold:
return holdValue; return sampleHoldValue;
case LfoShape::StepSeq: case LfoShape::StepSeq:
{ return point (juce::jlimit (0, numSteps - 1, (int) (phase * (float) numSteps)));
const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps));
return shapeBuffer[(size_t) idx];
}
case LfoShape::Freehand: case LfoShape::Freehand:
{ {
const float pos = p * (float) (shapeSteps - 1); const float pos = phase * (float) (numSteps - 1);
const int i0 = (int) pos; 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; 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: default:
return 0.0f; return 0.0f;
} }
} }
float LFO::shapeValue() noexcept
{
return lfoShapeValue (getPhase(), (LfoShape) shape, shapeSteps,
shapeBuffer.data(), (int) shapeBuffer.size(), holdValue);
}
float LFO::process() noexcept float LFO::process() noexcept
{ {
return advance (1); return advance (1);
+9
View File
@@ -62,4 +62,13 @@ private:
float shapeValue() noexcept; 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 } // namespace serum
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,5 @@
#include "Oscillator.h" #include "Oscillator.h"
#include "ExactCompare.h"
namespace serum namespace serum
{ {
@@ -12,9 +13,8 @@ void Oscillator::reset()
paramsValid = false; 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); rng.setSeed (seed);
initializedUnison = 0; initializedUnison = 0;
paramsValid = false; paramsValid = false;
@@ -25,8 +25,9 @@ void Oscillator::setParams (const OscParams& p) noexcept
{ {
const int uni = juce::jlimit (1, kMaxUnison, p.unison); const int uni = juce::jlimit (1, kMaxUnison, p.unison);
const bool countChanged = ! paramsValid || uni != activeUnison; const bool countChanged = ! paramsValid || uni != activeUnison;
const bool detuneChanged = countChanged || p.detune != cachedParams.detune; const bool detuneChanged = countChanged || changed (p.detune, cachedParams.detune);
const bool panChanged = countChanged || p.pan != cachedParams.pan || p.spread != cachedParams.spread; const bool panChanged = countChanged || changed (p.pan, cachedParams.pan)
|| changed (p.spread, cachedParams.spread);
if (! detuneChanged && ! panChanged) if (! detuneChanged && ! panChanged)
return; return;
@@ -98,6 +99,7 @@ float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept
} }
case WarpMode::PWM: return phase; case WarpMode::PWM: return phase;
case WarpMode::Fold: return phase; case WarpMode::Fold: return phase;
case WarpMode::Count: return phase;
default: return phase; default: return phase;
} }
} }
@@ -114,6 +116,13 @@ float Oscillator::warpSample (float sample, const OscParams& p) const noexcept
} }
case WarpMode::Fold: case WarpMode::Fold:
return std::sin (sample * (1.0f + amt * 5.0f) * 1.5707963267948966f); 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: default:
return sample; return sample;
} }
@@ -42,7 +42,7 @@ public:
void reset(); void reset();
// Initialise a fresh note's unison sub-voices: phase offsets, detune, pan and gain. // 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; void setParams (const OscParams& p) noexcept;
// Accumulate this oscillator's contribution into outL/outR. // Accumulate this oscillator's contribution into outL/outR.
@@ -3,12 +3,8 @@
namespace serum 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; phase = 0.0;
} }
@@ -18,14 +14,14 @@ void SubOscillator::processAdd (double freqHz, int shape, float level, float& ou
return; return;
// The octave shift is baked into freqHz by the voice; here we just phase-accumulate. // 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 += 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; float s = 0.0f;
if (shape == (int) SubShape::Triangle) 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); s = 1.0f - 4.0f * std::abs (p - 0.5f);
} }
else else
@@ -15,7 +15,7 @@ public:
void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; } void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; }
void reset() { 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). // Accumulate into out (mono; the caller pans/levels it).
void processAdd (double freqHz, int shape, float level, float& out) noexcept; void processAdd (double freqHz, int shape, float level, float& out) noexcept;
@@ -23,7 +23,6 @@ public:
private: private:
double sr = 44100.0; double sr = 44100.0;
double phase = 0.0; double phase = 0.0;
static constexpr double kTwoPi = 6.28318530717958647692;
}; };
} // namespace serum } // namespace serum
@@ -17,7 +17,7 @@ void SynthVoice::prepare (double sampleRate, int maxBlockSize)
oscB.prepare (sampleRate); oscB.prepare (sampleRate);
sub.prepare (sampleRate); sub.prepare (sampleRate);
noise.prepare (sampleRate); noise.prepare (sampleRate);
filters.prepare (sampleRate, maxBlockSize); filters.prepare (sampleRate);
for (auto& e : env) for (auto& e : env)
e.prepare (sampleRate); e.prepare (sampleRate);
scratch.setSize (2, maxBlockSize, false, false, true); 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); juce::Random rng (noteSeed);
noteRandom = rng.nextFloat(); noteRandom = rng.nextFloat();
sub.noteOn (freqHz, -1); sub.noteOn();
noise.noteOn (noteSeed); noise.noteOn (noteSeed);
for (auto& e : env) for (auto& e : env)
@@ -71,8 +71,20 @@ void SynthVoice::noteOff()
e.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 void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept
{ {
jassert (numSamples <= scratch.getNumSamples());
if (! active || numSamples <= 0) if (! active || numSamples <= 0)
return; return;
@@ -118,14 +130,14 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC
float v = src[(int) c.source]; float v = src[(int) c.source];
if (c.bipolar && ! isBipolarSource (c.source)) if (c.bipolar && ! isBipolarSource (c.source))
v = v * 2.0f - 1.0f; 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) if (ctx.macros != nullptr)
{ {
for (int m = 0; m < kNumMacros; ++m) for (int m = 0; m < kNumMacros; ++m)
for (const auto& a : ctx.macros->assignments[(size_t) 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. // 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. // without resetting existing phases when modulated at control rate.
if (oscillatorsNeedNoteOn) if (oscillatorsNeedNoteOn)
{ {
oscA.noteOn (freqA, a, seed); oscA.noteOn (a, seed);
oscB.noteOn (freqB, b, seed + 1); oscB.noteOn (b, seed + 1);
oscillatorsNeedNoteOn = false; oscillatorsNeedNoteOn = false;
} }
else else
@@ -72,6 +72,7 @@ public:
bool isReleased() const noexcept { return released; } bool isReleased() const noexcept { return released; }
int getNote() const noexcept { return note; } int getNote() const noexcept { return note; }
juce::uint64 getNoteId() const noexcept { return noteId; } juce::uint64 getNoteId() const noexcept { return noteId; }
float getModulationValue (ModSource source) const noexcept;
private: private:
Oscillator oscA, oscB; Oscillator oscA, oscB;
@@ -18,7 +18,6 @@ void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int n
frames.assign (kFrames, std::vector<float> (kTableSize, 0.0f)); frames.assign (kFrames, std::vector<float> (kTableSize, 0.0f));
const float invN = 1.0f / (float) kTableSize; const float invN = 1.0f / (float) kTableSize;
constexpr double twoPi = 6.28318530717958647692;
for (int f = 0; f < kFrames; ++f) 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 float a = ampFn (f, h);
const double m = std::abs ((double) a); const double m = std::abs ((double) a);
mag[(size_t) h] = m; mag[(size_t) h] = m;
phase[(size_t) h] = (a < 0.0f) ? twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base 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 = twoPi * (double) h * (double) invN; const double ang = juce::MathConstants<double>::twoPi * (double) h * (double) invN;
cosStep[(size_t) h] = std::cos (ang); cosStep[(size_t) h] = std::cos (ang);
sinStep[(size_t) h] = std::sin (ang); sinStep[(size_t) h] = std::sin (ang);
maxAmp = std::max (maxAmp, m); maxAmp = std::max (maxAmp, m);
@@ -153,7 +152,6 @@ void WavetableLibrary::buildTable (int index) const
namespace namespace
{ {
inline float normT (int frame) { return (float) frame / 255.0f; } inline float normT (int frame) { return (float) frame / 255.0f; }
constexpr float kPi = 3.14159265358979323846f;
} }
// sine -> saw morph // sine -> saw morph
@@ -178,8 +176,8 @@ Wavetable WavetableLibrary::makeSawPwm()
{ {
const float t = normT (f); const float t = normT (f);
const float duty = 0.08f + 0.42f * t; // 8% -> 50% const float duty = 0.08f + 0.42f * t; // 8% -> 50%
const float a = std::sin (kPi * (float) h * duty); const float a = std::sin (juce::MathConstants<float>::pi * (float) h * duty);
return (2.0f / ((float) h * kPi)) * a; return (2.0f / ((float) h * juce::MathConstants<float>::pi)) * a;
}, 48); }, 48);
return wt; return wt;
} }
@@ -192,7 +190,7 @@ Wavetable WavetableLibrary::makeSquareSync()
{ {
const float t = normT (f); const float t = normT (f);
const float ratio = 1.0f + 3.0f * t; 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; return (1.0f / std::pow ((float) h, 0.8f)) * comb;
}, 48); }, 48);
return wt; return wt;
@@ -211,7 +209,7 @@ Wavetable WavetableLibrary::makeTriangleFold()
{ {
const int k = (h - 1) / 2; const int k = (h - 1) / 2;
const float sign = ((k & 1) == 0) ? 1.0f : -1.0f; 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; const float saw = 1.0f / (float) h;
return tri + (saw - tri) * t; return tri + (saw - tri) * t;
@@ -235,15 +233,11 @@ Wavetable WavetableLibrary::makeVowel()
float from[3], to[3]; float from[3], to[3];
if (t < 0.5f) 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]; } for (int k = 0; k < 3; ++k) { from[k] = aa[k]; to[k] = ee[k]; }
(void) u;
} }
else else
{ {
const float u = (t - 0.5f) * 2.0f;
for (int k = 0; k < 3; ++k) { from[k] = ee[k]; to[k] = ii[k]; } for (int k = 0; k < 3; ++k) { from[k] = ee[k]; to[k] = ii[k]; }
(void) u;
} }
float tt = (t < 0.5f) ? t * 2.0f : (t - 0.5f) * 2.0f; float tt = (t < 0.5f) ? t * 2.0f : (t - 0.5f) * 2.0f;
File renamed without changes.
@@ -16,7 +16,7 @@
## IF-THEN ## IF-THEN
- IF a check fails THEN increment the failure count and print the preset name. - IF a check fails THEN increment the failure count and print the preset name.
- Build and run natively from the repository root with `./build_harness.sh --run`. Building the `SerumAltTest` target alone does not run checks. - Build and run natively from the repository root with `./scripts/build_harness.sh --run`. Building the `SerumAltTest` target alone does not run checks.
- The harness-only configuration sets `SERUMALT_BUILD_PLUGIN=OFF` and `SERUMALT_BUILD_TESTS=ON`. Production scripts disable the harness. - The harness-only configuration sets `SERUMALT_BUILD_PLUGIN=OFF` and `SERUMALT_BUILD_TESTS=ON`. Production scripts disable the harness.
- The harness links the real processor and editor sources but creates no plugin bundles. Keep that build separation when adding checks. - The harness links the real processor and editor sources but creates no plugin bundles. Keep that build separation when adding checks.
@@ -37,4 +37,4 @@ if (! ok) ++failures;
return failures == 0 ? 0 : 1; return failures == 0 ? 0 : 1;
``` ```
This file overrides /AGENT.md where they conflict. This file overrides /AGENTS.md where they conflict.
+122 -52
View File
@@ -1,7 +1,7 @@
#include <JuceHeader.h> #include <JuceHeader.h>
#include <cmath> #include <cmath>
#include <iostream> #include <iostream>
#include "../PluginProcessor.h" #include "PluginProcessor.h"
// =========================================================================== // ===========================================================================
// Headless QA harness: instantiates the processor, plays notes through every // Headless QA harness: instantiates the processor, plays notes through every
@@ -9,6 +9,8 @@
// =========================================================================== // ===========================================================================
namespace namespace
{ {
const int notes[3] = { 48, 60, 67 };
bool hasNaN (const juce::AudioBuffer<float>& b) bool hasNaN (const juce::AudioBuffer<float>& b)
{ {
for (int ch = 0; ch < b.getNumChannels(); ++ch) for (int ch = 0; ch < b.getNumChannels(); ++ch)
@@ -32,6 +34,36 @@ namespace
} }
return p; 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() int main()
@@ -44,64 +76,18 @@ int main()
const int numPresets = processor.getNumPrograms(); const int numPresets = processor.getNumPrograms();
int failures = 0; int failures = 0;
const int notes[3] = { 48, 60, 67 };
// Default (init) preset. // Default (init) preset.
{ if (! runPreset (processor, "Init", 0.001))
juce::MidiBuffer midi; ++failures;
for (int n : notes)
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
juce::AudioBuffer<float> buffer (2, 512);
float maxPeak = 0.0f;
bool nan = false;
for (int block = 0; block < 240; ++block)
{
buffer.clear();
processor.processBlock (buffer, midi);
midi.clear();
if (block == 180)
for (int n : notes)
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
nan = nan || hasNaN (buffer);
maxPeak = std::max (maxPeak, peak (buffer));
}
const bool ok = ! nan && maxPeak > 0.001f;
if (! ok) ++failures;
std::cout << (ok ? "PASS" : "FAIL") << " [Init] peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
}
// Every factory preset. // Every factory preset.
for (int preset = 0; preset < numPresets; ++preset) for (int preset = 0; preset < numPresets; ++preset)
{ {
processor.setCurrentProgram (preset); processor.setCurrentProgram (preset);
const juce::String name = processor.getProgramName (preset);
juce::MidiBuffer midi; if (! runPreset (processor, name, 0.001))
for (int n : notes) ++failures;
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
juce::AudioBuffer<float> buffer (2, 512);
float maxPeak = 0.0f;
bool nan = false;
for (int block = 0; block < 240; ++block)
{
buffer.clear();
processor.processBlock (buffer, midi);
midi.clear();
if (block == 180)
for (int n : notes)
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
nan = nan || hasNaN (buffer);
maxPeak = std::max (maxPeak, peak (buffer));
}
const bool ok = ! nan && maxPeak > 0.001f;
if (! ok) ++failures;
std::cout << (ok ? "PASS" : "FAIL") << " [" << processor.getProgramName (preset) << "]"
<< " peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
} }
// RAVE toggle + parameter churn while playing (no crash / NaN). // 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"; 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") std::cout << "\n" << (failures == 0 ? "ALL QA CHECKS PASSED" : "QA FAILURES DETECTED")
<< " (" << failures << " failures)\n"; << " (" << failures << " failures)\n";
return failures == 0 ? 0 : 1; return failures == 0 ? 0 : 1;
-48
View File
@@ -1,48 +0,0 @@
# engine module
## Related source files
This directory contains guidance; the implementation remains in `../Engine.h`
and `../Engine.cpp`. The preparation rules below are requirements for changes;
current buffer resizing and lazy wavetable issues are recorded in the root AGENT.md.
## Rules
- Own the voice pool, LFOs, wavetable library, FX rack, matrix and macros by value.
- Read every APVTS value through the null-guarding `v()` helper.
- Build the `RenderContext` once per block and pass it to voices as `const&`.
- Size `mixBuffer` once in `prepare()` and only `clear()` it per block.
- Prebuild wavetables in `prepare()` before the first render.
- Clamp the final output with the soft limiter.
- Never call `setSize` on any buffer inside `processBlock`.
## IF-THEN
- IF a per-block buffer is needed THEN allocate it in `prepare()` and clear it per block.
- IF a module needs the matrix, macros or wavetables THEN go through Engine accessors rather than reaching into another module.
## Examples
```cpp
// BAD: per-callback buffer reconfiguration on the audio thread
void processBlock (...)
{
mixBuffer.setSize (2, n, false, false, true);
...
}
```
```cpp
// GOOD: size once, clear per block
void prepare (double sr, int blockSize)
{
mixBuffer.setSize (2, blockSize, false, false, true);
}
void processBlock (...)
{
mixBuffer.clear();
...
}
```
This file overrides /AGENT.md where they conflict.
-34
View File
@@ -1,34 +0,0 @@
# modulation module
## Related source files
This directory contains guidance; implementations remain in the parent `Source/`
directory:
- `../ModulationMatrix.h`, `../ModulationMatrix.cpp`
- `../MacroControls.h`, `../MacroControls.cpp`
- `../RAVEButton.h`, `../RAVEButton.cpp`, which define `RaveController`
## Rules
- Use `bool` return codes for bounded inserts (`addConnection`, `addAssignment`).
- Require a cap on connection and assignment counts (`kMaxConnections`, `kMaxAssignments`).
- Serialise and restore state through `juce::ValueTree` so presets survive save and load.
- Never accept a `ModTarget::None` connection; reject it and return false.
- Use `ids::` for every APVTS parameter reference in RaveController.
- Keep the existing RAVEButton filenames unless a rename is explicitly in scope.
## GUI/audio state
The current public connection and assignment vectors are shared mutable state.
GUI writes can race with audio-thread iteration. A mutex taken only by the writer
does not protect an unlocked reader, and taking a blocking mutex in the audio
callback conflicts with the real-time requirements.
When fixing this path, publish a stable snapshot or use a bounded thread-safe
handoff. Keep allocation and snapshot reclamation off the audio thread. Validate
reader and writer lifetimes together, including preset and state restore.
If `addConnection` or `addAssignment` returns false, handle the failed insert.
This file refines the repository-root AGENT.md for the related sources.
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# build_windows.sh — cross-compile SerumAlt VST3 for Windows using MinGW-w64
# ---------------------------------------------------------------------------
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
TOOLCHAIN_FILE="$SCRIPT_DIR/mingw64-toolchain.cmake"
BUILD_DIR="${BUILD_DIR:-$SCRIPT_DIR/build_windows}"
BUILD_TYPE="${BUILD_TYPE:-Release}"
OUTPUT_DIR="${OUTPUT_DIR:-$SCRIPT_DIR/SerumAlt_Windows}"
PLUGIN_NAME="SerumAlt"
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
if [[ "$(uname -s)" != Linux ]]; then
echo "ERROR: build_windows.sh cross-compiles from Linux using MinGW-w64." >&2
exit 1
fi
# 1. Check for the host tools and MinGW-w64 cross-compiler.
for tool in cmake ninja pkg-config "${CC:-cc}" "${CXX:-c++}" \
x86_64-w64-mingw32-gcc x86_64-w64-mingw32-g++ \
x86_64-w64-mingw32-windres x86_64-w64-mingw32-ar \
x86_64-w64-mingw32-ranlib x86_64-w64-mingw32-strip; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: $tool not found. See README.md prerequisites." >&2
exit 1
fi
done
echo "Found: $(command -v x86_64-w64-mingw32-g++)"
# 2. Use the checked-in CMake toolchain file without overwriting it.
if [[ ! -f "$TOOLCHAIN_FILE" ]]; then
echo "ERROR: Toolchain file not found: $TOOLCHAIN_FILE" >&2
exit 1
fi
echo "Using toolchain file: $TOOLCHAIN_FILE"
# 3. Configure with CMake + Ninja (VST3-only cross build, no test harness).
echo "Configuring Windows VST3 in $BUILD_DIR ($BUILD_TYPE)..."
cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -G Ninja \
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_FILE" \
-DSERUMALT_BUILD_PLUGIN=ON \
-DSERUMALT_BUILD_TESTS=OFF
# 4. Build the VST3 target.
echo "Building Windows VST3..."
cmake --build "$BUILD_DIR" --config "$BUILD_TYPE" --target SerumAlt_VST3
# 5. Inject the VST3 moduleinfo.json.
# The automatic manifest step is disabled when cross-compiling (see
# cmake/Plugin.cmake), because JUCE's manifest helper is a host tool that would
# otherwise be built as a Windows executable. The moduleinfo content is
# deterministic for this Windows plugin: it depends on the plugin's
# name, manufacturer/plugin codes, and version, so we write the same metadata
# that the native Windows helper generates. Keep it in sync with the metadata
# in CMakeLists.txt and cmake/Plugin.cmake, including the vendored VST3 SDK version.
VST3_BUNDLE="$BUILD_DIR/${PLUGIN_NAME}_artefacts/$BUILD_TYPE/VST3/$PLUGIN_NAME.vst3"
echo "Writing Windows VST3 manifest..."
mkdir -p "$VST3_BUNDLE/Contents/Resources"
cat > "$VST3_BUNDLE/Contents/Resources/moduleinfo.json" <<'EOF'
{
"Name": "SerumAlt",
"Version": "1.0.0",
"Factory Info": {
"Vendor": "SerumAlt Audio",
"URL": "",
"E-Mail": "",
"Flags": {
"Unicode": true,
"Classes Discardable": false,
"Component Non Discardable": false
}
},
"Classes": [
{
"CID": "ABCDEF019182FAEB53616C7453657261",
"Category": "Audio Module Class",
"Name": "SerumAlt",
"Vendor": "SerumAlt Audio",
"Version": "1.0.0",
"SDKVersion": "VST 3.7.8",
"Sub Categories": [
"Instrument",
"Synth"
],
"Class Flags": 2,
"Cardinality": 2147483647,
"Snapshots": []
},
{
"CID": "ABCDEF011234ABCD53616C7453657261",
"Category": "Component Controller Class",
"Name": "SerumAlt",
"Vendor": "SerumAlt Audio",
"Version": "1.0.0",
"SDKVersion": "VST 3.7.8",
"Sub Categories": [
"Instrument",
"Synth"
],
"Class Flags": 2,
"Cardinality": 2147483647,
"Snapshots": []
},
{
"CID": "ABCDEF01C0DEF00D53616C7453657261",
"Category": "Plugin Compatibility Class",
"Name": "SerumAlt",
"Vendor": "SerumAlt Audio",
"Version": "1.0.0",
"SDKVersion": "VST 3.7.8",
"Class Flags": 0,
"Cardinality": 2147483647,
"Snapshots": []
}
]
}
EOF
# 6. Copy the resulting .vst3 bundle to SerumAlt_Windows/ without deleting files.
echo "Staging Windows VST3 in $OUTPUT_DIR..."
cmake -E copy_directory "$VST3_BUNDLE" "$OUTPUT_DIR/$PLUGIN_NAME.vst3"
echo
echo "Done. Windows VST3 plugin at: $OUTPUT_DIR/$PLUGIN_NAME.vst3"
+1 -3
View File
@@ -1,6 +1,4 @@
# --------------------------------------------------------------------------- # Headless QA harness: a console app that drives the processor and checks output.
# Headless QA harness (console app that drives the processor and checks output)
# ---------------------------------------------------------------------------
juce_add_console_app(SerumAltTest) juce_add_console_app(SerumAltTest)
serumalt_configure_target(SerumAltTest) serumalt_configure_target(SerumAltTest)
target_sources(SerumAltTest PRIVATE Source/Tests/TestMain.cpp) target_sources(SerumAltTest PRIVATE Source/Tests/TestMain.cpp)
+32 -19
View File
@@ -1,14 +1,32 @@
# --------------------------------------------------------------------------- # Plugin target.
# Plugin target
# --------------------------------------------------------------------------- # Plugin metadata. It lives here once because juce_add_plugin and the cross-build
# JUCE 7.0.12 builds its VST3 manifest helper (juce_vst3_helper) with the same # VST3 manifest in cmake/moduleinfo.json.in both need the same values.
# toolchain as the plugin itself. When cross-compiling, that produces a Windows set(SERUMALT_COMPANY_NAME "SerumAlt Audio")
# .exe which cannot run on the Linux build host, so we disable the automatic set(SERUMALT_MANUFACTURER_CODE Salt)
# manifest step and inject a Windows moduleinfo.json afterwards set(SERUMALT_PLUGIN_CODE Sera)
# (see build_windows.sh). Native builds keep the automatic step. set(SERUMALT_VST3_SDK_VERSION "VST 3.7.8") # ships with the vendored JUCE 7.0.12
# VST3 class IDs. Each is a fixed prefix, a per-class constant and the
# manufacturer and plugin codes as ASCII hex, built the same way JUCE builds
# them in juce_audio_plugin_client_VST3.cpp.
string(HEX "${SERUMALT_MANUFACTURER_CODE}${SERUMALT_PLUGIN_CODE}" serumaltCodeHex)
string(TOUPPER "${serumaltCodeHex}" serumaltCodeHex)
set(SERUMALT_CID_AUDIO_MODULE "ABCDEF019182FAEB${serumaltCodeHex}")
set(SERUMALT_CID_CONTROLLER "ABCDEF011234ABCD${serumaltCodeHex}")
set(SERUMALT_CID_COMPATIBILITY "ABCDEF01C0DEF00D${serumaltCodeHex}")
# JUCE builds its VST3 manifest helper with the target toolchain, so during a
# Windows cross-build that helper is a Windows .exe and cannot run on the Linux
# host. Disable the automatic step and write the manifest from the template
# instead. Native builds keep JUCE's automatic step.
set(SERUMALT_VST3_AUTO_MANIFEST TRUE) set(SERUMALT_VST3_AUTO_MANIFEST TRUE)
if(CMAKE_CROSSCOMPILING) if(CMAKE_CROSSCOMPILING)
set(SERUMALT_VST3_AUTO_MANIFEST FALSE) set(SERUMALT_VST3_AUTO_MANIFEST FALSE)
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/moduleinfo.json.in"
"${CMAKE_BINARY_DIR}/moduleinfo.json"
@ONLY)
endif() endif()
set(SERUMALT_FORMATS VST3) set(SERUMALT_FORMATS VST3)
@@ -21,14 +39,14 @@ endif()
juce_add_plugin(SerumAlt juce_add_plugin(SerumAlt
VERSION ${PROJECT_VERSION} VERSION ${PROJECT_VERSION}
COMPANY_NAME "SerumAlt Audio" COMPANY_NAME "${SERUMALT_COMPANY_NAME}"
IS_SYNTH TRUE IS_SYNTH TRUE
NEEDS_MIDI_INPUT TRUE NEEDS_MIDI_INPUT TRUE
NEEDS_MIDI_OUTPUT FALSE NEEDS_MIDI_OUTPUT FALSE
IS_MIDI_EFFECT FALSE IS_MIDI_EFFECT FALSE
EDITOR_WANTS_KEYBOARD_FOCUS FALSE EDITOR_WANTS_KEYBOARD_FOCUS FALSE
PLUGIN_MANUFACTURER_CODE Salt PLUGIN_MANUFACTURER_CODE ${SERUMALT_MANUFACTURER_CODE}
PLUGIN_CODE Sera PLUGIN_CODE ${SERUMALT_PLUGIN_CODE}
BUNDLE_ID com.serumalt.SerumAlt BUNDLE_ID com.serumalt.SerumAlt
FORMATS ${SERUMALT_FORMATS} FORMATS ${SERUMALT_FORMATS}
PRODUCT_NAME "SerumAlt" PRODUCT_NAME "SerumAlt"
@@ -36,14 +54,9 @@ juce_add_plugin(SerumAlt
serumalt_configure_target(SerumAlt) serumalt_configure_target(SerumAlt)
# --------------------------------------------------------------------------- # A MinGW cross-build must link libgcc, libstdc++ and winpthread statically.
# MinGW cross-build: static C++/threading runtime # A stock Windows host has none of those DLLs, so a dynamic link produces a
# --------------------------------------------------------------------------- # module that fails to load and disappears from the host's plugin list.
# When cross-compiling for Windows with MinGW, link libgcc / libstdc++ /
# winpthread statically so the VST3 DLL is self-contained. Otherwise the DLL
# imports libgcc_s_seh-1.dll, libstdc++-6.dll and libwinpthread-1.dll, none of
# which ship with the bundle (or exist on a stock Windows host), so the host's
# scan would fail to load the plugin. Native (Linux) builds are unaffected.
if(MINGW) if(MINGW)
target_link_options(SerumAlt_VST3 PRIVATE target_link_options(SerumAlt_VST3 PRIVATE
-static-libgcc -static-libgcc
File renamed without changes.
+57
View File
@@ -0,0 +1,57 @@
{
"Name": "@PROJECT_NAME@",
"Version": "@PROJECT_VERSION@",
"Factory Info": {
"Vendor": "@SERUMALT_COMPANY_NAME@",
"URL": "",
"E-Mail": "",
"Flags": {
"Unicode": true,
"Classes Discardable": false,
"Component Non Discardable": false
}
},
"Classes": [
{
"CID": "@SERUMALT_CID_AUDIO_MODULE@",
"Category": "Audio Module Class",
"Name": "@PROJECT_NAME@",
"Vendor": "@SERUMALT_COMPANY_NAME@",
"Version": "@PROJECT_VERSION@",
"SDKVersion": "@SERUMALT_VST3_SDK_VERSION@",
"Sub Categories": [
"Instrument",
"Synth"
],
"Class Flags": 2,
"Cardinality": 2147483647,
"Snapshots": []
},
{
"CID": "@SERUMALT_CID_CONTROLLER@",
"Category": "Component Controller Class",
"Name": "@PROJECT_NAME@",
"Vendor": "@SERUMALT_COMPANY_NAME@",
"Version": "@PROJECT_VERSION@",
"SDKVersion": "@SERUMALT_VST3_SDK_VERSION@",
"Sub Categories": [
"Instrument",
"Synth"
],
"Class Flags": 2,
"Cardinality": 2147483647,
"Snapshots": []
},
{
"CID": "@SERUMALT_CID_COMPATIBILITY@",
"Category": "Plugin Compatibility Class",
"Name": "@PROJECT_NAME@",
"Vendor": "@SERUMALT_COMPANY_NAME@",
"Version": "@PROJECT_VERSION@",
"SDKVersion": "@SERUMALT_VST3_SDK_VERSION@",
"Class Flags": 0,
"Cardinality": 2147483647,
"Snapshots": []
}
]
}
+9 -9
View File
@@ -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/` 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 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 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 script does not do). A combined "clean build + run tests" command does not exist.
Windows script disables tests, and the README test instructions assume an already-built The Windows script disables tests, and the README test instructions assume an
native `build/` tree. 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 - `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 `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`, - `Wavetable`, `Oscillator`, `SubOscillator`, `NoiseOscillator`, `Envelope`, `LFO`,
`Filter`, `FilterBank`, `SynthVoice` depend only on `Params`, `JuceHeader`, and each `Filter`, `FilterBank`, `SynthVoice` depend only on `Params`, `JuceHeader`, and each
other (`Oscillator.h:4-5`, `FilterBank.h:4-5`, `SynthVoice.h:4-12`). 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: 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`). (`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)` - 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 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 itself. The only reporting is the test harness printing to `std::cout` and returning an
exit code (`TestMain.cpp:73,103,129,132-134`). 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 guards or default values, and bounded-insertion failures use `bool` return codes that
callers currently ignore. callers currently ignore.
@@ -317,9 +317,9 @@ thread:
thread reads the same `shapeBuffer` in `LFO::shapeValue` (`LFO.cpp:82-91`). 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 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 comment "Public DSP state (read/write from the GUI thread)" at `PluginProcessor.h:55` only
describes intent. describes intent.
@@ -372,7 +372,7 @@ describes intent.
## 7. Anti-pattern catalog ## 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. should encode.
1. Parameter ID tables duplicated three times. 1. Parameter ID tables duplicated three times.
+204
View File
@@ -0,0 +1,204 @@
# Janitor round 2: structure, build separation, scripts
Executed on branch `main` from the round 1 checkpoint. Round 1 had left all of its
work uncommitted and `HEAD` did not compile without the `FXProcessor` repair, so
this round begins by committing that work and then lands two phases of its own.
Round 1's own task list is archived at `docs/archive/JANITOR_TASKS_2026-05.md`.
The user confirmed the scope before any file changed. The full set of decisions is
in the commit messages; this file records what changed, the evidence, and what was
left alone.
## Commits
| Commit | Contents |
| --- | --- |
| `e4cea30` | Round 1 checkpoint: the uncommitted `FXProcessor` repair, `ExactCompare.h`, `KnobLookAndFeel`, the `AGENT.md` to `AGENTS.md` renames, and graft integration. |
| `facd5c5` | Phase 1: `scripts/`, the toolchain file in `cmake/`, build output under `build/`, `clean.sh`, `docs/`, the Windows manifest generated from CMake, and three code fixes. |
| `4b0db50` | Phase 2: 36 source files into their module directories, and five directories renamed. |
This file and the refreshed graft graph land in a fourth commit.
## Phase 1: repository root, build output, scripts
### Layout
- `scripts/` holds `build_linux.sh`, `build_macos.sh`, `build_windows.sh`,
`build_harness.sh` and `clean.sh`. Each resolves the repository root from its own
location, so the documented `./scripts/build_harness.sh --run` works from any
directory.
- `cmake/` holds `mingw64-toolchain.cmake` beside `Plugin.cmake` and `Harness.cmake`.
- `docs/` holds `AUDIT_REPORT.md` and `UI_UX_AUDIT.md`; `docs/archive/` holds the
finished round 1 list.
- The repository root now holds `CMakeLists.txt`, `README.md`, `AGENTS.md`,
`.gitignore`, the tool configuration files, and four directories: `Source/`,
`cmake/`, `scripts/`, `docs/`.
### Build output
Each script defaults to a directory under `build/`: `build/linux`, `build/macos`,
`build/windows` and `build/harness`. `BUILD_DIR` still overrides any of them, and a
relative override resolves from the repository root. `SerumAlt_Windows/` remains the
staging directory for the shippable Windows bundle, and `OUTPUT_DIR` still overrides
it.
`scripts/clean.sh` removes generated output:
| Invocation | Removes |
| --- | --- |
| `./scripts/clean.sh` | the trees under `build/` |
| `--staging` | adds `SerumAlt_Windows/` |
| `--legacy` | adds `build_*` and `cmake-build-*` left in the root by earlier script versions |
| `--all` | both of the above |
| `--dry-run` | nothing; prints each path with its size |
The script refuses a path outside the repository, a path under `Source/`,
`third_party/`, `.git`, `scripts/`, `cmake/`, `docs/` or `graft/`, a path tracked by
git, and refuses to run at all when `BUILD_DIR` or `OUTPUT_DIR` resolves outside the
repository. Exit status 2 marks a refusal.
902 MB of stale trees were removed after the new layout built successfully:
`build/`'s original in-place tree, `build_harness`, `build_windows`,
`build_windows_fresh` and the three `build_verify_*` trees. `build_linux/` and
`SerumAlt_Windows/` were left in place; `./scripts/clean.sh --legacy --staging`
removes them when you no longer need the artifacts in them.
### Windows manifest
`build_windows.sh` used to carry a 60-line `moduleinfo.json` heredoc with the
version, vendor and class IDs written out by hand. The version also lives in
`project(SerumAlt VERSION 1.0.0)`, so a version bump would have shipped a manifest
that disagreed with the binary.
`cmake/Plugin.cmake` now defines the plugin metadata once, passes it to
`juce_add_plugin`, and configures `cmake/moduleinfo.json.in` into the build
directory. The three VST3 class IDs are built from `PLUGIN_MANUFACTURER_CODE` and
`PLUGIN_CODE` the same way JUCE builds them, so they cannot drift from the codes
either. The script copies the configured file into the bundle and fails when it is
missing.
### Code fixes
- `Source/PluginProcessor.h` gained `using juce::AudioProcessor::processBlock;`. The
float override hid the base class double overload, which produced three warning
lines from three translation units.
- `Source/GUI/Slider.{h,cpp}` were deleted. Nothing constructed `serum::Slider`; it
was reachable only through one include in `PluginEditor.h`. `SERUMALT_INCLUDE_DIRS`
and `GUI/AGENTS.md` were updated to match, and `KnobLookAndFeel` was added to the
owned-files list, where it had been missing.
- Prose in the five shell scripts, the two `.claude/helpers/*.cjs` files and the
CMake files was tightened. The scripts had no em dashes and no banned vocabulary
left from round 1, so what changed was structure: numbered step headings, a
paragraph explaining what a load failure would look like, and the manifest
heredoc, all gone.
### Unslop scope
The five shell scripts, `.claude/helpers/graft-hooks.cjs`,
`.claude/helpers/graft-statusline.cjs`, `CMakeLists.txt`, `cmake/Plugin.cmake` and
`cmake/Harness.cmake`. `README.md` and `AGENTS.md` keep their prose and received
path corrections only, so the layout changes stay reviewable.
## Phase 2: source modules
The five directories `engine/`, `modulation/`, `params/`, `plugin/` and `synth/`
held nothing but guidance pointing at root-level implementations. Every module doc
already claimed its files, so the move only had to honour those claims.
| Module | Files moved in |
| --- | --- |
| `Engine/` | `Engine.{h,cpp}` |
| `Synth/` | `Wavetable`, `Oscillator`, `SubOscillator`, `NoiseOscillator`, `Envelope`, `LFO`, `Filter`, `FilterBank`, `SynthVoice` (18 files) |
| `Modulation/` | `ModulationMatrix`, `MacroControls`, `RAVEButton` (6 files) |
| `Params/` | `Params.{h,cpp}` |
| `Plugin/` | `PluginProcessor.{h,cpp}` |
| `GUI/` | `PluginEditor.{h,cpp}` |
| `EffectUnits/` | `FXProcessor.{h,cpp}` |
| `Resources/` | `Resources.{h,cpp}` |
36 files moved, not the 31 stated when the plan was agreed. The five directories
were renamed to `Engine/`, `Modulation/`, `Params/`, `Plugin/` and `Synth/` so that
every subdirectory of `Source/` uses one casing rule.
`Source/ExactCompare.h` stays at the `Source/` root: five modules include it, and a
`Common/` directory for one header would cost more than it explains.
`SERUMALT_INCLUDE_DIRS` now lists `Source/` and one directory per module, which let
all 31 parent-relative includes become plain names such as `Params.h`. No header
basename is duplicated in the tree, so that resolution cannot pick the wrong file.
The module docs list owned files instead of describing a move that had not happened.
`README.md` and the `Current source layout` section of `AGENTS.md` describe the tree
that exists.
## Verification
Every command below was run after the phase it verifies. Build directories are under
`build/`, which is git-ignored.
- `for s in scripts/*.sh; do bash -n "$s"; done` passes for all five scripts.
- `./scripts/build_harness.sh --run` reaches `100% tests passed out of 1` from a
clean directory, in both phases.
- `./scripts/build_linux.sh` links `SerumAlt.vst3` and the `SerumAlt` Standalone.
- `./scripts/build_windows.sh` links the cross-compiled VST3 and passes its own
checks: the module is x86-64, imports none of `libgcc_s_seh-1.dll`,
`libstdc++-6.dll` or `libwinpthread-1.dll`, and exports `GetPluginFactory`,
`InitDll` and `ExitDll`.
- The generated manifest is identical to the previously hand-written one. Compared
with `python3 -m json.tool` on both, after taking the old one from
`git show HEAD:build_windows.sh`.
- Target hygiene, read from `cmake --build <dir> --target help`: the harness
configure offers `SerumAltTest` and no plugin target; the Linux and Windows
configures offer `SerumAlt_VST3` and no `SerumAltTest`.
- First-party warning lines: 3 before the `processBlock` fix, 0 after. The four
warning lines that remain in a full run all name files under `third_party/JUCE`
(a `-Wpedantic` note about `::main` in the Standalone, two MinGW `#warning` lines
in `juce_graphics`, and a deprecated `tzname` in `juce_Time`).
- A full build leaves the source tree unchanged: `git status --porcelain -- Source
third_party/JUCE` before and after a three-target build is identical, so no build
product lands in the source tree.
- `./scripts/clean.sh --dry-run --all` prints each path with its size and removes
nothing. Run before the stale trees were deleted it listed nine paths; it now
lists the three that remain: `build/`, `SerumAlt_Windows/` and `build_linux/`.
`--staging`, `--legacy` and the outside-repository refusal were each run.
- `graft build` was re-run after the moves. The graph now mirrors the new paths, for
example `graft/Source/Engine/Engine.md`.
## Kept, and why
- `MacroControls::removeAssignment` is still defined and never called. No control in
the Macro tab would call it, so deleting it would remove the only path to a
feature rather than clean up dead code.
- `Source/GUI/PluginEditor.cpp` is still 1147 lines. It is the largest remaining
readability problem in the tree, and it is a GUI refactor with no headless way to
check the pixels.
- The two `.claude/helpers/*.cjs` files remain 66-line copies that differ in one
line. Each is a self-contained hook that `graft init` can regenerate; merging their
shared body into a third file would break on regeneration. Only their comments
changed.
- `opencode.json` and `.mcp.json` each declare the same graft MCP server for a
different host. Both hosts are configured this way deliberately, so removing
either would break that host.
- `third_party/JUCE/Projucer` and `third_party/JUCE/DemoRunner` are 22 MB of
upstream Linux binaries that ship with the vendored copy. They are not built here
and were not deleted. `.gitignore` now covers them, so they no longer appear as
untracked files.
- `docs/UI_UX_AUDIT.md` and `docs/archive/JANITOR_TASKS_2026-05.md` still contain the
old `./build_harness.sh` commands. They record what was run at the time, and
rewriting them would misreport that. `README.md` and `AGENTS.md` are the
maintained sources for commands.
- Nine em dashes remain inside the generated `<!-- graft:start -->` block in
`AGENTS.md`. They come from the installed `@nanonets/graft` template and return on
the next `graft init`; removing them needs a change to that package.
## Limits
- The macOS build could not be run, because it needs a Mac and the Apple SDK.
`scripts/build_macos.sh` was syntax checked and reviewed, and nothing else.
- No change in this round touched audio or GUI behaviour except deleting an unused
class, so the harness result is a regression check, not sound or visual proof.
There is still no headless way to verify a pixel.
- The staged cross-build for this round is at
`build/windows-staged-check/SerumAlt.vst3`. It is inside `build/`, so
`./scripts/clean.sh` removes it. `SerumAlt_Windows/SerumAlt.vst3` is the older
bundle and was not overwritten.
+412
View File
@@ -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.
+699
View File
@@ -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
View File
@@ -1,4 +1,5 @@
{ {
"$schema": "https://opencode.ai/config.json",
"mcp": { "mcp": {
"graft": { "graft": {
"type": "local", "type": "local",
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more