Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f69316a454 | ||
|
|
4b0db50271 | ||
|
|
facd5c5b5e | ||
|
|
e4cea300db |
No files matched your search
@@ -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 */ });
|
||||||
@@ -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 */ });
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# SerumAlt agent guidance
|
|
||||||
|
|
||||||
## Project
|
|
||||||
|
|
||||||
SerumAlt is a JUCE 7 wavetable synthesiser using C++17. Project metadata is in
|
|
||||||
`CMakeLists.txt`. Production formats are VST3 and Standalone on Linux, VST3/AU/Standalone
|
|
||||||
on macOS, and VST3 on Windows. First-party classes use `namespace serum`; executable
|
|
||||||
entry points and JUCE's `createPluginFilter` are outside that namespace.
|
|
||||||
|
|
||||||
## Build and verification
|
|
||||||
|
|
||||||
Read README.md's Building section before changing scripts, dependencies, or CMake.
|
|
||||||
It is the maintained source for prerequisites, options and artifact paths.
|
|
||||||
|
|
||||||
- Linux production: `./build_linux.sh`.
|
|
||||||
- macOS production, on a Mac: `./build_macos.sh`.
|
|
||||||
- Windows x64 VST3, cross-compiled on Linux: `./build_windows.sh`.
|
|
||||||
- Native QA build and execution: `./build_harness.sh --run`.
|
|
||||||
- Shell syntax: `for script in build_*.sh; do bash -n "$script"; done`.
|
|
||||||
|
|
||||||
Production scripts explicitly enable `SERUMALT_BUILD_PLUGIN` and disable
|
|
||||||
`SERUMALT_BUILD_TESTS`. The harness script does the reverse in a separate directory.
|
|
||||||
Building `SerumAltTest` alone does not execute it; `--run` invokes CTest.
|
|
||||||
When changing CMake, verify production has no `SerumAltTest` target and the
|
|
||||||
harness-only build has no `SerumAlt`, VST3, AU, or Standalone target.
|
|
||||||
|
|
||||||
Keep existing build trees and local changes intact. Use a new `BUILD_DIR` for
|
|
||||||
clean-build verification. Use a fresh Windows `OUTPUT_DIR` for distribution checks,
|
|
||||||
since staging does not remove extra files. Never edit generated CMakeCache.txt files.
|
|
||||||
The checked-in MinGW toolchain is the single source of cross-compiler configuration.
|
|
||||||
|
|
||||||
## Current source layout
|
|
||||||
|
|
||||||
`CMakeLists.txt` owns the explicit `SERUMALT_SOURCES` list and shared target setup.
|
|
||||||
`cmake/Plugin.cmake` owns production formats and MinGW link flags;
|
|
||||||
`cmake/Harness.cmake` owns the console harness and CTest registration.
|
|
||||||
The harness compiles the same processor/editor sources independently of the plugin.
|
|
||||||
|
|
||||||
Most C++ files still live directly under `Source/`. Existing implementation
|
|
||||||
subdirectories are `EffectUnits/`, `GUI/`, `Presets/`, `Resources/`, and `Tests/`.
|
|
||||||
`FXProcessor`, `PluginEditor`, `Resources`, and `RAVEButton` remain at the source root.
|
|
||||||
`RAVEButton.{h,cpp}` defines `RaveController`.
|
|
||||||
|
|
||||||
The lowercase `Source/engine`, `modulation`, `params`, `plugin`, and `synth`
|
|
||||||
directories currently hold guidance, not relocated implementations. Consult their
|
|
||||||
AGENT.md files when editing the related root-level sources. Consult the matching
|
|
||||||
AGENT.md when editing an existing implementation subdirectory.
|
|
||||||
`AUDIT_REPORT.md` is a historical audit and proposed refactor, not a pending
|
|
||||||
instruction to move files. Update the explicit source list and includes only when
|
|
||||||
an actual source move is part of the requested task.
|
|
||||||
|
|
||||||
## Style and ownership
|
|
||||||
|
|
||||||
Follow the surrounding file. Existing C++ generally uses four-space indentation,
|
|
||||||
`#pragma once`, PascalCase types, camelCase members without prefixes, `k`-prefixed
|
|
||||||
constants, scoped enums, and `noexcept` on cheap getters and DSP paths. Use `Count`
|
|
||||||
only for enums that need a size or iteration sentinel. Headers include JuceHeader;
|
|
||||||
implementation files normally include their own header first.
|
|
||||||
|
|
||||||
Prefer value members or `std::unique_ptr` for owned objects and attachments.
|
|
||||||
`addAndMakeVisible` does not transfer ownership or delete children. Raw allocations
|
|
||||||
returned by `createEditor` and `createPluginFilter` transfer ownership to JUCE callers.
|
|
||||||
Guard parameter lookups and invalid state data. Use `jassert` sparingly for programmer
|
|
||||||
errors and return `bool` from bounded inserts to report failure.
|
|
||||||
|
|
||||||
## Real-time and concurrency requirements
|
|
||||||
|
|
||||||
For new or changed audio paths, allocate buffers during preparation and avoid heap
|
|
||||||
allocation, blocking locks and I/O in `processBlock` or per-sample DSP. Account for
|
|
||||||
variable host block sizes without allocating in the callback. Keep GUI updates on
|
|
||||||
the JUCE message thread. Exchange GUI/audio state through a safe snapshot or bounded
|
|
||||||
handoff; a timer or a writer-only mutex does not make concurrent access safe.
|
|
||||||
|
|
||||||
These are requirements, not claims that the current code already satisfies them.
|
|
||||||
The historical audit records lazy wavetable allocation, per-block buffer resizing,
|
|
||||||
and unsynchronised modulation/LFO edits. When changing those paths, inspect the
|
|
||||||
current implementation and verify the fix separately. The QA smoke test does not
|
|
||||||
prove real-time safety or absence of data races.
|
|
||||||
|
|
||||||
## Scope of guidance
|
|
||||||
|
|
||||||
Module guidance refines this file for its related sources. Preserve the root
|
|
||||||
real-time and ownership requirements if an example conflicts with them.
|
|
||||||
@@ -39,3 +39,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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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.
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "../FXProcessor.h"
|
#include "FXProcessor.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "../FXProcessor.h"
|
#include "FXProcessor.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "../FXProcessor.h"
|
#include "FXProcessor.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,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.
@@ -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,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "../FXProcessor.h"
|
#include "FXProcessor.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "../FXProcessor.h"
|
#include "FXProcessor.h"
|
||||||
#include "Biquad.h"
|
#include "Biquad.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "../FXProcessor.h"
|
#include "FXProcessor.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "../FXProcessor.h"
|
#include "FXProcessor.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <JuceHeader.h>
|
#include <JuceHeader.h>
|
||||||
#include "../Resources.h"
|
#include "Resources.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#include "KnobLookAndFeel.h"
|
||||||
|
|
||||||
|
namespace serum
|
||||||
|
{
|
||||||
|
|
||||||
|
void KnobLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||||
|
float sliderPos, float rotaryStartAngle, float rotaryEndAngle,
|
||||||
|
juce::Slider& slider)
|
||||||
|
{
|
||||||
|
const auto dial = juce::Rectangle<float> ((float) x, (float) y, (float) width, (float) height);
|
||||||
|
if (dial.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const float radius = juce::jmin (dial.getWidth(), dial.getHeight()) * 0.5f - 2.0f;
|
||||||
|
if (radius <= 1.0f)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const float lineW = juce::jlimit (1.5f, 5.0f, radius * 0.22f);
|
||||||
|
const float arcR = radius - lineW * 0.5f;
|
||||||
|
const float centreX = dial.getCentreX();
|
||||||
|
const float centreY = dial.getCentreY();
|
||||||
|
const float toAngle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle);
|
||||||
|
const bool live = slider.isEnabled();
|
||||||
|
|
||||||
|
juce::Path track;
|
||||||
|
track.addCentredArc (centreX, centreY, arcR, arcR, 0.0f, rotaryStartAngle, rotaryEndAngle, true);
|
||||||
|
g.setColour (theme::outline);
|
||||||
|
g.strokePath (track, juce::PathStrokeType (lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
|
||||||
|
|
||||||
|
if (live)
|
||||||
|
{
|
||||||
|
juce::Path value;
|
||||||
|
value.addCentredArc (centreX, centreY, arcR, arcR, 0.0f, rotaryStartAngle, toAngle, true);
|
||||||
|
g.setColour (theme::accent.withAlpha (slider.isMouseOverOrDragging() ? 1.0f : 0.75f));
|
||||||
|
g.strokePath (value, juce::PathStrokeType (lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
|
||||||
|
}
|
||||||
|
|
||||||
|
const float pointer = radius * 0.62f;
|
||||||
|
g.setColour (live ? theme::text : theme::textDim);
|
||||||
|
g.drawLine (centreX + std::sin (toAngle) * pointer * 0.35f,
|
||||||
|
centreY - std::cos (toAngle) * pointer * 0.35f,
|
||||||
|
centreX + std::sin (toAngle) * pointer,
|
||||||
|
centreY - std::cos (toAngle) * pointer,
|
||||||
|
juce::jmax (1.5f, lineW * 0.5f));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace serum
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <JuceHeader.h>
|
||||||
|
#include "Resources.h"
|
||||||
|
|
||||||
|
namespace serum
|
||||||
|
{
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Look-and-feel for Knob.
|
||||||
|
//
|
||||||
|
// Two jobs:
|
||||||
|
//
|
||||||
|
// 1. Draw the rotary with theme:: colours. The shared default look-and-feel
|
||||||
|
// paints it with its own palette, which ignores the rest of the editor.
|
||||||
|
//
|
||||||
|
// 2. Leave room for the knob's name. juce::LookAndFeel_V4 reduces the rotary
|
||||||
|
// bounds by a fixed ten pixels on each side, so a 76 pixel knob loses
|
||||||
|
// 20 pixels of dial and a smaller knob loses most of it. This version
|
||||||
|
// keeps the dial whole inside its footprint and reserves a text strip
|
||||||
|
// under it, which is the strip Knob paints its name into.
|
||||||
|
// ===========================================================================
|
||||||
|
class KnobLookAndFeel : public juce::LookAndFeel_V4
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// Height of the name strip reserved under the dial.
|
||||||
|
static constexpr int labelHeight = 16;
|
||||||
|
|
||||||
|
void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||||
|
float sliderPos, float rotaryStartAngle, float rotaryEndAngle,
|
||||||
|
juce::Slider& slider) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace serum
|
||||||
+29
-33
@@ -1,52 +1,28 @@
|
|||||||
#include "LFODisplay.h"
|
#include "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
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
updateModList();
|
if (added)
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
updateMacroList();
|
if (added)
|
||||||
|
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" };
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <JuceHeader.h>
|
#include <JuceHeader.h>
|
||||||
#include "../Resources.h"
|
#include "Resources.h"
|
||||||
|
|
||||||
namespace serum
|
namespace serum
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
std::fill (combLine.begin(), combLine.end(), 0.0f);
|
// combLine is one second of samples per filter, and a preset change now
|
||||||
|
// resets on the audio thread. It only needs clearing when the comb has
|
||||||
|
// actually written to it.
|
||||||
|
if (combDirty)
|
||||||
|
{
|
||||||
|
std::fill (combLine.begin(), combLine.end(), 0.0f);
|
||||||
|
combDirty = false;
|
||||||
|
}
|
||||||
combWrite = 0;
|
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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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
@@ -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.
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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,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
Reference in new issue
Block a user