Compare commits

...
25 Commits
Author SHA1 Message Date
biggy 5c942cad0c chore: add graft repo context graph integration
Add .claude/ config (settings, graft statusline and hooks helpers,
graft skill), .mcp.json and opencode.json MCP server entries, and
AGENTS.md with graft usage instructions. Add .ignore to re-admit
graft/ to ripgrep search while excluding its cache. Add /graft/ to
.gitignore since the graph is regenerable.
2026-09-09 14:33:58 +02:00
biggy 8364737d1c perf(gui): skip unchanged repaints and gate updates by tab
Add change detection to Display, WaveformDisplay, FilterDisplay,
EnvelopeDisplay, and LFODisplay so repaint() is only called when
values actually change. Gate updateVisuals() by currentTab so only
the visible tab's displays are updated each timer tick. Similarly
gate updateModList/updateMacroList to their respective tabs.

Manage dynamically created controls via ownedControls
unique_ptr vector instead of raw new leaks. Null-check parameter
lookups before creating attachments. Hold engine control lock
when reading matrix/macros/LFO shape data.

Fix envelope display curve directions (attack rising from bottom,
decay falling toward sustain, release falling toward bottom).
Add tooltip to FilterDisplay noting the approximation. Replace
ToggleButton raw param pointer with ParameterAttachment for proper
gesture handling. Sync preset and scale combos to processor state.
2026-09-09 14:33:41 +02:00
biggy 3be1e42242 refactor(engine): add thread-safe control capture and sub-block MIDI
Introduce a CriticalSection controlLock separating control-thread
state (matrix, macros, controlLfos) from audio-thread snapshots
(audioMatrix, audioMacros, lfos). captureControls() copies under
lock at the start of each processBlock; the audio thread never
touches control state directly. Cache parameter values in an
unordered_map keyed by string_view to avoid per-sample APVTS lookups.

Prebuild wavetables in the constructor instead of lazy allocation.
Resize mixBuffer only in prepare(), not per block. Render MIDI
events at their sample positions using sub-block rendering with
renderUntil(), so notes start at the correct sub-sample offset.

Make RaveController non-destructive: replace APVTS mutation with a
static apply() that boosts the RenderContext and FX slots for the
current block only. Remove the snapshot/restore machinery.

Make currentProgram atomic. Validate state I/O: check XML tag,
clamp program index, sanitize LFO shape data, and use
beginChangeGesture/endChangeGesture for RAVE toggle. Reset
parameters to defaults before loading preset values.
2026-09-09 14:33:30 +02:00
biggy cd26beca42 refactor(osc): support held-note parameter updates without phase reset
Add Oscillator::setParams() which updates detune, pan, and gain for
already-initialised sub-voices without resetting their phases. This
preserves phase continuity when unison or spread is LFO-modulated at
control rate. noteOn() now just seeds phases and delegates to
setParams().

Precompute panL/panR in SubVoice instead of calling cos/sin per
sample. Add paramsValid and cachedParams to skip redundant work.

Replace SynthVoice's lastUnisonA/B tracking with a single
oscillatorsNeedNoteOn flag: noteOn sets it, render clears it after
the first block. Add SubLevel and NoiseLevel to the per-voice
modulation targets so they can be modulated like other parameters.
2026-09-09 14:33:21 +02:00
biggy 40dc0d4105 refactor(lfo): advance by sample count and separate phase offset
Add advance(numSamples) so the engine can step LFOs per sub-block
instead of calling process() in a sample loop. process() now
delegates to advance(1).

Split phaseOffset from phase so setParams doesn't overwrite the
running phase; getPhase() adds the offset and wraps. Fix sync mode
to divide by beat multiplier instead of multiplying. Move SampleHold
randomization into advance() based on cycle count. Validate shape
data in setShapeData with isfinite and jlimit.
2026-09-09 14:33:13 +02:00
biggy e2e66457c2 refactor(mod): validate modulation matrix and macro inputs
Add bounds, finiteness, and range clamping to addConnection and
addAssignment. Validate ValueTree types and property values in
fromValueTree before inserting, rejecting non-integer indices and
unknown source names. Run toValueTree through a validated copy to
ensure persisted state is always within constraints.
2026-09-09 14:33:06 +02:00
biggy 4748bfe768 refactor(fx): give each FX slot independent DSP history
Replace the single shared array of 9 effect units with a 2D array
of kNumFxSlots × kNumEffectTypes, so each slot owns its own DSP
state. This prevents state bleed when the same effect type appears
in multiple slots and allows reordering without carrying over
internal history.

Track activeTypes to skip unchanged slots. Remove the dry buffer
since it was unused.
2026-09-09 14:32:58 +02:00
biggy dc6fd88ed1 refactor(filter): cache coefficients and extract cutoff calculation
Add updateCoefficients() that short-circuits when cutoff, resonance,
and type are unchanged. Cache ladderG and formantCoefficients so
processSample() avoids redundant exp()/tan() calls per sample.

Extract static getCutoffHz() from Filter::process() so FilterBank
can compute the keytracked cutoff once per parallel branch instead
of per sample. Simplify formant() and screamer() signatures to use
cached state.
2026-09-09 14:32:49 +02:00
biggy 3b2ecc50f2 refactor(params): structure parameter IDs into typed groups
Introduce paramIds namespace with Oscillator struct and constexpr
arrays for envelope, LFO, FX, and macro parameter IDs. Replace the
scattered local const char* arrays in Engine and PluginEditor with
references to these shared definitions.

Return ModSource::Count from modSourceFromString for unknown strings
instead of silently mapping to Lfo1. Simplify isPerVoiceSource and
isPerVoiceTarget to range-based expressions.
2026-09-09 14:32:43 +02:00
biggy 42d2302c28 docs(readme): document build system, prerequisites, and harness
Replace the single cmake invocation with per-platform build scripts,
prerequisite packages for Debian/Ubuntu and Arch/CachyOS, macOS and
Windows cross-compilation instructions, artifact path tables, and the
independent QA harness workflow.
2026-09-09 13:25:24 +02:00
biggy 05fd6440bd docs: add agent guidance and historical audit report
Add root AGENT.md with project conventions, build verification steps,
source layout, style rules, and real-time/concurrency requirements.
Add per-module AGENT.md files for each existing and proposed source
subdirectory. Add AUDIT_REPORT.md as a historical Phase 1 snapshot
documenting memory management, error handling, concurrency model,
naming conventions, and anti-pattern catalog.
2026-09-09 13:25:02 +02:00
biggy a50666355c feat(gui): add knob value popup, tooltips, tab cycling, and layout system
Add a floating value label near the cursor while dragging a knob, giving
immediate feedback without altering slider behaviour. Inherit
SettableTooltipClient on ToggleButton so setTooltip works consistently
with knobs and combos.

Introduce a shared layout constants namespace in PluginEditor so every
panel, row and control position derives from one spacing system. Add
tooltips to all knobs, combos, toggles, and buttons. Add Tab/Shift+Tab
tab cycling via keyPressed, with a TooltipWindow for hover hints.
2026-09-09 13:24:40 +02:00
biggy f10409ad1e build: extract CMake plugin/harness modules and add platform build scripts
Split CMakeLists.txt into cmake/Plugin.cmake (production formats and
MinGW link flags) and cmake/Harness.cmake (console test harness with
CTest registration). Add SERUMALT_BUILD_PLUGIN and SERUMALT_BUILD_TESTS
options so production and harness builds are mutually exclusive and
each omits the other's targets.

Add build_linux.sh, build_macos.sh, and build_harness.sh with host
validation, env-overridable build dirs, and explicit option flags.
Rewrite build_windows.sh to use the checked-in mingw64-toolchain.cmake
instead of regenerating it, validate all required tools, and stage
with cmake -E copy_directory.

Broaden .gitignore to cover all build_*/ directories.
2026-09-09 13:24:09 +02:00
biggy a1feeb76f0 build(vendor): migrate JUCE webkit2gtk from 4.0 to 4.1
Update all vendored JUCE 7.0.12 references from webkit2gtk-4.0 to
webkit2gtk-4.1: pkg-config targets, dynamic library names, docs, and
Projucer project exporter. The 4.0 API is removed in current Linux
distributions.
2026-09-09 13:23:54 +02:00
biggy 421f5c6ced chore: add project gitignore 2026-09-09 10:28:26 +02:00
biggy 769e85e4e4 test: add headless QA harness 2026-09-08 14:55:22 +02:00
biggy 6e4d399a04 feat(plugin): add plugin processor and editor 2026-09-08 14:55:22 +02:00
biggy 0a94208d0f feat(params): add parameters, macros, RAVE controls and resources 2026-09-08 14:55:22 +02:00
biggy a9abca9e15 feat(gui): add GUI components and displays 2026-09-08 14:55:22 +02:00
biggy 5a80534a2e feat(fx): add effect rack and effect units 2026-09-08 14:55:22 +02:00
biggy 4021b9cdf7 feat(filter): add filter bank and filter models 2026-09-08 14:55:21 +02:00
biggy 44960b9acb feat(modulation): add envelopes, LFOs and modulation matrix 2026-09-08 14:55:21 +02:00
biggy fc9d16b302 feat(dsp): add core synthesizer oscillators, wavetable and voice engine 2026-09-08 14:55:21 +02:00
biggy 6e2859a1f6 build: add CMake and MinGW cross-compilation toolchain 2026-09-08 14:55:21 +02:00
biggy d6705ab29f build(deps): vendor JUCE 7.0.12 2026-09-08 14:55:21 +02:00
3700 changed files with 1227898 additions and 22 deletions
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
const path = require('path');
const fs = require('fs');
const { pathToFileURL } = require('url');
const { execFileSync } = require('child_process');
const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
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.
function fromPkg(base) {
try {
const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] });
return path.join(path.dirname(pkg), 'dist', 'claude');
} catch { return null; }
}
// The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand.
function globalRoot() {
try {
const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }).trim();
return root || null;
} catch { return null; /* npm unavailable */ }
}
// The version of the package a dist/claude dir belongs to, or null if unreadable.
function versionOf(distClaude) {
try {
return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null;
} catch { return null; }
}
// Numeric-dotted compare of the release part; an unreadable version loses to any known one.
function newer(a, b) {
if (!a) return false;
if (!b) return true;
const p = (v) => String(v).split('-')[0].split('.').map((n) => Number(n) || 0);
const pa = p(a), pb = p(b);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d > 0;
}
return false;
}
// The highest-versioned dir in `dirs` that actually contains `name`, or null.
function best(dirs, name) {
let bestDir = null, bestVer = null;
for (const d of dirs) {
if (!d || !fs.existsSync(path.join(d, name))) continue;
const v = versionOf(d);
if (bestDir === null || newer(v, bestVer)) { bestDir = d; bestVer = v; }
}
return bestDir;
}
function entry(name) {
// Cheap candidates first, and only shell out to npm when every one of them misses.
const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))];
const hit = best(cheap, name);
if (hit) return path.join(hit, name);
const gr = globalRoot();
const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude');
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
}
import(pathToFileURL(entry("hooks.js")).href).then((m) => m.main(process.argv[2])).catch(() => { /* graft unavailable — no-op */ });
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
const path = require('path');
const fs = require('fs');
const { pathToFileURL } = require('url');
const { execFileSync } = require('child_process');
const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
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.
function fromPkg(base) {
try {
const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] });
return path.join(path.dirname(pkg), 'dist', 'claude');
} catch { return null; }
}
// The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand.
function globalRoot() {
try {
const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }).trim();
return root || null;
} catch { return null; /* npm unavailable */ }
}
// The version of the package a dist/claude dir belongs to, or null if unreadable.
function versionOf(distClaude) {
try {
return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null;
} catch { return null; }
}
// Numeric-dotted compare of the release part; an unreadable version loses to any known one.
function newer(a, b) {
if (!a) return false;
if (!b) return true;
const p = (v) => String(v).split('-')[0].split('.').map((n) => Number(n) || 0);
const pa = p(a), pb = p(b);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d > 0;
}
return false;
}
// The highest-versioned dir in `dirs` that actually contains `name`, or null.
function best(dirs, name) {
let bestDir = null, bestVer = null;
for (const d of dirs) {
if (!d || !fs.existsSync(path.join(d, name))) continue;
const v = versionOf(d);
if (bestDir === null || newer(v, bestVer)) { bestDir = d; bestVer = v; }
}
return bestDir;
}
function entry(name) {
// Cheap candidates first, and only shell out to npm when every one of them misses.
const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))];
const hit = best(cheap, name);
if (hit) return path.join(hit, name);
const gr = globalRoot();
const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude');
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
}
import(pathToFileURL(entry("statusline.js")).href).then((m) => m.main()).catch(() => { /* graft unavailable — no-op */ });
+78
View File
@@ -0,0 +1,78 @@
{
"statusLine": {
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-statusline.cjs\""
},
"subagentStatusLine": {
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-statusline.cjs\""
},
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" post-edit",
"timeout": 10000
}
]
},
{
"matcher": "Bash|mcp__graft__|Read|Grep|Glob",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" tool-savings",
"timeout": 8000
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" prompt",
"timeout": 15000
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" session-start",
"timeout": 8000
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" stop",
"timeout": 8000
}
]
}
]
},
"footerLinksRegexes": [
"graft/[\\w./-]+\\.md"
],
"permissions": {
"allow": [
"Bash(graft:*)",
"Bash(npx graft:*)",
"Bash(graft-dev:*)",
"Bash(node dist/cli.js:*)"
]
}
}
+150
View File
@@ -0,0 +1,150 @@
---
name: graft
description: This repo is indexed by graft/. For ANY task here, whether
understanding how something works, finding where code lives, tracing what
calls a symbol or what a change breaks, or scoping an edit, get your context
from graft before grepping or reading source files.
---
# graft
`graft/` holds a graph of this repo: small markdown nodes that each explain one
part in prose and name the exact `file:line` spans they cover, plus a wiring
graph of who-calls-what. Querying a node costs a few hundred tokens; rebuilding
that understanding by reading source costs thousands, and misses the edges.
Every command below is `$0`, needs no API key, and returns in under a second.
There are six of them. **Pick the one that fits the task, run it, act on the
answer; don't chain tools hoping for more. Most tasks need one call.**
## The tools
### 1 · `graft ask "<question>" --source`: locate + understand (the default)
Ranked retrieval over the graph, routed automatically between prose nodes and
the wiring graph, returning the top hits with exact `file:line`.
- `--source` inlines the code at each hit, the ≤8-line **crux** of each
definition, so the result IS the code you need, no follow-up file read. Add
`--full` only when the crux is too small to act on.
- `--in <path>` narrows to a subtree before ranking; `-n N` caps results (default 8).
- **Use it when** the question is conceptual or locational: "how does auth
work", "where is rate-limiting handled", "what assembles the request pipeline".
- One ask usually answers. A genuinely multi-part question needs one ask per
distinct sub-aspect, never the same question reworded. Few or weak hits mean
switch tool (grep / skeleton / callers), don't re-ask.
### 2 · `graft grep "<pattern>"`: exhaustive find
Regex (or `--fixed` for a literal) over every indexed file, hits **grouped by
enclosing symbol** and ranked by coupling; it also reports files it couldn't read.
- **Use it when** you need every occurrence: all call sites, all uses of a
constant, all providers. `ask` is ranked top-N and *will* miss instances;
grep won't. One grep replaces a spray of asks.
- Search a **short symbol name or literal**, not a full guessed signature: an
over-specific regex (`func (s *Server) GenerateHandler`) returns nothing even
when the code is indexed. If a grep misses, **loosen it** (drop the receiver
and signature, keep the bare name) and retry `graft grep` — do NOT switch to
raw `grep -rn`, which is slower and unranked.
- `-i` case-insensitive; `--in <path>` scopes to a subtree. Raw `grep -rn` is
only for files graft genuinely doesn't index (docs, configs, brand-new files).
### 3 · `graft skeleton <file>`: a file's API at a glance
Signatures-only view of one file (every function / method / type with its span)
in ~200 tokens, ~10x cheaper than reading the file.
- **Use it when** you need "what's in this file / what can I call here" before
editing or wiring into it. One skeleton is the whole answer for a file; don't
re-skeleton the same file, and don't skeleton every file `map` already named.
### 4 · `graft callers <symbol>`: the exact edges
Precomputed call/reference edges, not a text search. Symbol can be bare
(`Foo`), qualified (`Class.method`), or package-qualified (`pkg.Fn`).
- default `--direction in`: **who calls/references** this; run before you
rename, delete, or change its signature.
- `--direction out`: **what this symbol itself calls/depends on** (the old `callees`).
- `--depth N`: walk transitively N hops for the **full blast radius** (the old
`impact`); `--depth 2` is the usual "what breaks if I touch this".
- `--depth all`: the **entire connected closure** — every source reachable
through the edges. Reach for this before a **refactor, rename, or any
multi-file change**: it surfaces the sibling and downstream files (platform
variants, a module you must split out) that a single-file edit would miss.
### 5 · `graft map`: orientation for an unfamiliar repo or area
A token-budgeted tour: directory clusters, per-directory hubs, and global
hotspots, straight from the wiring graph.
- **Use it when** you land in a repo cold or are asked for "the architecture".
`map` alone is the answer: read the hub cards it names; do NOT then skeleton
or ask your way through every subsystem it lists. `--max-dirs N` widens it.
### 6 · Lifecycle: `graft build` / `graft check`
Every tool above refreshes the graph itself before answering, so what those tools
return always describes the code as it is right now — including edits you just made
and have not committed. You do **not** need to run `build` after editing.
One caveat, if you `grep` the markdown under `graft/` directly: those cards are a
projection, rebuilt at the end of the turn rather than on each query, so after an edit
they can lag. The tools above never do — prefer them, and treat a card's spans as
stale if you have edited that file this turn.
`build` is for the LLM layer (`--deep` adds a concept map; skip unless asked);
`check` fails when `graft/` is stale, for CI.
## Scenarios: the shortest path through a coding task
| When you're… | Reach for | Calls |
|---|---|---|
| Onboarding / "explain this codebase" | `graft map`, then read the named hub cards | 1 |
| Understanding a flow ("how does X work") | `graft ask "<flow>" --source` | 1 |
| Finding where a change belongs | `graft ask "where is <behavior>" --source` | 1 |
| Editing a symbol you can already name | `graft grep "<symbol>"`, edit at the `file:line` (skip `ask` — you know where it is) | 1 |
| 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 |
| "What does this depend on?" | `graft callers <sym> --direction out` | 1 |
| Finding every occurrence of a pattern | `graft grep "<literal>"` | 1 |
| "What's the API of this file?" | `graft skeleton <file>` | 1 |
| Debugging a failure in area X | `graft ask "<symptom>" --source`, then `callers` on the suspect | 12 |
| Judging a diff's risk before merge | `graft callers <changed sym> --depth 2` | 1 / symbol |
| Working inside one repo of a monorepo | add `--in <scope>/` to ask / grep / callers | n/a |
In a multi-repo workspace, graft ranks fairly so the biggest repo can't drown
the rest, and every hit carries a `[scope/]` label naming its sub-project; when
you already know where you're working, narrow with `graft ask "<task>" --in <scope>/`.
## Spend the fewest calls
- A node's `covers:` list already gives exact `file:line` for every symbol, so
cite straight from it. The spans are generated from source and authoritative;
don't re-open or re-grep files to "double-check".
- When the task already names the file or symbol to change, go straight there:
`graft grep "<symbol>"` for the exact `file:line`, then edit. Reserve
`graft ask` for when you don't yet know where the code lives — an `ask`
round-trip is wasted on a target you can already name.
- Trust the answer and act. Reach for a second tool only when the first genuinely
fell short: weak hits, a truncated span, or a need to be exhaustive.
- If graft names a path that isn't on disk, its index is ahead of your checkout
(a branch switch or unpulled move). Don't read the missing file — `graft grep`
the symbol to find where it lives now, or run `graft build` to refresh.
## Report what graft saved, every turn
Each retrieval tool **opens** its output with a `[graft] tokens saved ≈ N` line:
the estimated tokens that call saved versus reading the files it covers whole.
Whenever you used any graft tool in a turn, close your reply with a one-line
tally summing those numbers across every graft call you made, e.g.
`🌱 graft saved ~12,400 tokens this turn (3 calls)`. A call with no such line
(tiny files, where the pointers cost as much as the source) saved nothing, so
skip it. This is the per-turn figure; the statusline carries the running
session total.
**Never pipe a graft command through `head`, `tail`, or `sed -n`.** Every tool
is already capped and states what it dropped; clipping it costs you hits you
asked for, and it silently drops the savings line the statusline's running
total is parsed from.
## When graft isn't enough
- Span truncated ("+N more lines"): open the file at that exact range.
- A node lacks a detail: ask a more specific question; only then read source at
the exact `file:line`, never a whole file to rebuild understanding graft gives.
- You may also grep / ls / cat inside `graft/` directly (plain markdown;
`graft/INDEX.md` indexes the nodes), but the tools above are faster and
exhaustive where it matters, so reach for them first.
When the graft MCP server is connected, these are exposed as tools too:
`graft_find_code`, `graft_find_all`, `graft_file_api`, `graft_trace_calls` (with
`direction` / `depth`), `graft_repo_map`, `graft_check_freshness`. Use whichever surface is
available; the guidance is identical.
+21
View File
@@ -0,0 +1,21 @@
# CMake build directories
/build/
/build_*/
/cmake-build-*/
# Windows output staging
/SerumAlt_Windows/
# CMake generated files
CMakeUserPresets.json
compile_commands.json
# IDE and OS files
.vscode/
.idea/
*.user
.DS_Store
.cache/
# graft's local graph cache — regenerable, not committed (run `graft build`).
/graft/
+5
View File
@@ -0,0 +1,5 @@
# graft's cards are gitignored but should stay greppable: ripgrep reads
# .ignore before .gitignore, so this re-admits the tree to search only.
!graft/
graft/.cache/
graft/.graph/
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"graft": {
"command": "graft",
"args": [
"mcp"
]
}
}
}
+83
View File
@@ -0,0 +1,83 @@
# SerumAlt agent guidance
## Project
SerumAlt is a JUCE 7 wavetable synthesiser using C++17. Project metadata is in
`CMakeLists.txt`. Production formats are VST3 and Standalone on Linux, VST3/AU/Standalone
on macOS, and VST3 on Windows. First-party classes use `namespace serum`; executable
entry points and JUCE's `createPluginFilter` are outside that namespace.
## Build and verification
Read README.md's Building section before changing scripts, dependencies, or CMake.
It is the maintained source for prerequisites, options and artifact paths.
- Linux production: `./build_linux.sh`.
- macOS production, on a Mac: `./build_macos.sh`.
- Windows x64 VST3, cross-compiled on Linux: `./build_windows.sh`.
- Native QA build and execution: `./build_harness.sh --run`.
- Shell syntax: `for script in build_*.sh; do bash -n "$script"; done`.
Production scripts explicitly enable `SERUMALT_BUILD_PLUGIN` and disable
`SERUMALT_BUILD_TESTS`. The harness script does the reverse in a separate directory.
Building `SerumAltTest` alone does not execute it; `--run` invokes CTest.
When changing CMake, verify production has no `SerumAltTest` target and the
harness-only build has no `SerumAlt`, VST3, AU, or Standalone target.
Keep existing build trees and local changes intact. Use a new `BUILD_DIR` for
clean-build verification. Use a fresh Windows `OUTPUT_DIR` for distribution checks,
since staging does not remove extra files. Never edit generated CMakeCache.txt files.
The checked-in MinGW toolchain is the single source of cross-compiler configuration.
## Current source layout
`CMakeLists.txt` owns the explicit `SERUMALT_SOURCES` list and shared target setup.
`cmake/Plugin.cmake` owns production formats and MinGW link flags;
`cmake/Harness.cmake` owns the console harness and CTest registration.
The harness compiles the same processor/editor sources independently of the plugin.
Most C++ files still live directly under `Source/`. Existing implementation
subdirectories are `EffectUnits/`, `GUI/`, `Presets/`, `Resources/`, and `Tests/`.
`FXProcessor`, `PluginEditor`, `Resources`, and `RAVEButton` remain at the source root.
`RAVEButton.{h,cpp}` defines `RaveController`.
The lowercase `Source/engine`, `modulation`, `params`, `plugin`, and `synth`
directories currently hold guidance, not relocated implementations. Consult their
AGENT.md files when editing the related root-level sources. Consult the matching
AGENT.md when editing an existing implementation subdirectory.
`AUDIT_REPORT.md` is a historical audit and proposed refactor, not a pending
instruction to move files. Update the explicit source list and includes only when
an actual source move is part of the requested task.
## Style and ownership
Follow the surrounding file. Existing C++ generally uses four-space indentation,
`#pragma once`, PascalCase types, camelCase members without prefixes, `k`-prefixed
constants, scoped enums, and `noexcept` on cheap getters and DSP paths. Use `Count`
only for enums that need a size or iteration sentinel. Headers include JuceHeader;
implementation files normally include their own header first.
Prefer value members or `std::unique_ptr` for owned objects and attachments.
`addAndMakeVisible` does not transfer ownership or delete children. Raw allocations
returned by `createEditor` and `createPluginFilter` transfer ownership to JUCE callers.
Guard parameter lookups and invalid state data. Use `jassert` sparingly for programmer
errors and return `bool` from bounded inserts to report failure.
## Real-time and concurrency requirements
For new or changed audio paths, allocate buffers during preparation and avoid heap
allocation, blocking locks and I/O in `processBlock` or per-sample DSP. Account for
variable host block sizes without allocating in the callback. Keep GUI updates on
the JUCE message thread. Exchange GUI/audio state through a safe snapshot or bounded
handoff; a timer or a writer-only mutex does not make concurrent access safe.
These are requirements, not claims that the current code already satisfies them.
The historical audit records lazy wavetable allocation, per-block buffer resizing,
and unsynchronised modulation/LFO edits. When changing those paths, inspect the
current implementation and verify the fix separately. The QA smoke test does not
prove real-time safety or absence of data races.
## Scope of guidance
Module guidance refines this file for its related sources. Preserve the root
real-time and ownership requirements if an example conflicts with them.
+41
View File
@@ -0,0 +1,41 @@
<!-- graft:start -->
## Graft — repo context graph
This repo is indexed in `graft/`: small linked markdown nodes that explain each
system and carry exact file:line spans, kept in sync with the code through git.
For ANY task here — understanding how something works, finding where code lives,
or scoping a change — get context from the graph before grepping or opening
source files. Re-ask freely (it's cheap) and reuse literal identifiers you
already have (symbol, error string, file name) as the query. New to this repo?
Run `graft map` first — a token-budgeted orientation (dir clusters, hubs,
hotspots), no LLM, no key.
- Run `graft ask "<your question>" --source` → ranked nodes with the relevant
code spans inlined (each hit's ≤8-line crux by default; `--full` for whole
definitions when the crux isn't enough). Match the tool to the task shape:
for understanding or editing, the top node IS the answer — cite its
`covers:` file:line spans and edit straight from `--source`. For
exhaustive tasks ("every occurrence / every caller of this pattern"), ranked
results are top-N, not complete — run `graft grep "<literal>"` instead
(exhaustive over indexed files, grouped by enclosing symbol), falling back
to raw `grep -rn` only for unindexed files.
- `graft skeleton <file>` → every definition's signature + span, ~10× cheaper
than reading the file; use it to skim an API surface.
- `graft callers <symbol>` gives precomputed, exact edges — who calls this.
Add `--direction out` for what it calls, or `--depth N` to walk
transitively for the full blast radius. For structural questions, skip
ranking and use this directly.
- Or browse: `graft/INDEX.md` lists every node; follow the links.
- Monorepos and folders of multiple repos rank fairly across sub-projects —
hits carry `[scope/]` labels naming which one they're from. Narrow with
`graft ask "<task>" --in <scope>/` once you know where you're working.
If a returned span is truncated ("+N more lines"), open the file at that exact
range before finalizing. Only open source files when a node genuinely lacks a
needed detail, and then at the exact file:line the node points to — never
re-read whole files.
After big code changes, refresh the graph with `graft build` (deterministic,
no API key, $0).
<!-- graft:end -->
+500
View File
@@ -0,0 +1,500 @@
# SerumAlt codebase audit (Phase 1)
Historical snapshot, taken before the platform/harness build separation. Build
commands, defaults, target locations and line numbers below describe that earlier
state. Use README.md's Building section and the current CMake files for builds.
The proposed source moves are suggestions, not an approved implementation plan.
Scope: the SerumAlt C++/JUCE VST3 + Standalone synthesiser. This report records the
implicit standards, anti-patterns, memory choices, error handling, concurrency model and
module boundaries found by reading the real repository files. No source, build or
documentation files were modified. All findings are cited with `path:line` references read
during this task.
Repository note: at the start of this audit the working tree already had uncommitted
changes to `CMakeLists.txt`, several `Source/` files and several `third_party/JUCE/` files,
plus untracked `build/`, `build_windows/` and `SerumAlt_Windows/` directories. Those
changes predate this audit and are not analysed here except where the current on-disk
content is the source of truth.
---
## 1. Build & test setup
### Project metadata and targets
- Project name and version: `project(SerumAlt VERSION 1.0.0 LANGUAGES CXX)` at `CMakeLists.txt:3`.
- C++ standard: C++17, required, extensions off at `CMakeLists.txt:8-10`
(`CMAKE_CXX_STANDARD 17`, `CMAKE_CXX_STANDARD_REQUIRED ON`, `CMAKE_CXX_EXTENSIONS OFF`).
- The plugin target is `SerumAlt`, declared with `juce_add_plugin(...)` at
`CMakeLists.txt:33-46`. Formats are `VST3 AU Standalone` at `CMakeLists.txt:44`. JUCE
generates the concrete per-format targets (`SerumAlt_VST3`, `SerumAlt_Standalone`, and
`SerumAlt_AU` on macOS). There is no separate `PluginProcessor`/`PluginEditor` target;
those are ordinary classes compiled into `SerumAlt`.
- A console test target `SerumAltTest` is added at `CMakeLists.txt:126-140` via
`juce_add_console_app`. It compiles the entire `${SERUMALT_SOURCES}` list plus
`Source/Tests/TestMain.cpp` (`CMakeLists.txt:128`).
### Source list
Sources are an explicit list, not a glob. `set(SERUMALT_SOURCES ...)` spans
`CMakeLists.txt:50-87` and is consumed by `target_sources(SerumAlt PRIVATE
${SERUMALT_SOURCES})` at `CMakeLists.txt:89`. The list enumerates every `.cpp` used by the
plugin, including the subdirectory files `Source/EffectUnits/*.cpp`, `Source/GUI/*.cpp`
and `Source/Presets/FactoryPresets.cpp`.
Two header-only files are intentionally absent from the list because they have no
translation unit: `Source/EffectUnits/Biquad.h` and `Source/GUI/SerumLookAndFeel.h`.
Consequence for later phases: any file move or rename must update this list, because there
is no glob to pick files up automatically.
### Custom flags and linkage
- Compile definitions at `CMakeLists.txt:91-95`: `JUCE_WEB_BROWSER=0`, `JUCE_USE_CURL=0`,
`JUCE_VST3_CAN_REPLACE_VST2=0`.
- Link libraries at `CMakeLists.txt:97-103`: `juce::juce_audio_utils` and `juce::juce_dsp`
(private), `juce::juce_recommended_config_flags` and `juce::juce_recommended_warning_flags`
(public).
- Warning flags at `CMakeLists.txt:142-146`: `/W4` under MSVC, `-Wall -Wextra` otherwise.
- MinGW cross-build static runtime at `CMakeLists.txt:113-118`: when
`CMAKE_CROSSCOMPILING`, `SerumAlt_VST3` gets `-static-libgcc -static-libstdc++ -static`
so the DLL is self-contained.
- VST3 manifest handling at `CMakeLists.txt:23-31`: `SERUMALT_VST3_AUTO_MANIFEST` is `FALSE`
when cross-compiling (because JUCE 7.0.12 builds the manifest helper with the same
toolchain, producing a Windows `.exe` that cannot run on the Linux host). The manifest is
instead injected by `build_windows.sh`.
### Windows cross-build (build_windows.sh)
`build_windows.sh` is the canonical Windows build path. Exact steps, in order:
1. Verify `x86_64-w64-mingw32-g++` exists, else exit with an error (`build_windows.sh:16-21`).
2. Regenerate `mingw64-toolchain.cmake` from a heredoc inside the script
(`build_windows.sh:26-42`). This is identical in content to the checked-in
`mingw64-toolchain.cmake` (see below), so the toolchain file is defined in two places.
3. Configure: `cmake -S . -B build_windows -G Ninja -DCMAKE_BUILD_TYPE=Release
-DCMAKE_TOOLCHAIN_FILE=mingw64-toolchain.cmake -DSERUMALT_BUILD_TESTS=OFF`
(`build_windows.sh:47-50`). Note the test harness is explicitly disabled here.
4. Build: `cmake --build build_windows --target SerumAlt_VST3` (`build_windows.sh:53`).
5. Inject `Contents/Resources/moduleinfo.json` into the `.vst3` bundle by writing the file
directly, because the automatic manifest step was disabled (`build_windows.sh:63-126`).
6. Copy the bundle to `SerumAlt_Windows/SerumAlt.vst3` (`build_windows.sh:129-131`).
The toolchain (`mingw64-toolchain.cmake:1-15`) sets `CMAKE_SYSTEM_NAME Windows`,
`CMAKE_SYSTEM_PROCESSOR x86_64`, the `x86_64-w64-mingw32-*` compilers/tools, and
`CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32` with `PROGRAM NEVER` and the other
`..._MODE_*` values `ONLY`.
### Native Linux build (README only)
`README.md:76-78` documents a native build:
```
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
Outputs listed at `README.md:80-84` are
`build/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3` and the standalone app.
### Tests
Tests exist. `Source/Tests/TestMain.cpp` is a console harness that instantiates the
processor, plays notes through every factory preset, checks for NaN/inf and silence, and
toggles RAVE (`TestMain.cpp:37-135`). It returns a non-zero exit code on failure
(`TestMain.cpp:134`).
Tests are run through the native build, documented at `README.md:96-98`:
```
cmake --build build --target SerumAltTest
./build/SerumAltTest_artefacts/Release/SerumAltTest
```
The Windows cross-build does not build or run tests: `build_windows.sh:50` passes
`-DSERUMALT_BUILD_TESTS=OFF`.
### Clean build and test command
There is no clean step in `build_windows.sh`. The script configures into `build_windows/`
and builds without removing or cleaning it first. A clean rebuild is not scripted; it would
require manually removing `build_windows/` (or invoking Ninja's clean target, which the
script does not do). A combined "clean build + run tests" command does not exist: the
Windows script disables tests, and the README test instructions assume an already-built
native `build/` tree.
---
## 2. Proposed module boundaries
The codebase already has three real subdirectory boundaries (`EffectUnits/`, `GUI/`,
`Presets/`) plus `Resources/` (assets only) and `Tests/`. The flat files at `Source/` root
mix four distinct concerns, and three root files clearly belong inside existing
subdirectories. The proposal below is based on the include graph actually present, not on
generic synth architecture.
### Dependency facts that drive the split
- `Params.h`/`Params.cpp` depend only on `<JuceHeader.h>` (`Params.h:3`). It declares the
`ids`, `maps` namespaces, all enums, `ModSource`/`ModTarget`, and the wavetable name
table. It is the shared foundation: nearly every other file includes it.
- `Wavetable`, `Oscillator`, `SubOscillator`, `NoiseOscillator`, `Envelope`, `LFO`,
`Filter`, `FilterBank`, `SynthVoice` depend only on `Params`, `JuceHeader`, and each
other (`Oscillator.h:4-5`, `FilterBank.h:4-5`, `SynthVoice.h:4-12`).
- `ModulationMatrix` and `MacroControls` depend only on `Params` (`ModulationMatrix.h:4`,
`MacroControls.h:4`).
- `RAVEButton.h`/`RaveController` depends only on `Params` (`RAVEButton.h:4`) but manipulates
the APVTS, so it sits between modulation and plugin plumbing.
- `FXProcessor.h` depends only on `Params` (`FXProcessor.h:4`); every file in
`EffectUnits/` includes `../FXProcessor.h` (`Hyper.h:3`, `Chorus.h:3`, etc.), so
`FXProcessor` is the base the effect units build on.
- `Engine` aggregates the DSP: `Engine.h:4-10` includes `Wavetable`, `SynthVoice`, `LFO`,
`FXProcessor`, `ModulationMatrix`, `MacroControls`.
- `PluginProcessor.h` includes `Params`, `Engine`, `RAVEButton` (`PluginProcessor.h:4-6`);
it is the JUCE `AudioProcessor` boundary plus state/preset/RAVE glue.
- `PluginEditor.h` includes `PluginProcessor` and every `GUI/` header
(`PluginEditor.h:4-14`); it is pure GUI.
- `Resources.h` depends on `JuceHeader` plus `Params` for the format helpers
(`Resources.cpp:2`); it holds the theme and SVG strings. The SVG asset files live in
`Source/Resources/`, but the `Resources.{h,cpp}` code lives at root, splitting one
concept across two locations.
- `Presets/FactoryPresets` depends on `Params` and `ModulationMatrix`
(`FactoryPresets.h:4-5`).
- `Tests/TestMain.cpp` depends only on `PluginProcessor` (`TestMain.cpp:4`).
### Proposed modules
| Module | Files (current location) | Depends on |
| --- | --- | --- |
| params (foundation) | `Params.{h,cpp}` | JuceHeader only |
| synth (DSP building blocks + voice) | `Wavetable.{h,cpp}`, `Oscillator.{h,cpp}`, `SubOscillator.{h,cpp}`, `NoiseOscillator.{h,cpp}`, `Envelope.{h,cpp}`, `LFO.{h,cpp}`, `Filter.{h,cpp}`, `FilterBank.{h,cpp}`, `SynthVoice.{h,cpp}` | params |
| modulation | `ModulationMatrix.{h,cpp}`, `MacroControls.{h,cpp}`, `RAVEButton.{h,cpp}` | params |
| effects | `FXProcessor.{h,cpp}` plus all of `EffectUnits/` (including `Biquad.h`) | params |
| engine | `Engine.{h,cpp}` | synth, effects, modulation |
| gui | `PluginEditor.{h,cpp}` plus all of `GUI/` (including `SerumLookAndFeel.h`) | params, synth (for wavetable display), plugin |
| resources | `Resources.{h,cpp}` plus `Resources/*.svg` | params |
| presets | `Presets/FactoryPresets.{h,cpp}` | params, modulation |
| plugin (JUCE plumbing) | `PluginProcessor.{h,cpp}` | engine, modulation, presets, params |
| tests | `Tests/TestMain.cpp` | plugin |
### Where the current layout violates the boundaries
- `FXProcessor.{h,cpp}` sits at `Source/` root but is the base class and owner of the
`EffectUnits/` classes; `EffectUnits/*.h` include it as `../FXProcessor.h`. It belongs in
`EffectUnits/` (or in a renamed `FX/` directory with the units).
- `PluginEditor.{h,cpp}` sits at root but is pure GUI and includes every `GUI/` header. It
belongs in `GUI/`.
- `Resources.{h,cpp}` holds the theme and SVG strings, while the `.svg` assets live in
`Source/Resources/`. The code and assets should live together under one `resources`
module.
- `RAVEButton.{h,cpp}` is orphaned at root under a filename that does not match its class
(`RaveController`). It is a macro-like boost over the APVTS, so it fits the `modulation`
module, though its APVTS manipulation also makes `plugin` a defensible home.
- The remaining root files (`Params`, `Wavetable`, `Oscillator`, `SubOscillator`,
`NoiseOscillator`, `Envelope`, `LFO`, `Filter`, `FilterBank`, `SynthVoice`,
`ModulationMatrix`, `MacroControls`, `Engine`, `PluginProcessor`) are the synthesis core
and JUCE boundary. Grouping them into `synth/` and `modulation/` subdirectories would
match the dependencies above, but the flat layout is at least internally consistent:
they are the only files that legitimately belong at root today.
---
## 3. Memory management standards
### Dominant idiom: value semantics plus `std::unique_ptr` for ownership
- Owned sub-objects are held by value, not by pointer: `juce::AudioProcessorValueTreeState
parameters; Engine engine; RaveController rave;` at `PluginProcessor.h:56-58`; the
`Engine` holds `std::array<SynthVoice, kNumVoices> voices`, `std::array<LFO, kNumLfos>
lfos`, and `WavetableLibrary`, `FXProcessor`, `ModulationMatrix`, `MacroControls` by value
at `Engine.h:45-50`.
- Owned polymorphic objects use `std::unique_ptr`: `std::array<std::unique_ptr<FXUnit>, 9>
units` at `FXProcessor.h:50`, populated with `std::make_unique` at `FXProcessor.cpp:15-26`.
- GUI attachments are owned by `std::unique_ptr`: `std::vector<std::unique_ptr<...
SliderAttachment>>` and `...ComboBoxAttachment` at `PluginEditor.h:103-104`, filled at
`PluginEditor.cpp:169-170` and `183-184`.
- Transient GUI objects are `std::unique_ptr`: `std::unique_ptr<juce::Label> valuePopup`
at `Knob.h:38`, created at `Knob.cpp:61` and released with `valuePopup.reset()` at
`Knob.cpp:83`.
- JUCE-managed objects are returned as raw `new` pointers because JUCE takes ownership
through `addAndMakeVisible` or the plugin API: `createEditor()` returns
`new PluginEditor (*this)` at `PluginProcessor.cpp:195`; the plugin entry point returns
`new serum::SerumAltAudioProcessor()` at `PluginProcessor.cpp:380`; GUI children are
created with `new` at `PluginEditor.cpp:166,178,193,602,606`. These raw pointers are never
deleted by the code, which is correct only because JUCE's component tree and plugin host
own them.
- Non-owning cross-object references are raw pointers or references: `juce::RangedAudioParameter*
param` at `ToggleButton.h:38`, `const WavetableLibrary* wtLib` at `WaveformDisplay.h:24`,
and the `RenderContext` raw `const` pointers to the library/matrix/macros at
`SynthVoice.h:24,32-33`.
### Containers and buffers
- `std::vector<float>` is the standard delay line / table buffer: `Wavetable.h:24`
(`frames`), `Filter.h:39` (`combLine`), `Chorus.h:21`, `Delay.h:21`, `Reverb.h:22-29`.
- `std::array` is used for fixed-size DSP state: `Oscillator.h:62`
(`std::array<SubVoice, kMaxUnison>`), `Filter.h:36,44`, `Reverb.h:22-27`.
- `juce::AudioBuffer<float>` is used for block buffers: `Engine.h:59` (`mixBuffer`),
`SynthVoice.h:95` (`scratch`), `FXProcessor.h:51` (`dry`, `wet`). These are sized once in
`prepare()` (for example `Engine.cpp:68`, `SynthVoice.cpp:23`, `FXProcessor.cpp:34-35`)
and cleared per block.
### Realtime vs non-realtime allocation
The code intends to allocate only at prepare time, but it does not fully achieve this:
- Prepare-time allocation is the norm: delay lines and buffers are sized in `prepare()`
(`Filter.cpp:24`, `Chorus.cpp:9-11`, `Reverb.cpp:17-33`, `Engine.cpp:68`).
- Violation 1: `Engine::processBlock` calls `mixBuffer.setSize (2, n, false, false, true)`
on every block at `Engine.cpp:211`. `AudioBuffer::setSize` is a no-op when the size is
unchanged, but it is still a per-callback reconfiguration call on the audio thread.
- Violation 2: wavetables are built lazily. `WavetableLibrary::getTable` builds the table
on first access (`Wavetable.cpp:126-132`) and `getTable` is reached from the audio path at
`SynthVoice.cpp:177-178`. The `prebuild()` method exists at `Wavetable.cpp:120-124` but is
never called (it is not referenced anywhere else, and `Engine::prepare` at
`Engine.cpp:58-70` does not call it). The first rendered block for a wavetable therefore
allocates `256 x 2048` floats plus harmonic work buffers on the audio thread.
---
## 4. Error handling
There is no exception handling and no user-facing alerting. Failures are handled with a
mix of asserts, null guards, and return codes.
- `jassert` is used exactly once in the entire codebase: `jassert (freqHz > 0.0)` at
`Oscillator.cpp:20`. There are no `throw`, `try` or `catch` statements (verified by grep).
- Null guards with silent fallback are the dominant pattern. The APVTS helper `v()` returns
`0.0f` when a parameter is missing: `Engine.cpp:8-13`. State restore silently returns on
malformed input: `PluginProcessor.cpp:313-315` returns when the XML is null and
`PluginProcessor.cpp:318-319` returns when the `ValueTree` is invalid. Preset loads skip
missing parameters with `if (auto* param = ...)` at `PluginProcessor.cpp:248-250`.
- Return codes carry failure for the two bounded collections:
`ModulationMatrix::addConnection` returns `false` when the target is `None` or the
connection limit is reached (`ModulationMatrix.cpp:6-12`), and
`MacroControls::addAssignment` returns `false` similarly (`MacroControls.cpp:6-13`).
These return values are ignored by their callers (`PluginEditor.cpp:536`, `PluginEditor.cpp:687`,
`PluginProcessor.cpp:254`, `PluginProcessor.cpp:258`).
- Out-of-range lookups degrade to empty/default values: `getProgramName` returns an empty
string for a bad index (`PluginProcessor.cpp:218-224`); the enum-string converters fall
through to `return {}` (`Params.cpp:27,44,91`).
- There are no `juce::AlertWindow`, no `MessageManager` calls, and no logging in the plugin
itself. The only reporting is the test harness printing to `std::cout` and returning an
exit code (`TestMain.cpp:73,103,129,132-134`).
Net style: programmer errors use `jassert` (very sparingly), runtime/state errors use null
guards or default values, and bounded-insertion failures use `bool` return codes that
callers currently ignore.
---
## 5. Concurrency
The codebase is single-threaded and has no explicit synchronisation. A grep for threads,
mutexes, locks, atomics, `CriticalSection`, `SpinLock` and `ScopedLock` returns nothing
outside JUCE's own headers.
- The only non-audio execution context is the JUCE message thread, driven by a `juce::Timer`:
`PluginEditor` inherits `juce::Timer` (`PluginEditor.h:24`), starts it at 30 Hz
(`PluginEditor.cpp:135`), and refreshes visuals in `timerCallback` (`PluginEditor.cpp:875-880`).
- The audio thread is `SerumAltAudioProcessor::processBlock` (`PluginProcessor.cpp:187-191`)
delegating to `Engine::processBlock` (`Engine.cpp:176-318`). It reads the playhead tempo at
`Engine.cpp:184-187`.
The significant issue is unsynchronised sharing between the message thread and the audio
thread:
- The GUI mutates `ModulationMatrix::connections` via `addConnection`/`removeConnection`/
`clear` (`PluginEditor.cpp:536,549,559`) while the audio thread iterates the same vector in
`SynthVoice::render` at `SynthVoice.cpp:114`. `connections` is a public `std::vector`
(`ModulationMatrix.h:30`).
- The GUI mutates `MacroControls::assignments` (`PluginEditor.cpp:687,699`) while the audio
thread iterates it at `SynthVoice.cpp:125`. `assignments` is a public `std::array` of
vectors (`MacroControls.h:28`).
- The GUI writes LFO shape data through `engine.setLfoShapeData` (`PluginEditor.cpp:483-486`)
which calls `LFO::setShapeData` (`Engine.cpp:84-88`, `LFO.cpp:51-59`), while the audio
thread reads the same `shapeBuffer` in `LFO::shapeValue` (`LFO.cpp:82-91`).
None of these shared collections are locked or double-buffered. This is a real data race
today: resizing `connections` while the audio thread iterates it is undefined behaviour.
The processor header acknowledges GUI-thread access but does not address the race: the
comment "Public DSP state (read/write from the GUI thread)" at `PluginProcessor.h:55` only
describes intent.
---
## 6. Naming & style conventions
- Namespace: every file is inside `namespace serum` (`PluginProcessor.h:8`, `Engine.h:12`,
etc.). File-local helpers use an anonymous namespace (`Engine.cpp:6`, `SynthVoice.cpp:6`,
`PluginEditor.cpp:6`, `FactoryPresets.cpp:6`).
- Include guard: `#pragma once` in every header (for example `PluginProcessor.h:1`,
`Params.h:1`, `Biquad.h:1`). No `#ifndef` guards exist.
- Include order: `<JuceHeader.h>` first, then local headers (`PluginProcessor.h:3-6`).
Subdirectory files include root files with a `../` relative path (`Hyper.h:3`,
`Knob.h:5`, `FactoryPresets.h:4`).
- File/header pairs: one primary class per `.h`/`.cpp` pair, with the pair named after the
class (`Oscillator.h`/`Oscillator.cpp`, `FilterBank.h`/`FilterBank.cpp`). Header-only
helpers exist where they have no state logic worth a TU (`Biquad.h`,
`SerumLookAndFeel.h`).
- Class and struct names: PascalCase, no prefix (`SynthVoice`, `RenderContext`,
`FilterBankParams`, `ModConnection`).
- Members: no `m_` prefix and no trailing underscore. Plain camelCase fields such as
`note`, `velocity`, `baseFreq`, `active`, `released`, `noteId` at `SynthVoice.h:83-88`,
and `sr`, `blockSize`, `bpm`, `pitchBend` at `Engine.h:52-57`.
- Constants: `k` prefix for compile-time constants (`kNumVoices`, `kNumLfos` at
`Params.h:221-227`; `kFrames`, `kTableSize` at `Wavetable.h:18-19`; `kMaxConnections` at
`ModulationMatrix.h:28`; `kMaxAssignments` at `MacroControls.h:26`). Local constant arrays
in anonymous namespaces also use `k` (`kEnvAttack` at `Engine.cpp:30`, `kFxType` at
`PluginEditor.cpp:97`).
- Enums: `enum class` (scoped) with PascalCase enumerators (`WarpMode::BendPlus`,
`ModSource::Lfo1`, `ModTarget::Filter1Cutoff`) at `Params.h:213-219` and `Params.h:232-256`.
- Namespaces for related constants: `ids::` for parameter ID strings (`Params.h:18`),
`theme::` for colours (`Resources.h:12`), `maps::` for unit mappings (`Params.h:309`).
- Method naming: camelCase with a leading verb (`noteOn`, `processAdd`, `getValue`).
JUCE overrides keep JUCE's spelling (`processBlock`, `prepareToPlay`, `resized`,
`timerCallback`).
- `noexcept` is applied consistently to the per-sample DSP hot paths (`Oscillator.cpp:93`,
`Filter.cpp:181,236`, `Wavetable.cpp:81`, `Envelope.cpp:54`, `SynthVoice.cpp:72`,
`LFO.cpp:98`) and to cheap getters (`SynthVoice.h:71-74`).
- Formatting: two-space indentation; a space before the opening parenthesis in function
calls and definitions (`f (x)` at `PluginProcessor.cpp:12`, `juce::jlimit (...)`;
`if (...)`, `for (...)`). Aligned member initialiser lists (`PluginEditor.cpp:113-115`).
- Section dividers: `// ===...` banners at class/file tops and `// ---...` dividers within
files (`PluginProcessor.cpp:17,198,261`, `PluginEditor.cpp:161`).
- Casting idiom: `(size_t)` casts on int loop indices when indexing `std::vector`/`std::array`
(`Engine.cpp:220`, `SynthVoice.cpp:79`, `FactoryPresets.cpp` throughout), and `(int)`
casts on `size()` results.
---
## 7. Anti-pattern catalog
Each entry lists the location, the problem, and the standard that a later AGENT.md phase
should encode.
1. Parameter ID tables duplicated three times.
- `Params.h:18-208` (canonical `ids::`), `Engine.cpp:30-55` (`kEnv*`, `kLfo*`, `kFx*`),
`PluginEditor.cpp:97-108,433-437,458-464,820-825,833` (the same arrays again), plus the
display names and defaults in `PluginProcessor.cpp:31-171`.
- Standard: one source of truth in `Params.h` (or a single generated table); engine and
editor must reference it rather than re-declaring ID arrays.
2. Wavetable library allocates on the audio thread.
- `WavetableLibrary::getTable` builds lazily (`Wavetable.cpp:126-132`), reached from
`SynthVoice::render` (`SynthVoice.cpp:177-178`); `prebuild()` is defined
(`Wavetable.cpp:120-124`) but never called.
- Standard: call `prebuild()` (or build all tables) in `prepareToPlay`, never in the
render path.
3. Per-block buffer resize in the audio callback.
- `Engine::processBlock` calls `mixBuffer.setSize(...)` every block at `Engine.cpp:211`.
- Standard: size the buffer once in `prepare()` and only `clear()` per block.
4. Unsynchronised GUI/audio sharing of container state.
- `ModulationMatrix::connections` is public (`ModulationMatrix.h:30`), written by the GUI
(`PluginEditor.cpp:536,549,559`) and read on the audio thread (`SynthVoice.cpp:114`).
`MacroControls::assignments` is public (`MacroControls.h:28`), written
(`PluginEditor.cpp:687,699`) and read (`SynthVoice.cpp:125`). LFO `shapeBuffer` is
written via `setShapeData` (`LFO.cpp:51-59`) and read (`LFO.cpp:82-91`).
- Standard: either protect these collections with a lock, or use atomic/double-buffered
exchange between the message and audio threads.
5. Class name does not match its file.
- `Source/RAVEButton.h` declares `RaveController` (`RAVEButton.h:14`), and
`RAVEButton.cpp` implements it.
- Standard: file pair name matches the class (`RaveController.h/.cpp`), or the class is
renamed to match the file.
6. Dead code in `SubOscillator::noteOn`.
- `SubOscillator.cpp:9-12` computes `mult` and then discards it with `(void) freqHz;
(void) mult;` because the octave shift is already baked into the frequency by the voice.
- Standard: remove the unused parameters or make the signature reflect what is actually
used (the octave shift is applied at `SynthVoice.cpp:179`).
7. No-op override.
- `changeProgramName` is an empty override at `PluginProcessor.cpp:226-228`.
- Standard: implement it or do not override it.
8. Ignored prepare-time parameter.
- `Filter::prepare` accepts `maxBlockSize` and discards it with `(void) maxBlockSize;`
(`Filter.cpp:21-29`); effect units name the parameter `int` but never use it
(`Hyper.cpp:6`, `Chorus.cpp:6`, `Flanger.cpp:6`, etc.).
- Standard: keep the `FXUnit` interface uniform but do not add unused parameters to
concrete `prepare()` implementations.
9. Duplicated envelope curve constants.
- The attack/decay shape mapping `0.3 + curve * 2.7` / `3.0 - curve * 2.7` appears in
`Envelope.cpp:44-45` and again in the display `EnvelopeDisplay.cpp:19-20`.
- Standard: expose one shared helper so the preview cannot drift from the DSP.
10. Filter response reimplemented for display.
- `FilterDisplay::magnitude` (`FilterDisplay.cpp:6-36`) hardcodes a parallel set of
transfer-function approximations that duplicate the real DSP in `Filter.cpp`
(`Filter.cpp:87-234`).
- Standard: keep the display response derived from one documented source, or mark it as
an approximation with a comment that the two are not the same code.
11. Inconsistent pi constants.
- `kTwoPi` is defined at `Oscillator.h:67` and `SubOscillator.h:26`; `twoPi` at
`Wavetable.cpp:21`; `kPi` at `Wavetable.cpp:156` and `Filter.cpp:8`; raw literals
`6.28318530717958647692` appear at `Chorus.cpp:40-41`, `Flanger.cpp:38-39`,
`Phaser.cpp:30-31`, and `6.2831853f` at `LFO.cpp:67` and `LFODisplay.cpp:19`.
- Standard: use `juce::MathConstants<T>::pi` / `twoPi` everywhere.
12. Public mutable state exposes internals.
- `PluginProcessor.h:56-58` exposes `parameters`, `engine` and `rave` as public fields;
`ModulationMatrix.h:30` and `MacroControls.h:28` expose their collections;
`Engine.h:36-38` returns non-const references from `getMatrix()`, `getMacros()` and
`getWavetables()`.
- Standard: return `const&` from accessors and route mutation through member functions,
except where a public field is an explicit, documented design choice.
13. Unchecked raw parameter dereference.
- `PluginEditor.cpp:794` dereferences `getRawParameterValue(id)->load()` without a null
check, while the equivalent helper in `Engine.cpp:8-13` guards the pointer.
- Standard: always guard `getRawParameterValue`/`getParameter` results before use.
14. Mixed ownership for GUI children.
- `makeKnob`/`makeCombo`/`makeToggle` return raw pointers to `new`-allocated children
that JUCE owns via `addAndMakeVisible` (`PluginEditor.cpp:162-199`), and `fxUp`/
`fxDown` use raw `new` at `PluginEditor.cpp:602,606`. This is correct only because of
JUCE's component-tree ownership, but it is easy to misread as a leak.
- Standard: document that JUCE-owned children are raw pointers and never deleted, and
keep `std::unique_ptr` for everything the plugin owns directly.
---
## 8. Hard constraints worth codifying
Only rules that the current code and build actually support are listed.
1. Do not allocate or resize containers in the audio callback. Evidence that this is the
intent: all DSP state is sized in `prepare()` (`Engine.cpp:68`, `SynthVoice.cpp:23`,
`FXProcessor.cpp:34-35`) and the code uses pre-sized `juce::AudioBuffer` and
`std::array`. The current violations (`Engine.cpp:211` and the lazy wavetable build) are
exceptions to fix, not the rule.
2. All audio parameters are normalised to 0..1 in the APVTS, and physical units are mapped
only through `maps::` (`Params.h:309-342`). DSP must consume the normalised values, not
physical units.
3. Parameter IDs live in the `ids` namespace in `Params.h` (`Params.h:18-208`). Engine,
editor and preset code must reference `ids::`, not re-declare string literals. (This is
the stated intent at `Params.h:5-9`; the current duplication in `Engine.cpp` and
`PluginEditor.cpp` is a violation to eliminate.)
4. The CMake source list is explicit (`CMakeLists.txt:50-87`), not a glob. Every file move
or rename must update `SERUMALT_SOURCES`.
5. All code is inside `namespace serum`, and every header uses `#pragma once` (verified
across all headers).
6. No exceptions and no user-facing alert dialogs. Use `jassert` for programmer errors,
null guards for runtime lookups, and `bool` returns for bounded inserts (see
`Oscillator.cpp:20`, `Engine.cpp:8-13`, `ModulationMatrix.cpp:6-12`).
7. When cross-compiling for Windows, keep the VST3 manifest step disabled and let
`build_windows.sh` inject `moduleinfo.json`, because JUCE's manifest helper must not be
built as a Windows executable on a Linux host (`CMakeLists.txt:23-31`,
`build_windows.sh:55-126`). Keep the injected metadata in sync with
`PLUGIN_CODE`/`PLUGIN_MANUFACTURER_CODE`/`VERSION` (`build_windows.sh:60-62`).
8. The project is single-threaded by design. Any future thread, mutex or atomic must come
with an explicit plan for the GUI-to-audio boundary, because the current matrix/macro/LFO
state is shared without synchronisation (section 5).
+95
View File
@@ -0,0 +1,95 @@
cmake_minimum_required(VERSION 3.22)
project(SerumAlt VERSION 1.0.0 LANGUAGES CXX)
# ---------------------------------------------------------------------------
# C++ standard
# ---------------------------------------------------------------------------
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(SERUMALT_BUILD_PLUGIN "Build production plugin and standalone formats" ON)
option(SERUMALT_BUILD_TESTS "Build the headless QA harness" OFF)
if(NOT SERUMALT_BUILD_PLUGIN AND NOT SERUMALT_BUILD_TESTS)
message(FATAL_ERROR "Enable SERUMALT_BUILD_PLUGIN or SERUMALT_BUILD_TESTS")
endif()
# ---------------------------------------------------------------------------
# JUCE
# ---------------------------------------------------------------------------
if(NOT DEFINED JUCE_ROOT)
set(JUCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/third_party/JUCE")
endif()
add_subdirectory("${JUCE_ROOT}" "${CMAKE_CURRENT_BINARY_DIR}/third_party/JUCE")
set(SERUMALT_SOURCES
Source/PluginProcessor.cpp
Source/PluginEditor.cpp
Source/Params.cpp
Source/Wavetable.cpp
Source/Oscillator.cpp
Source/SubOscillator.cpp
Source/NoiseOscillator.cpp
Source/Filter.cpp
Source/FilterBank.cpp
Source/Envelope.cpp
Source/LFO.cpp
Source/ModulationMatrix.cpp
Source/FXProcessor.cpp
Source/MacroControls.cpp
Source/RAVEButton.cpp
Source/SynthVoice.cpp
Source/Engine.cpp
Source/Resources.cpp
Source/EffectUnits/Hyper.cpp
Source/EffectUnits/Chorus.cpp
Source/EffectUnits/Flanger.cpp
Source/EffectUnits/Phaser.cpp
Source/EffectUnits/Distortion.cpp
Source/EffectUnits/EQ.cpp
Source/EffectUnits/Compressor.cpp
Source/EffectUnits/Delay.cpp
Source/EffectUnits/Reverb.cpp
Source/GUI/Knob.cpp
Source/GUI/Slider.cpp
Source/GUI/ToggleButton.cpp
Source/GUI/Display.cpp
Source/GUI/WaveformDisplay.cpp
Source/GUI/FilterDisplay.cpp
Source/GUI/EnvelopeDisplay.cpp
Source/GUI/LFODisplay.cpp
Source/GUI/Panel.cpp
Source/Presets/FactoryPresets.cpp)
function(serumalt_configure_target target)
juce_generate_juce_header(${target})
target_sources(${target} PRIVATE ${SERUMALT_SOURCES})
target_compile_definitions(${target}
PUBLIC
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0)
target_link_libraries(${target}
PRIVATE
juce::juce_audio_utils
juce::juce_dsp
PUBLIC
juce::juce_recommended_config_flags
juce::juce_recommended_warning_flags)
if(MSVC)
target_compile_options(${target} PRIVATE /W4)
else()
target_compile_options(${target} PRIVATE -Wall -Wextra)
endif()
endfunction()
if(SERUMALT_BUILD_PLUGIN)
include(cmake/Plugin.cmake)
endif()
if(SERUMALT_BUILD_TESTS)
enable_testing()
include(cmake/Harness.cmake)
endif()
+159 -22
View File
@@ -68,36 +68,173 @@ macro assignments and LFO shape data.
## Building ## Building
Requirements: CMake ≥ 3.22, a C++17 compiler, and the Linux dev libraries Each script configures and builds its own directory. Production builds disable the
(`libasound2-dev`, `libjack-dev`, `libfreetype-dev`, `libcurl`, X11, OpenGL). QA harness explicitly; the harness script creates no plugin or standalone targets.
JUCE 7.0.12 is vendored under `third_party/JUCE`. JUCE 7.0.12 is vendored under `third_party/JUCE` and is not downloaded by the scripts.
### Prerequisites
All scripts require Bash, CMake 3.22+, Ninja, and C/C++17 compilers. The harness
also uses CTest, included with CMake, when invoked with `--run`.
On Linux, install GCC or Clang, `pkg-config`, and the ALSA, FreeType, X11 and OpenGL
development libraries. For Debian/Ubuntu:
```bash ```bash
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release sudo apt update
cmake --build build sudo apt install build-essential cmake ninja-build pkg-config \
libasound2-dev libfreetype6-dev libx11-dev libxcomposite-dev \
libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev libxrender-dev \
libgl1-mesa-dev
``` ```
Outputs: For Arch/CachyOS:
- `build/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3` — VST3
- `build/SerumAlt_artefacts/Release/Standalone/SerumAlt` — standalone app
- (on macOS, the `AU` target is produced automatically)
Cross-compilation for Windows/macOS is handled by JUCE: configure with the relevant
toolchain and add `-DCMAKE_TOOLCHAIN_FILE=...` as usual.
## Headless QA harness
A console app drives the processor through every factory preset, plays a chord,
verifies finite (non-NaN) output and non-silence, and exercises the RAVE toggle.
Build and run with:
```bash ```bash
cmake --build build --target SerumAltTest sudo pacman -S --needed base-devel cmake ninja pkgconf alsa-lib freetype2 \
./build/SerumAltTest_artefacts/Release/SerumAltTest libx11 libxcomposite libxcursor libxext libxinerama libxrandr libxrender libglvnd
``` ```
(Disable it with `-DSERUMALT_BUILD_TESTS=OFF`.) JACK is disabled by JUCE's default `JUCE_JACK=0`. Curl and the web browser are
explicitly disabled in both project targets. Their development packages are not
required for these builds, even though JUCE probes for them during configuration.
See `third_party/JUCE/docs/Linux Dependencies.md` if enabling additional JUCE modules.
On macOS, install Apple's Command Line Tools with `xcode-select --install`, or
select an installed Xcode toolchain. Install CMake and Ninja, for example with
`brew install cmake ninja` if Homebrew is already installed. The macOS script checks
that `xcrun` can locate the macOS SDK. It must run on a Mac; JUCE does not provide
an Apple cross-compilation toolchain for Linux.
For Windows cross-compilation, also install MinGW-w64 on the Linux host:
```bash
sudo apt install gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 binutils-mingw-w64-x86-64
```
On Arch/CachyOS, use `sudo pacman -S --needed mingw-w64-gcc` instead. Keep the
native Linux tools and libraries installed: JUCE builds `juceaide` for the host
before compiling the Windows plugin. The checked-in `mingw64-toolchain.cmake`
selects the `x86_64-w64-mingw32-*` tools and isolates target library searches.
### Production builds
| Host | Command | Build directory | Formats |
| --- | --- | --- | --- |
| Linux | `./build_linux.sh` | `build_linux/` | VST3, Standalone |
| macOS | `./build_macos.sh` | `build_macos/` | VST3, AU, Standalone |
| Linux targeting Windows x64 | `./build_windows.sh` | `build_windows/` | VST3 only |
Default Release artifacts:
- Linux: `build_linux/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3` and
`build_linux/SerumAlt_artefacts/Release/Standalone/SerumAlt`.
- macOS: `build_macos/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3`,
`build_macos/SerumAlt_artefacts/Release/AU/SerumAlt.component`, and
`build_macos/SerumAlt_artefacts/Release/Standalone/SerumAlt.app`.
- Windows: `build_windows/SerumAlt_artefacts/Release/VST3/SerumAlt.vst3`, also
copied to `SerumAlt_Windows/SerumAlt.vst3`.
The Windows script reuses the toolchain file without rewriting it. It injects
`moduleinfo.json` because the cross-compiled JUCE manifest helper cannot run on
Linux. Keep this manifest's metadata in sync with `CMakeLists.txt`,
`cmake/Plugin.cmake`, and the vendored VST3 SDK. MinGW links its C++ and threading
runtimes statically via `-static-libgcc -static-libstdc++ -static`.
macOS builds target the host architecture by default. To request a universal binary:
```bash
CMAKE_OSX_ARCHITECTURES='arm64;x86_64' ./build_macos.sh
```
`CMAKE_OSX_DEPLOYMENT_TARGET` can also be passed to that script. Architecture and
SDK settings persist in CMake's cache. Use a separate build directory when changing
toolchains. These scripts build local artifacts, not signed or notarized releases,
and do not install plugins into a DAW's plugin directories.
### Independent QA harness
On Linux or macOS, build the console harness without production targets:
```bash
./build_harness.sh
```
Build and run the checks:
```bash
./build_harness.sh --run
```
Or rerun an already-built Release harness:
```bash
ctest --test-dir build_harness --build-config Release --output-on-failure --no-tests=error
```
The executable is `build_harness/SerumAltTest_artefacts/Release/SerumAltTest`.
It renders the initial state and every factory preset, checks for non-finite
samples and aggregate non-silence, and exercises the RAVE toggle. A failed check
returns a nonzero exit status. This is a processor integration smoke test, not a
complete DSP, GUI, or real-time safety test suite.
The harness does not open an editor or audio device, but it still compiles the real
processor, editor and JUCE GUI dependencies. Its separate build directory and
CMake options isolate its outputs from production bundles; it is not a DSP-only
library. It is built and run natively, not as a Windows cross-compiled executable.
### Build options and layout
- `BUILD_TYPE` defaults to `Release`; set it to `Debug` for a debug build.
- `BUILD_DIR` overrides a script's build directory. Relative paths are resolved
from the repository root, even when the script is invoked elsewhere.
- `CMAKE_BUILD_PARALLEL_LEVEL` defaults to `2` to limit memory use, including JUCE's
configure-time helper build. Increase it if memory permits.
- Windows-only `OUTPUT_DIR` overrides the staging directory.
For example:
```bash
BUILD_TYPE=Debug BUILD_DIR=build_harness_debug ./build_harness.sh --run
CMAKE_BUILD_PARALLEL_LEVEL=4 ./build_linux.sh
```
`CMakeLists.txt` owns the shared source list and target configuration.
`cmake/Plugin.cmake` owns production formats and platform-specific linking.
`cmake/Harness.cmake` owns the test executable and its CTest registration.
For direct CMake use, `SERUMALT_BUILD_PLUGIN` defaults to `ON` and
`SERUMALT_BUILD_TESTS` defaults to `OFF`. A harness-only configure is:
```bash
cmake -S . -B build_harness -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DSERUMALT_BUILD_PLUGIN=OFF -DSERUMALT_BUILD_TESTS=ON
cmake --build build_harness --target SerumAltTest
```
An existing CMake cache retains its old option values; changing the defaults does
not turn tests off in an old `build/` directory. Use the scripts to set the flags
explicitly. An alternate JUCE checkout can be selected with `-DJUCE_ROOT=/path/to/JUCE`
when configuring directly with CMake.
### Fresh builds
The scripts reuse their build directories without deleting files, resetting Git,
or installing packages. Generated `build/`, `build_*/`, and `SerumAlt_Windows/`
directories are git-ignored. Leave existing builds intact and select an unused
build directory for a fresh configure:
```bash
BUILD_DIR=build_linux_fresh ./build_linux.sh
```
Windows staging updates generated files without deleting extra files already in
the destination. For a clean distribution bundle, choose unused build and output
paths together:
```bash
BUILD_DIR=build_windows_fresh OUTPUT_DIR=build_windows_fresh/staged ./build_windows.sh
```
## License ## License
+53
View File
@@ -0,0 +1,53 @@
# effects module
## Owned files
- Related rack implementation: `../FXProcessor.h`, `../FXProcessor.cpp`
- Biquad.h
- Hyper.h, Hyper.cpp
- Chorus.h, Chorus.cpp
- Flanger.h, Flanger.cpp
- Phaser.h, Phaser.cpp
- Distortion.h, Distortion.cpp
- EQ.h, EQ.cpp
- Compressor.h, Compressor.cpp
- Delay.h, Delay.cpp
- Reverb.h, Reverb.cpp
## Rules
- Implement the `FXUnit` interface with `prepare`, `reset` and `process (float* l, float* r, int numSamples, const float p[4])`.
- Process full-wet in place; FXProcessor handles the dry/wet mix.
- Document what p[0] through p[3] mean for each unit in the header comment.
- Use `std::vector<float>` for delay lines and size them in `prepare()`.
- Use `juce::MathConstants<T>::pi` and `twoPi` in delay LFOs, not raw literals.
- Never allocate or resize a delay line inside `process`.
- Guard empty delay lines with an early `if (len < 4) return;`.
## IF-THEN
- IF a unit needs a delay line THEN allocate it in `prepare()` at the full required length and only read and write it in `process`.
- Include the existing rack interface via `../FXProcessor.h`; no source relocation is implied by this guidance.
## Examples
```cpp
// BAD: reallocates a delay line on the audio thread
void process (float* l, float* r, int n, const float p[4]) override
{
std::vector<float> delay ((size_t) (sr * 0.05), 0.0f);
...
}
```
```cpp
// GOOD: sized once at prepare time, reused per block
void prepare (double sampleRate, int) override
{
sr = sampleRate;
delayL.assign ((size_t) (sr * 0.05), 0.0f);
delayR.assign ((size_t) (sr * 0.05), 0.0f);
}
```
This file overrides /AGENT.md where they conflict.
+107
View File
@@ -0,0 +1,107 @@
#pragma once
#include <JuceHeader.h>
namespace serum
{
// ===========================================================================
// Small RBJ-cookbook biquad (direct form 1) used by EQ and the Hyper crossover.
// ===========================================================================
class Biquad
{
public:
void setSampleRate (double sr) noexcept { sampleRate = sr; }
void clear() noexcept
{
x1 = x2 = y1 = y2 = 0.0;
}
float process (float in) noexcept
{
const double out = b0 * in + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
x2 = x1; x1 = in;
y2 = y1; y1 = out;
return (float) out;
}
void setLowpass (double freq, double q)
{
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
const double cw = std::cos (w0);
const double sw = std::sin (w0);
const double alpha = sw / (2.0 * q);
const double a0 = 1.0 + alpha;
b0 = ((1.0 - cw) / 2.0) / a0;
b1 = (1.0 - cw) / a0;
b2 = b0;
a1 = (-2.0 * cw) / a0;
a2 = (1.0 - alpha) / a0;
}
void setHighpass (double freq, double q)
{
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
const double cw = std::cos (w0);
const double sw = std::sin (w0);
const double alpha = sw / (2.0 * q);
const double a0 = 1.0 + alpha;
b0 = ((1.0 + cw) / 2.0) / a0;
b1 = -(1.0 + cw) / a0;
b2 = b0;
a1 = (-2.0 * cw) / a0;
a2 = (1.0 - alpha) / a0;
}
void setLowShelf (double freq, double gainDb)
{
const double a = std::pow (10.0, gainDb / 40.0);
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
const double cw = std::cos (w0);
const double sw = std::sin (w0);
const double alpha = sw / 2.0 * std::sqrt (2.0);
const double a0 = (a + 1.0) + (a - 1.0) * cw + 2.0 * std::sqrt (a) * alpha;
b0 = (a * ((a + 1.0) - (a - 1.0) * cw + 2.0 * std::sqrt (a) * alpha)) / a0;
b1 = (2.0 * a * ((a - 1.0) - (a + 1.0) * cw)) / a0;
b2 = (a * ((a + 1.0) - (a - 1.0) * cw - 2.0 * std::sqrt (a) * alpha)) / a0;
a1 = (-2.0 * ((a - 1.0) + (a + 1.0) * cw)) / a0;
a2 = ((a + 1.0) + (a - 1.0) * cw - 2.0 * std::sqrt (a) * alpha) / a0;
}
void setHighShelf (double freq, double gainDb)
{
const double a = std::pow (10.0, gainDb / 40.0);
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
const double cw = std::cos (w0);
const double sw = std::sin (w0);
const double alpha = sw / 2.0 * std::sqrt (2.0);
const double a0 = (a + 1.0) - (a - 1.0) * cw + 2.0 * std::sqrt (a) * alpha;
b0 = (a * ((a + 1.0) + (a - 1.0) * cw + 2.0 * std::sqrt (a) * alpha)) / a0;
b1 = (-2.0 * a * ((a - 1.0) + (a + 1.0) * cw)) / a0;
b2 = (a * ((a + 1.0) + (a - 1.0) * cw - 2.0 * std::sqrt (a) * alpha)) / a0;
a1 = (2.0 * ((a - 1.0) - (a + 1.0) * cw)) / a0;
a2 = ((a + 1.0) - (a - 1.0) * cw - 2.0 * std::sqrt (a) * alpha) / a0;
}
void setPeak (double freq, double gainDb, double q)
{
const double a = std::pow (10.0, gainDb / 40.0);
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
const double cw = std::cos (w0);
const double sw = std::sin (w0);
const double alpha = sw / (2.0 * q);
const double a0 = 1.0 + alpha / a;
b0 = (1.0 + alpha * a) / a0;
b1 = (-2.0 * cw) / a0;
b2 = (1.0 - alpha * a) / a0;
a1 = (-2.0 * cw) / a0;
a2 = (1.0 - alpha / a) / a0;
}
double sampleRate = 44100.0;
double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0;
double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0;
};
} // namespace serum
+63
View File
@@ -0,0 +1,63 @@
#include "Chorus.h"
namespace serum
{
void ChorusUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
const int maxDelay = (int) (sr * 0.05); // 50ms
delayL.assign ((size_t) maxDelay, 0.0f);
delayR.assign ((size_t) maxDelay, 0.0f);
reset();
}
void ChorusUnit::reset()
{
std::fill (delayL.begin(), delayL.end(), 0.0f);
std::fill (delayR.begin(), delayR.end(), 0.0f);
writePos = 0;
lfoPhase = 0.0;
}
void ChorusUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const int len = (int) delayL.size();
if (len < 4)
return;
const double rate = 0.1 + p[0] * 4.9; // 0.1..5 Hz
const double depth = (0.5 + p[1] * 5.5) * sr / 1000.0; // 0.5..6 ms
const double base = 8.0 * sr / 1000.0; // 8 ms centre
const float width = p[2];
for (int i = 0; i < numSamples; ++i)
{
const float inL = l[i], inR = r[i];
delayL[(size_t) writePos] = inL;
delayR[(size_t) writePos] = inR;
lfoPhase += 6.28318530717958647692 * rate / sr;
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
float sumL = 0.0f, sumR = 0.0f;
for (int v = 0; v < 2; ++v)
{
const double mod = std::sin (lfoPhase + v * 3.14159265358979323846) * depth;
const double d = base + mod + (v == 1 ? width * depth * 0.5 : 0.0);
double readPos = writePos - d;
if (readPos < 0.0) readPos += len;
const int i0 = (int) readPos;
const int i1 = (i0 + 1) % len;
const float frac = (float) (readPos - std::floor (readPos));
sumL += delayL[(size_t) i0] * (1.0f - frac) + delayL[(size_t) i1] * frac;
sumR += delayR[(size_t) i0] * (1.0f - frac) + delayR[(size_t) i1] * frac;
}
l[i] = sumL * 0.5f;
r[i] = sumR * 0.5f;
writePos = (writePos + 1) % len;
}
}
} // namespace serum
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "../FXProcessor.h"
namespace serum
{
// ===========================================================================
// Chorus — two LFO-modulated delay taps per channel. p[0]=rate, p[1]=depth,
// p[2]=width, p[3]=unused.
// ===========================================================================
class ChorusUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
double sr = 44100.0;
std::vector<float> delayL, delayR;
int writePos = 0;
double lfoPhase = 0.0;
};
} // namespace serum
+52
View File
@@ -0,0 +1,52 @@
#include "Compressor.h"
namespace serum
{
void CompressorUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
reset();
}
void CompressorUnit::reset()
{
envL = envR = 0.0f;
gainL = gainR = 1.0f;
}
void CompressorUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const double thresholdDb = -40.0 + p[0] * 40.0; // -40..0 dB
const double ratio = 2.0 + p[1] * 18.0; // 2..20
const double attackMs = 2.0 + p[2] * 98.0;
const double releaseMs = 30.0 + p[3] * 470.0;
const double attackCoef = std::exp (-1.0 / (attackMs / 1000.0 * sr));
const double releaseCoef = std::exp (-1.0 / (releaseMs / 1000.0 * sr));
const double threshold = std::pow (10.0, thresholdDb / 20.0);
const double makeup = std::pow (10.0, -thresholdDb * 0.5 / 20.0);
for (int i = 0; i < numSamples; ++i)
{
const float aL = std::abs (l[i]);
const float aR = std::abs (r[i]);
envL = aL > envL ? (float) (attackCoef * envL + (1.0 - attackCoef) * aL)
: (float) (releaseCoef * envL + (1.0 - releaseCoef) * aL);
envR = aR > envR ? (float) (attackCoef * envR + (1.0 - attackCoef) * aR)
: (float) (releaseCoef * envR + (1.0 - releaseCoef) * aR);
const double targetL = (envL > threshold) ? threshold * std::pow (envL / threshold, 1.0 / ratio) : envL;
const double targetR = (envR > threshold) ? threshold * std::pow (envR / threshold, 1.0 / ratio) : envR;
const float desiredL = (float) juce::jlimit (0.05, 1.0, (envL > 1e-6) ? targetL / envL : 1.0);
const float desiredR = (float) juce::jlimit (0.05, 1.0, (envR > 1e-6) ? targetR / envR : 1.0);
gainL += 0.01f * (desiredL - gainL);
gainR += 0.01f * (desiredR - gainR);
l[i] = (float) (l[i] * gainL * makeup);
r[i] = (float) (r[i] * gainR * makeup);
}
}
} // namespace serum
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "../FXProcessor.h"
namespace serum
{
// ===========================================================================
// Compressor — soft-knee RMS compressor with makeup gain. p[0]=threshold,
// p[1]=ratio, p[2]=attack, p[3]=release.
// ===========================================================================
class CompressorUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
double sr = 44100.0;
float envL = 0.0f, envR = 0.0f;
float gainL = 1.0f, gainR = 1.0f;
};
} // namespace serum
+62
View File
@@ -0,0 +1,62 @@
#include "Delay.h"
namespace serum
{
void DelayUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
const int maxDelay = (int) (sr * 2.1); // 2.1 seconds
delayL.assign ((size_t) maxDelay, 0.0f);
delayR.assign ((size_t) maxDelay, 0.0f);
reset();
}
void DelayUnit::reset()
{
std::fill (delayL.begin(), delayL.end(), 0.0f);
std::fill (delayR.begin(), delayR.end(), 0.0f);
writePos = 0;
dampL = dampR = 0.0f;
}
void DelayUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const int len = (int) delayL.size();
if (len < 4)
return;
const double delaySamples = juce::jlimit (2.0, (double) len - 2.0, maps::delayToMs (p[0]) / 1000.0 * sr);
const float feedback = p[1] * 0.92f;
const float dampCoef = 1.0f - p[2] * 0.95f; // 1 = no damping
const float pingpong = p[3];
for (int i = 0; i < numSamples; ++i)
{
double readPos = writePos - delaySamples;
if (readPos < 0.0) readPos += len;
const int i0 = (int) readPos;
const int i1 = (i0 + 1) % len;
const float frac = (float) (readPos - std::floor (readPos));
float dl = delayL[(size_t) i0] * (1.0f - frac) + delayL[(size_t) i1] * frac;
float dr = delayR[(size_t) i0] * (1.0f - frac) + delayR[(size_t) i1] * frac;
// Damping lowpass in the feedback path.
dampL = dl * (1.0f - dampCoef) + dampL * dampCoef;
dampR = dr * (1.0f - dampCoef) + dampR * dampCoef;
// Ping-pong: cross-feed between channels.
const float feedL = dampR * pingpong + dampL * (1.0f - pingpong);
const float feedR = dampL * pingpong + dampR * (1.0f - pingpong);
delayL[(size_t) writePos] = l[i] + feedL * feedback;
delayR[(size_t) writePos] = r[i] + feedR * feedback;
l[i] = dl;
r[i] = dr;
writePos = (writePos + 1) % len;
}
}
} // namespace serum
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "../FXProcessor.h"
namespace serum
{
// ===========================================================================
// Delay — stereo delay with feedback, damping and ping-pong. p[0]=time,
// p[1]=feedback, p[2]=damping, p[3]=ping-pong/width.
// ===========================================================================
class DelayUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
double sr = 44100.0;
std::vector<float> delayL, delayR;
int writePos = 0;
float dampL = 0.0f, dampR = 0.0f;
};
} // namespace serum
+44
View File
@@ -0,0 +1,44 @@
#include "Distortion.h"
namespace serum
{
void DistortionUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
lpL.setSampleRate (sr); lpR.setSampleRate (sr);
lpL.setLowpass (8000.0, 0.7071); lpR.setLowpass (8000.0, 0.7071);
reset();
}
void DistortionUnit::reset()
{
lpL.clear(); lpR.clear();
}
float DistortionUnit::shape (float x, float shapeParam) noexcept
{
if (shapeParam < 0.33f) return std::tanh (x);
if (shapeParam < 0.66f) return juce::jlimit (-1.0f, 1.0f, x);
return std::sin (x * 1.5707963267948966f); // sine fold
}
void DistortionUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const float drive = 1.0f + p[0] * 24.0f;
const float shapeP = p[1];
const float output = 0.4f + p[3] * 1.2f;
// Tone: re-tune the lowpass from 800..16000 Hz.
const double toneHz = 800.0 + p[2] * 15200.0;
lpL.setLowpass (toneHz, 0.7071);
lpR.setLowpass (toneHz, 0.7071);
for (int i = 0; i < numSamples; ++i)
{
l[i] = lpL.process (shape (l[i] * drive, shapeP) * output);
r[i] = lpR.process (shape (r[i] * drive, shapeP) * output);
}
}
} // namespace serum
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "../FXProcessor.h"
#include "Biquad.h"
namespace serum
{
// ===========================================================================
// Distortion — waveshaper with drive and a post lowpass tone. p[0]=drive,
// p[1]=shape (soft/hard/fold), p[2]=tone, p[3]=output.
// ===========================================================================
class DistortionUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
double sr = 44100.0;
Biquad lpL, lpR;
static float shape (float x, float shapeParam) noexcept;
};
} // namespace serum
+39
View File
@@ -0,0 +1,39 @@
#include "EQ.h"
namespace serum
{
void EQUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
lowL.setSampleRate (sr); lowR.setSampleRate (sr);
midL.setSampleRate (sr); midR.setSampleRate (sr);
highL.setSampleRate (sr); highR.setSampleRate (sr);
reset();
}
void EQUnit::reset()
{
lowL.clear(); lowR.clear(); midL.clear(); midR.clear(); highL.clear(); highR.clear();
}
void EQUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const double lowGain = (p[0] - 0.5f) * 2.0f * 15.0;
const double midGain = (p[1] - 0.5f) * 2.0f * 12.0;
const double highGain = (p[2] - 0.5f) * 2.0f * 15.0;
const double midFreq = 200.0 + p[3] * 4800.0;
lowL.setLowShelf (200.0, lowGain); lowR.setLowShelf (200.0, lowGain);
midL.setPeak (midFreq, midGain, 0.8); midR.setPeak (midFreq, midGain, 0.8);
highL.setHighShelf (4000.0, highGain); highR.setHighShelf (4000.0, highGain);
for (int i = 0; i < numSamples; ++i)
{
const float xL = l[i], xR = r[i];
l[i] = highL.process (midL.process (lowL.process (xL)));
r[i] = highR.process (midR.process (lowR.process (xR)));
}
}
} // namespace serum
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "../FXProcessor.h"
#include "Biquad.h"
namespace serum
{
// ===========================================================================
// EQ — three-band (low shelf / peak / high shelf). p[0]=low gain, p[1]=mid
// gain, p[2]=high gain, p[3]=mid frequency.
// ===========================================================================
class EQUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
double sr = 44100.0;
Biquad lowL, lowR, midL, midR, highL, highR;
};
} // namespace serum
+59
View File
@@ -0,0 +1,59 @@
#include "Flanger.h"
namespace serum
{
void FlangerUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
const int maxDelay = (int) (sr * 0.03); // 30ms
delayL.assign ((size_t) maxDelay, 0.0f);
delayR.assign ((size_t) maxDelay, 0.0f);
reset();
}
void FlangerUnit::reset()
{
std::fill (delayL.begin(), delayL.end(), 0.0f);
std::fill (delayR.begin(), delayR.end(), 0.0f);
writePos = 0;
lfoPhase = 0.0;
}
void FlangerUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const int len = (int) delayL.size();
if (len < 4)
return;
const double rate = 0.05 + p[0] * 2.0;
const double depth = (0.3 + p[1] * 4.7) * sr / 1000.0; // 0.3..5 ms
const double base = 1.5 * sr / 1000.0;
const float feedback = p[2] * 0.9f;
for (int i = 0; i < numSamples; ++i)
{
const float inL = l[i], inR = r[i];
lfoPhase += 6.28318530717958647692 * rate / sr;
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
const double mod = std::sin (lfoPhase) * depth;
double readPos = writePos - (base + mod);
if (readPos < 0.0) readPos += len;
const int i0 = (int) readPos;
const int i1 = (i0 + 1) % len;
const float frac = (float) (readPos - std::floor (readPos));
const float outL = delayL[(size_t) i0] * (1.0f - frac) + delayL[(size_t) i1] * frac;
const float outR = delayR[(size_t) i0] * (1.0f - frac) + delayR[(size_t) i1] * frac;
delayL[(size_t) writePos] = inL + outL * feedback;
delayR[(size_t) writePos] = inR + outR * feedback;
l[i] = outL;
r[i] = outR;
writePos = (writePos + 1) % len;
}
}
} // namespace serum
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "../FXProcessor.h"
namespace serum
{
// ===========================================================================
// Flanger — LFO-modulated delay with feedback. p[0]=rate, p[1]=depth,
// p[2]=feedback, p[3]=unused.
// ===========================================================================
class FlangerUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
double sr = 44100.0;
std::vector<float> delayL, delayR;
int writePos = 0;
double lfoPhase = 0.0;
};
} // namespace serum
+80
View File
@@ -0,0 +1,80 @@
#include "Hyper.h"
namespace serum
{
void HyperUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
lpL.setSampleRate (sr); lpR.setSampleRate (sr);
hpL.setSampleRate (sr); hpR.setSampleRate (sr);
lpL.setLowpass (150.0, 0.7071); lpR.setLowpass (150.0, 0.7071);
hpL.setHighpass (2500.0, 0.7071); hpR.setHighpass (2500.0, 0.7071);
reset();
}
void HyperUnit::reset()
{
lpL.clear(); lpR.clear(); hpL.clear(); hpR.clear();
for (auto& b : bL) { b.env = 0.0f; b.gain = 1.0f; }
for (auto& b : bR) { b.env = 0.0f; b.gain = 1.0f; }
}
float HyperUnit::computeGain (Band& band, float absIn, float intensity) noexcept
{
// Peak envelope follower with fast attack, slower release.
const float attack = (absIn > band.env) ? 0.3f : 0.003f;
band.env += attack * (absIn - band.env);
const float env = band.env + 1e-6f;
const float ratio = 4.0f + 8.0f * intensity;
const float upAmount = intensity * 0.7f;
// Downward compression above the reference level, upward expansion below.
float target;
if (env > 1.0f)
target = 1.0f + (env - 1.0f) / ratio;
else
target = 1.0f - (1.0f - env) * (1.0f - upAmount);
float desired = target / env;
desired = juce::jlimit (0.2f, 4.0f, desired);
band.gain += 0.01f * (desired - band.gain);
return band.gain;
}
void HyperUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const float intensity = p[0];
const float lowAmt = 0.4f + 0.6f * p[1];
const float highAmt = 0.4f + 0.6f * p[2];
const float output = 0.5f + p[3] * 1.0f;
for (int i = 0; i < numSamples; ++i)
{
const float inL = l[i], inR = r[i];
const float loL = lpL.process (inL);
const float restL = inL - loL;
const float hiL = hpL.process (restL);
const float midL = restL - hiL;
const float loR = lpR.process (inR);
const float restR = inR - loR;
const float hiR = hpR.process (restR);
const float midR = restR - hiR;
const float gLL = computeGain (bL[0], std::abs (loL), intensity);
const float gML = computeGain (bL[1], std::abs (midL), intensity);
const float gHL = computeGain (bL[2], std::abs (hiL), intensity);
const float gLR = computeGain (bR[0], std::abs (loR), intensity);
const float gMR = computeGain (bR[1], std::abs (midR), intensity);
const float gHR = computeGain (bR[2], std::abs (hiR), intensity);
l[i] = (loL * gLL * lowAmt + midL * gML + hiL * gHL * highAmt) * output;
r[i] = (loR * gLR * lowAmt + midR * gMR + hiR * gHR * highAmt) * output;
}
}
} // namespace serum
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "../FXProcessor.h"
#include "Biquad.h"
namespace serum
{
// ===========================================================================
// Hyper — an OTT-style three-band upward/downward compressor. p[0]=intensity,
// p[1]=low amount, p[2]=high amount, p[3]=output.
// ===========================================================================
class HyperUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
struct Band
{
float env = 0.0f;
float gain = 1.0f;
};
double sr = 44100.0;
Biquad lpL, lpR, hpL, hpR;
Band bL[3], bR[3];
float computeGain (Band& band, float absIn, float intensity) noexcept;
};
} // namespace serum
+54
View File
@@ -0,0 +1,54 @@
#include "Phaser.h"
namespace serum
{
void PhaserUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
reset();
}
void PhaserUnit::reset()
{
lfoPhase = 0.0;
for (int i = 0; i < 6; ++i)
{
xL[i] = xR[i] = yL[i] = yR[i] = 0.0f;
}
}
void PhaserUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const double rate = 0.05 + p[0] * 2.0;
const float depth = 0.3f + p[1] * 0.6f;
const float feedback = p[2] * 0.7f;
const int stages = juce::jlimit (2, 6, 2 + (int) (p[3] * 4.0f + 0.5f));
for (int i = 0; i < numSamples; ++i)
{
lfoPhase += 6.28318530717958647692 * rate / sr;
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
const float sweep = (float) (0.5 + 0.5 * std::sin (lfoPhase));
const float a = 0.3f + depth * sweep; // allpass coefficient
float outL = l[i] + yL[5] * feedback;
float outR = r[i] + yR[5] * feedback;
for (int s = 0; s < stages; ++s)
{
// y = a*x + x_prev - a*y_prev
const float inL = outL, inR = outR;
outL = a * inL + xL[s] - a * yL[s];
outR = a * inR + xR[s] - a * yR[s];
xL[s] = inL; xR[s] = inR;
yL[s] = outL; yR[s] = outR;
}
l[i] = outL;
r[i] = outR;
}
}
} // namespace serum
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "../FXProcessor.h"
namespace serum
{
// ===========================================================================
// Phaser — cascade of four first-order allpass stages with an LFO-modulated
// coefficient and feedback. p[0]=rate, p[1]=depth, p[2]=feedback, p[3]=stages.
// ===========================================================================
class PhaserUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
double sr = 44100.0;
double lfoPhase = 0.0;
float xL[6] {}, xR[6] {}, yL[6] {}, yR[6] {};
};
} // namespace serum
+132
View File
@@ -0,0 +1,132 @@
#include "Reverb.h"
namespace serum
{
namespace
{
constexpr int combTuning[8] = { 1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617 };
constexpr int allpassTuning[4] = { 556, 441, 341, 225 };
}
void ReverbUnit::prepare (double sampleRate, int)
{
sr = sampleRate;
const double scale = sr / 44100.0;
for (int i = 0; i < 8; ++i)
{
const int len = (int) (combTuning[i] * scale) + 1;
combL[(size_t) i].assign ((size_t) len, 0.0f);
combR[(size_t) i].assign ((size_t) len, 0.0f);
}
for (int i = 0; i < 4; ++i)
{
const int len = (int) (allpassTuning[i] * scale) + 1;
apL[(size_t) i].assign ((size_t) len, 0.0f);
apR[(size_t) i].assign ((size_t) len, 0.0f);
}
const int preLen = (int) (sr * 0.2);
preL.assign ((size_t) preLen, 0.0f);
preR.assign ((size_t) preLen, 0.0f);
reset();
}
void ReverbUnit::reset()
{
for (int i = 0; i < 8; ++i)
{
std::fill (combL[(size_t) i].begin(), combL[(size_t) i].end(), 0.0f);
std::fill (combR[(size_t) i].begin(), combR[(size_t) i].end(), 0.0f);
combPosL[(size_t) i] = combPosR[(size_t) i] = 0;
combFiltL[(size_t) i] = combFiltR[(size_t) i] = 0.0f;
}
for (int i = 0; i < 4; ++i)
{
std::fill (apL[(size_t) i].begin(), apL[(size_t) i].end(), 0.0f);
std::fill (apR[(size_t) i].begin(), apR[(size_t) i].end(), 0.0f);
apPosL[(size_t) i] = apPosR[(size_t) i] = 0;
}
std::fill (preL.begin(), preL.end(), 0.0f);
std::fill (preR.begin(), preR.end(), 0.0f);
prePos = 0;
}
void ReverbUnit::process (float* l, float* r, int numSamples, const float p[4])
{
const float feedback = maps::sizeToValue (p[0]);
const float damping = p[1];
const float width = p[2];
const int preLen = (int) preL.size();
const float preDelay = (preLen > 0) ? p[3] * 0.15f * sr : 0.0f;
for (int i = 0; i < numSamples; ++i)
{
// Predelay.
float inL = l[i], inR = r[i];
if (preLen > 0)
{
const int read = (prePos - (int) preDelay + preLen) % preLen;
preL[(size_t) prePos] = inL;
preR[(size_t) prePos] = inR;
inL = preL[(size_t) read];
inR = preR[(size_t) read];
prePos = (prePos + 1) % preLen;
}
// Combs.
float sumL = 0.0f, sumR = 0.0f;
for (int c = 0; c < 8; ++c)
{
auto& buf = combL[(size_t) c];
const int pos = combPosL[(size_t) c];
float out = buf[(size_t) pos];
combFiltL[(size_t) c] = out * (1.0f - damping) + combFiltL[(size_t) c] * damping;
buf[(size_t) pos] = inL + combFiltL[(size_t) c] * feedback;
combPosL[(size_t) c] = (pos + 1) % (int) buf.size();
sumL += out;
}
for (int c = 0; c < 8; ++c)
{
auto& buf = combR[(size_t) c];
const int pos = combPosR[(size_t) c];
float out = buf[(size_t) pos];
combFiltR[(size_t) c] = out * (1.0f - damping) + combFiltR[(size_t) c] * damping;
buf[(size_t) pos] = inR + combFiltR[(size_t) c] * feedback;
combPosR[(size_t) c] = (pos + 1) % (int) buf.size();
sumR += out;
}
// Allpasses.
float aL = sumL, aR = sumR;
for (int a = 0; a < 4; ++a)
{
auto& buf = apL[(size_t) a];
const int pos = apPosL[(size_t) a];
const float stored = buf[(size_t) pos];
buf[(size_t) pos] = aL + stored * 0.5f;
aL = stored - aL;
apPosL[(size_t) a] = (pos + 1) % (int) buf.size();
}
for (int a = 0; a < 4; ++a)
{
auto& buf = apR[(size_t) a];
const int pos = apPosR[(size_t) a];
const float stored = buf[(size_t) pos];
buf[(size_t) pos] = aR + stored * 0.5f;
aR = stored - aR;
apPosR[(size_t) a] = (pos + 1) % (int) buf.size();
}
// Width (stereo cross-mix).
const float w = 1.0f - width * 0.5f;
const float outL = aL * w + aR * (1.0f - w);
const float outR = aR * w + aL * (1.0f - w);
l[i] = outL;
r[i] = outR;
}
}
} // namespace serum
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "../FXProcessor.h"
namespace serum
{
// ===========================================================================
// Reverb — Freeverb-style Schroeder reverb (8 combs + 4 allpasses per channel)
// with predelay. p[0]=size, p[1]=damping, p[2]=width, p[3]=predelay.
// ===========================================================================
class ReverbUnit : public FXUnit
{
public:
void prepare (double sampleRate, int maxBlockSize) override;
void reset() override;
void process (float* l, float* r, int numSamples, const float p[4]) override;
private:
double sr = 44100.0;
std::array<std::vector<float>, 8> combL, combR;
std::array<int, 8> combPosL {}, combPosR {};
std::array<float, 8> combFiltL {}, combFiltR {};
std::array<std::vector<float>, 4> apL, apR;
std::array<int, 4> apPosL {}, apPosR {};
std::vector<float> preL, preR;
int prePos = 0;
};
} // namespace serum
+366
View File
@@ -0,0 +1,366 @@
#include "Engine.h"
#include "RAVEButton.h"
namespace serum
{
namespace
{
inline float limit (float x) noexcept
{
const float ax = std::fabs (x);
if (ax < 0.8f)
return x;
const float over = ax - 0.8f;
const float clipped = 0.8f + std::tanh (over) * 0.2f;
return std::copysign (clipped, x);
}
}
Engine::Engine()
{
wavetables.prebuild();
audioMatrix.connections.reserve (ModulationMatrix::kMaxConnections);
for (auto& assignments : audioMacros.assignments)
assignments.reserve (MacroControls::kMaxAssignments);
}
void Engine::prepare (double sampleRate, int maxBlockSize, juce::AudioProcessorValueTreeState& apvts)
{
const juce::ScopedLock lock (controlLock);
sr = sampleRate;
blockSize = juce::jmax (1, maxBlockSize);
parameterValues.clear();
for (auto* parameter : apvts.processor.getParameters())
if (auto* ranged = dynamic_cast<juce::RangedAudioParameter*> (parameter))
if (auto* raw = apvts.getRawParameterValue (ranged->paramID))
parameterValues.emplace (std::string_view (ranged->paramID.toRawUTF8()),
ParameterValue { raw, raw->load() });
for (auto& voice : voices)
voice.prepare (sampleRate, blockSize);
for (auto& lfo : lfos)
lfo.prepare (sampleRate);
fx.prepare (sampleRate, blockSize);
mixBuffer.setSize (2, blockSize, false, false, true);
reset();
captureControls();
}
void Engine::reset()
{
for (auto& voice : voices)
voice.reset();
for (auto& lfo : lfos)
lfo.reset();
fx.reset();
pitchBend = 0.0f;
modWheel = 0.0f;
activeVoiceCount.store (0, std::memory_order_relaxed);
mixBuffer.clear();
}
void Engine::captureControls()
{
const juce::ScopedTryLock lock (controlLock);
if (! lock.isLocked())
return;
audioMatrix.connections.assign (matrix.connections.begin(),
matrix.connections.begin() + juce::jmin (matrix.size(), ModulationMatrix::kMaxConnections));
for (int i = 0; i < kNumMacros; ++i)
{
const auto& source = macros.assignments[(size_t) i];
auto& dest = audioMacros.assignments[(size_t) i];
dest.assign (source.begin(), source.begin() + juce::jmin ((int) source.size(), MacroControls::kMaxAssignments));
}
for (int i = 0; i < kNumLfos; ++i)
lfos[(size_t) i].setShapeData (controlLfos[(size_t) i].getShapeData(),
controlLfos[(size_t) i].getShapeSteps());
for (auto& entry : parameterValues)
{
const float value = entry.second.source->load (std::memory_order_relaxed);
entry.second.value = std::isfinite (value) ? juce::jlimit (0.0f, 1.0f, value) : 0.0f;
}
}
void Engine::setLfoShapeData (int index, const std::vector<float>& data, int steps)
{
const juce::ScopedLock lock (controlLock);
index = juce::jlimit (0, kNumLfos - 1, index);
controlLfos[(size_t) index].setShapeData (data, steps);
}
float Engine::v (const char* id) const
{
const auto it = parameterValues.find (id);
return it != parameterValues.end() ? it->second.value : 0.0f;
}
int Engine::vic (const char* id, int maxValue) const
{
return juce::jlimit (0, maxValue, (int) std::llround (v (id) * maxValue));
}
SynthVoice* Engine::findFreeVoice()
{
for (auto& voice : voices)
if (! voice.isActive())
return &voice;
return nullptr;
}
SynthVoice* Engine::stealVoice()
{
// Prefer stealing an already-released voice, then the oldest active one.
SynthVoice* best = nullptr;
juce::uint64 bestId = std::numeric_limits<juce::uint64>::max();
for (auto& voice : voices)
if (voice.isActive() && voice.isReleased() && voice.getNoteId() < bestId)
{
best = &voice;
bestId = voice.getNoteId();
}
if (best != nullptr)
return best;
for (auto& voice : voices)
if (voice.isActive() && voice.getNoteId() < bestId)
{
best = &voice;
bestId = voice.getNoteId();
}
return best != nullptr ? best : &voices[0];
}
void Engine::noteOn (int noteNumber, float velocity01)
{
SynthVoice* voice = findFreeVoice();
if (voice == nullptr)
voice = stealVoice();
const double freq = juce::MidiMessage::getMidiNoteInHertz (noteNumber);
voice->noteOn (noteNumber, juce::jlimit (0.0f, 1.0f, velocity01), freq, (juce::uint32) (++noteCounter));
}
void Engine::noteOff (int noteNumber)
{
for (auto& voice : voices)
if (voice.isActive() && voice.getNote() == noteNumber && ! voice.isReleased())
voice.noteOff();
}
void Engine::allNotesOff()
{
for (auto& voice : voices)
if (voice.isActive())
voice.noteOff();
}
void Engine::readOscParams (const paramIds::Oscillator& ids, OscParams& o) const
{
o.enabled = v (ids.on) > 0.5f;
o.wave = vic (ids.wave, kNumWavetables - 1);
o.wtPos = v (ids.wtPos);
o.warp = vic (ids.warp, (int) WarpMode::Count - 1);
o.warpAmt = v (ids.warpAmt);
o.coarse = vic (ids.coarse, 48) - 24;
o.fine = vic (ids.fine, 200) - 100;
o.level = v (ids.level);
o.pan = v (ids.pan) * 2.0f - 1.0f;
o.unison = 1 + vic (ids.unison, kMaxUnison - 1);
o.detune = v (ids.detune);
o.spread = v (ids.spread);
o.phase = v (ids.phase);
o.randPhase = v (ids.randPhase);
}
void Engine::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi,
juce::AudioProcessorValueTreeState&, juce::AudioPlayHead* playhead)
{
const int n = buffer.getNumSamples();
const int numCh = buffer.getNumChannels();
captureControls();
// Tempo.
if (playhead != nullptr)
if (auto pos = playhead->getPosition())
if (auto tempo = pos->getBpm())
if (std::isfinite (*tempo) && *tempo > 0.0)
bpm = *tempo;
// Advance LFOs and capture their values at control-rate render boundaries.
for (int i = 0; i < kNumLfos; ++i)
{
auto& lfo = lfos[(size_t) i];
lfo.setTempo (bpm);
lfo.setParams (v (paramIds::lfoRate[i]), v (paramIds::lfoSync[i]) > 0.5f,
v (paramIds::lfoBeat[i]), vic (paramIds::lfoShape[i], (int) LfoShape::Count - 1),
v (paramIds::lfoPhase[i]), v (paramIds::lfoFade[i]), v (paramIds::lfoDelay[i]));
}
// Build the render context.
RenderContext ctx;
ctx.sampleRate = sr;
ctx.wavetables = &wavetables;
ctx.matrix = &audioMatrix;
ctx.macros = &audioMacros;
for (int i = 0; i < kNumMacros; ++i)
ctx.macroValues[i] = v (paramIds::macros[i]);
readOscParams (paramIds::oscillators[0], ctx.oscA);
readOscParams (paramIds::oscillators[1], ctx.oscB);
ctx.subOn = v (ids::subOn) > 0.5f;
ctx.subShape = vic (ids::subShape, (int) SubShape::Count - 1);
ctx.subOct = vic (ids::subOct, 2) - 2;
ctx.subLevel = v (ids::subLevel);
ctx.noiseOn = v (ids::noiseOn) > 0.5f;
ctx.noiseType = vic (ids::noiseType, (int) NoiseType::Count - 1);
ctx.noiseLevel = v (ids::noiseLevel);
ctx.filters.f1On = v (ids::f1On) > 0.5f;
ctx.filters.f1Type = vic (ids::f1Type, (int) FilterModel::Count - 1);
ctx.filters.f1Cutoff = v (ids::f1Cutoff);
ctx.filters.f1Res = v (ids::f1Res);
ctx.filters.f1Drive = v (ids::f1Drive);
ctx.filters.f1Key = v (ids::f1Key);
ctx.filters.f1Slope = vic (ids::f1Slope, 2);
ctx.filters.f2On = v (ids::f2On) > 0.5f;
ctx.filters.f2Type = vic (ids::f2Type, (int) FilterModel::Count - 1);
ctx.filters.f2Cutoff = v (ids::f2Cutoff);
ctx.filters.f2Res = v (ids::f2Res);
ctx.filters.f2Drive = v (ids::f2Drive);
ctx.filters.f2Key = v (ids::f2Key);
ctx.filters.f2Slope = vic (ids::f2Slope, 2);
ctx.filters.route = vic (ids::fRoute, (int) FilterRoute::Count - 1);
ctx.filters.mix = v (ids::fMix);
ctx.filters.out = v (ids::fOut) * 1.5f;
for (int i = 0; i < kNumEnvelopes; ++i)
{
ctx.envAttack[i] = v (paramIds::envAttack[i]);
ctx.envDecay[i] = v (paramIds::envDecay[i]);
ctx.envSustain[i] = v (paramIds::envSustain[i]);
ctx.envRelease[i] = v (paramIds::envRelease[i]);
ctx.envCurve[i] = v (paramIds::envCurve[i]);
}
// FX rack.
std::array<FxSlotParams, kNumFxSlots> slots;
for (int i = 0; i < kNumFxSlots; ++i)
{
auto& slot = slots[(size_t) i];
slot.type = vic (paramIds::fxType[i], (int) FxType::Count - 1);
slot.mix = v (paramIds::fxMix[i]);
slot.p[0] = v (paramIds::fxP1[i]);
slot.p[1] = v (paramIds::fxP2[i]);
slot.p[2] = v (paramIds::fxP3[i]);
slot.p[3] = v (paramIds::fxP4[i]);
}
if (v (ids::rave) > 0.5f)
RaveController::apply (ctx, slots.data(), kNumFxSlots);
int offset = 0;
auto renderUntil = [&] (int end)
{
while (offset < end)
{
const int count = juce::jmin (end - offset, juce::jmin (blockSize, 64));
ctx.modWheel = modWheel;
ctx.pitchBend = pitchBend;
for (int i = 0; i < kNumLfos; ++i)
ctx.lfoValues[i] = lfos[(size_t) i].getValue();
const SynthVoice* newest = nullptr;
for (const auto& voice : voices)
if (voice.isActive() && (newest == nullptr || voice.getNoteId() > newest->getNoteId()))
newest = &voice;
auto sourceValue = [&] (ModSource source)
{
const int index = (int) source;
if (source >= ModSource::Lfo1 && source <= ModSource::Lfo4)
return ctx.lfoValues[index - (int) ModSource::Lfo1];
if (source >= ModSource::Macro1 && source <= ModSource::Macro4)
return ctx.macroValues[index - (int) ModSource::Macro1];
if (source == ModSource::ModWheel) return modWheel;
if (source == ModSource::PitchBend) return pitchBend;
return newest != nullptr ? newest->getModulationValue (source) : 0.0f;
};
std::array<float, kNumModTargets> globalMod {};
for (const auto& connection : audioMatrix.connections)
if (! isPerVoiceTarget (connection.target))
{
float value = sourceValue (connection.source);
if (connection.bipolar && ! isBipolarSource (connection.source))
value = value * 2.0f - 1.0f;
globalMod[(size_t) connection.target] += value * connection.depth;
}
for (int i = 0; i < kNumMacros; ++i)
for (const auto& assignment : audioMacros.assignments[(size_t) i])
if (! isPerVoiceTarget (assignment.target))
globalMod[(size_t) assignment.target] += ctx.macroValues[i] * assignment.depth;
// Prepare the voice mix buffer.
juce::AudioBuffer<float> block (mixBuffer.getArrayOfWritePointers(), 2, count);
block.clear();
// Render all active voices into the mix buffer.
for (auto& voice : voices)
if (voice.isActive())
voice.render (block.getWritePointer (0), block.getWritePointer (1), count, ctx);
auto modulatedSlots = slots;
for (int i = 0; i < kNumFxSlots; ++i)
modulatedSlots[(size_t) i].mix = juce::jlimit (0.0f, 1.0f,
slots[(size_t) i].mix + globalMod[(size_t) ModTarget::Fx1Mix + (size_t) i]);
fx.process (block, modulatedSlots.data(), kNumFxSlots);
// Master + soft limiting.
const float master = juce::jlimit (0.0f, 1.0f, v (ids::master) + globalMod[(size_t) ModTarget::Master]);
for (int ch = 0; ch < numCh; ++ch)
{
float* dest = buffer.getWritePointer (ch, offset);
const float* src = block.getReadPointer (ch < 2 ? ch : 0);
for (int i = 0; i < count; ++i)
dest[i] = limit (src[i] * master);
}
for (auto& lfo : lfos)
lfo.advance (count);
offset += count;
}
};
// MIDI.
for (const auto meta : midi)
{
renderUntil (juce::jlimit (offset, n, meta.samplePosition));
const auto message = meta.getMessage();
if (message.isNoteOn())
noteOn (message.getNoteNumber(), message.getFloatVelocity());
else if (message.isNoteOff())
noteOff (message.getNoteNumber());
else if (message.isPitchWheel())
pitchBend = (message.getPitchWheelValue() - 8192) / 8192.0f;
else if (message.isAllSoundOff())
{
for (auto& voice : voices)
voice.reset();
fx.reset();
}
else if (message.isAllNotesOff())
allNotesOff();
else if (message.isController() && message.getControllerNumber() == 1)
modWheel = message.getControllerValue() / 127.0f;
}
renderUntil (n);
int active = 0;
for (const auto& voice : voices)
active += voice.isActive() ? 1 : 0;
activeVoiceCount.store (active, std::memory_order_relaxed);
}
} // namespace serum
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <JuceHeader.h>
#include <string_view>
#include <unordered_map>
#include "Params.h"
#include "Wavetable.h"
#include "SynthVoice.h"
#include "LFO.h"
#include "FXProcessor.h"
#include "ModulationMatrix.h"
#include "MacroControls.h"
namespace serum
{
// ===========================================================================
// The synthesis engine: a fixed pool of voices, four global LFOs, a wavetable
// library and the FX rack. Owns all per-block DSP and MIDI handling.
// ===========================================================================
class Engine
{
public:
Engine();
void prepare (double sampleRate, int blockSize, juce::AudioProcessorValueTreeState& apvts);
void reset();
void processBlock (juce::AudioBuffer<float>& buffer,
juce::MidiBuffer& midi,
juce::AudioProcessorValueTreeState& apvts,
juce::AudioPlayHead* playhead);
// MIDI
void noteOn (int noteNumber, float velocity01);
void noteOff (int noteNumber);
void allNotesOff();
// Control-state accessors: hold getControlLock() while reading or editing.
const juce::CriticalSection& getControlLock() const { return controlLock; }
ModulationMatrix& getMatrix() { return matrix; }
MacroControls& getMacros() { return macros; }
const std::array<LFO, kNumLfos>& getLfos() const { return controlLfos; }
const WavetableLibrary& getWavetables() const { return wavetables; }
void setLfoShapeData (int index, const std::vector<float>& data, int steps);
int getActiveVoiceCount() const { return activeVoiceCount.load (std::memory_order_relaxed); }
private:
std::array<SynthVoice, kNumVoices> voices;
std::array<LFO, kNumLfos> lfos, controlLfos;
WavetableLibrary wavetables;
FXProcessor fx;
ModulationMatrix matrix, audioMatrix;
MacroControls macros, audioMacros;
juce::CriticalSection controlLock;
struct ParameterValue
{
std::atomic<float>* source;
float value;
};
std::unordered_map<std::string_view, ParameterValue> parameterValues;
double sr = 44100.0;
int blockSize = 512;
double bpm = 120.0;
float pitchBend = 0.0f;
float modWheel = 0.0f;
juce::uint64 noteCounter = 0;
std::atomic<int> activeVoiceCount { 0 };
juce::AudioBuffer<float> mixBuffer;
SynthVoice* findFreeVoice();
SynthVoice* stealVoice();
void captureControls();
float v (const char* id) const;
int vic (const char* id, int maxValue) const;
void readOscParams (const paramIds::Oscillator& ids, OscParams& out) const;
};
} // namespace serum
+116
View File
@@ -0,0 +1,116 @@
#include "Envelope.h"
namespace serum
{
void Envelope::reset()
{
stage = Stage::Idle;
value = 0.0f;
startValue = endValue = 0.0f;
elapsed = duration = 0.0;
}
void Envelope::noteOn()
{
stage = Stage::Attack;
elapsed = 0.0;
duration = attackSamples;
startValue = value; // retrigger from current level
endValue = 1.0f;
}
void Envelope::noteOff()
{
if (stage == Stage::Idle)
return;
stage = Stage::Release;
elapsed = 0.0;
duration = releaseSamples;
startValue = value;
}
void Envelope::setParams (float attackSec, float decaySec, float sustainLevel, float releaseSec, float curve)
{
sustain = juce::jlimit (0.0f, 1.0f, sustainLevel);
curve = juce::jlimit (0.0f, 1.0f, curve);
attackSamples = std::max (1.0, (double) attackSec * sr);
decaySamples = std::max (1.0, (double) decaySec * sr);
releaseSamples = std::max (1.0, (double) releaseSec * sr);
// curve 0 -> fast attack / long release curve; curve 1 -> slow attack / fast decay
attackShape = 0.3 + curve * 2.7; // 0.3 .. 3.0
decayShape = 3.0 - curve * 2.7; // 3.0 .. 0.3
shape.attack = attackSec;
shape.decay = decaySec;
shape.sustain = sustain;
shape.release = releaseSec;
shape.curve = curve;
}
float Envelope::process() noexcept
{
switch (stage)
{
case Stage::Idle:
value = 0.0f;
return 0.0f;
case Stage::Attack:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = endValue;
stage = Stage::Decay;
elapsed = 0.0;
duration = decaySamples;
startValue = value;
endValue = sustain;
}
else
{
const double p = elapsed / duration;
value = startValue + (endValue - startValue) * (float) std::pow (p, attackShape);
}
return value;
case Stage::Decay:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = sustain;
stage = Stage::Sustain;
}
else
{
const double p = elapsed / duration;
value = endValue + (startValue - endValue) * (float) std::pow (1.0 - p, decayShape);
}
return value;
case Stage::Sustain:
value = sustain;
return value;
case Stage::Release:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = 0.0f;
stage = Stage::Idle;
}
else
{
const double p = elapsed / duration;
value = startValue * (float) std::pow (1.0 - p, decayShape);
}
return value;
}
return 0.0f;
}
} // namespace serum
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <JuceHeader.h>
namespace serum
{
// ===========================================================================
// ADSR envelope with curve tension. Durations are in seconds; the sustain
// level is 0..1. Retriggering (noteOn while active) starts from the current
// value for click-free legato behaviour.
// ===========================================================================
class Envelope
{
public:
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
void noteOn();
void noteOff();
void setParams (float attackSec, float decaySec, float sustain, float releaseSec, float curve);
float process() noexcept; // advance one sample and return the value
float getValue() const noexcept { return value; }
bool isActive() const noexcept { return stage != Stage::Idle; }
// Snapshot for GUI rendering.
struct Shape { float attack = 0, decay = 0, sustain = 0, release = 0, curve = 0.5f; };
Shape getShape() const noexcept { return shape; }
private:
enum class Stage { Idle, Attack, Decay, Sustain, Release };
Stage stage = Stage::Idle;
double sr = 44100.0;
float value = 0.0f;
float startValue = 0.0f, endValue = 0.0f;
double elapsed = 0.0, duration = 0.0;
double attackShape = 1.0, decayShape = 1.0;
float sustain = 0.7f;
double attackSamples = 0.0, decaySamples = 0.0, releaseSamples = 0.0;
Shape shape;
};
} // namespace serum
+114
View File
@@ -0,0 +1,114 @@
#include "FXProcessor.h"
#include "EffectUnits/Hyper.h"
#include "EffectUnits/Chorus.h"
#include "EffectUnits/Flanger.h"
#include "EffectUnits/Phaser.h"
#include "EffectUnits/Distortion.h"
#include "EffectUnits/EQ.h"
#include "EffectUnits/Compressor.h"
#include "EffectUnits/Delay.h"
#include "EffectUnits/Reverb.h"
namespace serum
{
FXProcessor::FXProcessor()
{
for (auto& slotUnits : units)
{
slotUnits[0] = std::make_unique<HyperUnit>();
slotUnits[1] = std::make_unique<ChorusUnit>();
slotUnits[2] = std::make_unique<FlangerUnit>();
slotUnits[3] = std::make_unique<PhaserUnit>();
slotUnits[4] = std::make_unique<DistortionUnit>();
slotUnits[5] = std::make_unique<EQUnit>();
slotUnits[6] = std::make_unique<CompressorUnit>();
slotUnits[7] = std::make_unique<DelayUnit>();
slotUnits[8] = std::make_unique<ReverbUnit>();
}
}
FXProcessor::~FXProcessor() = default;
void FXProcessor::prepare (double sampleRate, int maxBlockSize)
{
for (auto& slotUnits : units)
for (auto& u : slotUnits)
u->prepare (sampleRate, maxBlockSize);
wet.setSize (2, maxBlockSize, false, false, true);
reset();
}
void FXProcessor::reset()
{
for (auto& u : units)
u->reset();
dry.clear();
wet.clear();
}
juce::String FXProcessor::fxTypeName (int type)
{
switch ((FxType) type)
{
case FxType::Off: return "Off";
case FxType::Hyper: return "Hyper";
case FxType::Chorus: return "Chorus";
case FxType::Flanger: return "Flanger";
case FxType::Phaser: return "Phaser";
case FxType::Distortion: return "Distortion";
case FxType::EQ: return "EQ";
case FxType::Compressor: return "Compressor";
case FxType::Delay: return "Delay";
case FxType::Reverb: return "Reverb";
default: return {};
}
}
void FXProcessor::process (juce::AudioBuffer<float>& buffer, const FxSlotParams* slots, int numSlots)
{
const int n = buffer.getNumSamples();
if (n <= 0)
return;
for (int s = 0; s < numSlots; ++s)
{
const FxSlotParams& slot = slots[s];
if (slot.type == (int) FxType::Off)
continue;
const int unitIdx = slot.type - 1;
if (unitIdx < 0 || unitIdx >= (int) units.size())
continue;
dry.copyFrom (0, 0, buffer, 0, 0, n);
dry.copyFrom (1, 1, buffer, 1, 0, n);
wet.copyFrom (0, 0, buffer, 0, 0, n);
wet.copyFrom (1, 1, buffer, 1, 0, n);
units[(size_t) unitIdx]->process (wet.getWritePointer (0), wet.getWritePointer (1), n, slot.p);
const float mix = juce::jlimit (0.0f, 1.0f, slot.mix);
if (mix >= 1.0f)
{
buffer.copyFrom (0, 0, wet, 0, 0, n);
buffer.copyFrom (1, 0, wet, 1, 0, n);
}
else if (mix > 0.0f)
{
const float* dL = dry.getReadPointer (0);
const float* dR = dry.getReadPointer (1);
const float* wL = wet.getReadPointer (0);
const float* wR = wet.getReadPointer (1);
float* oL = buffer.getWritePointer (0);
float* oR = buffer.getWritePointer (1);
for (int i = 0; i < n; ++i)
{
oL[i] = dL[i] * (1.0f - mix) + wL[i] * mix;
oR[i] = dR[i] * (1.0f - mix) + wR[i] * mix;
}
}
}
}
} // namespace serum
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Base class for a stereo effect unit. Each unit processes a full-wet buffer.
// ===========================================================================
class FXUnit
{
public:
virtual ~FXUnit() = default;
virtual void prepare (double sampleRate, int maxBlockSize) = 0;
virtual void reset() = 0;
virtual void process (float* l, float* r, int numSamples, const float p[4]) = 0;
};
// ===========================================================================
// Snapshot of one FX slot.
// ===========================================================================
struct FxSlotParams
{
int type = (int) FxType::Off;
float mix = 0.0f;
float p[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
};
// ===========================================================================
// Reorderable effects rack. Slots are processed in list order; reordering is
// simply swapping FxSlotParams entries. Nine effect unit implementations are
// preconstructed per slot with independent DSP history.
// ===========================================================================
class FXProcessor
{
public:
FXProcessor();
~FXProcessor();
void prepare (double sampleRate, int maxBlockSize);
void reset();
void process (juce::AudioBuffer<float>& buffer, const FxSlotParams* slots, int numSlots);
static juce::String fxTypeName (int type);
private:
static constexpr int kNumEffectTypes = (int) FxType::Count - 1;
std::array<std::array<std::unique_ptr<FXUnit>, kNumEffectTypes>, kNumFxSlots> units;
std::array<int, kNumFxSlots> activeTypes {};
juce::AudioBuffer<float> wet;
};
} // namespace serum
+299
View File
@@ -0,0 +1,299 @@
#include "Filter.h"
namespace serum
{
namespace
{
constexpr double kPi = juce::MathConstants<double>::pi;
inline float clampF (float v, float lo, float hi) noexcept
{
return v < lo ? lo : (v > hi ? hi : v);
}
inline double clampD (double v, double lo, double hi) noexcept
{
return v < lo ? lo : (v > hi ? hi : v);
}
}
void Filter::prepare (double sampleRate, int maxBlockSize)
{
sr = sampleRate;
combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay
combWrite = 0;
combDamp = 0.0;
reset();
(void) maxBlockSize;
}
void Filter::reset()
{
lastCutoffHz = lastRes = -1.0f;
lastType = -1;
ladderG = 0.0;
ic1eq = ic2eq = 0.0;
lastG = lastK = 0.0;
a1 = a2 = a3 = 0.0;
stage.fill (0.0);
for (auto& s : formantState) s.fill (0.0);
std::fill (combLine.begin(), combLine.end(), 0.0f);
combWrite = 0;
combDamp = 0.0;
}
void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
{
if (cutoffHz == lastCutoffHz && res == lastRes && type == lastType)
return;
lastCutoffHz = cutoffHz;
lastRes = res;
lastType = type;
// Ladder stages are cascaded one-poles, which need an exponential coefficient
// (always in (0,1]) for unconditional stability. The TPT SVF (formant/screamer)
// uses cached tan()-based coefficients instead.
switch ((FilterModel) type)
{
case FilterModel::LadderLP:
case FilterModel::LadderHP:
case FilterModel::LadderBP:
case FilterModel::Diode:
{
const double fc = clampD (cutoffHz, 20.0, sr * 0.45);
ladderG = 1.0 - std::exp (-2.0 * kPi * fc / sr);
break;
}
case FilterModel::Formant:
{
// morph (0..1) sweeps the three bandpass centres to produce vowel-like spectra.
const double m = clampD (maps::hzToCutoff (cutoffHz), 0.0, 1.0);
const double base[3] = { 400.0, 1200.0, 2600.0 };
const double k = clampD (2.0 * (1.0 - (double) res), 0.05, 2.0);
for (int i = 0; i < 3; ++i)
{
const double fc = base[i] * (0.7 + 1.6 * m) * (i == 2 ? 0.9 : 1.0);
const double g = std::tan (kPi * clampD (fc, 30.0, sr * 0.45) / sr);
auto& c = formantCoefficients[(size_t) i];
c[0] = 1.0 / (1.0 + g * (g + k));
c[1] = g * c[0];
c[2] = g * c[1];
}
break;
}
case FilterModel::Screamer:
{
const double g = std::tan (kPi * clampD (cutoffHz, 30.0, sr * 0.45) / sr);
const double k = clampD (2.0 * (1.0 - (double) res), 0.05, 2.0);
updateSvf (g, k);
break;
}
default:
break;
}
}
void Filter::updateSvf (double g, double k) noexcept
{
if (g == lastG && k == lastK)
return;
lastG = g;
lastK = k;
a1 = 1.0 / (1.0 + g * (g + k));
a2 = g * a1;
a3 = g * a2;
}
double Filter::svfLow (double in, double g, double k) noexcept
{
updateSvf (g, k);
const double v3 = in - ic2eq;
const double v1 = a1 * ic1eq + a2 * v3;
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
ic1eq = 2.0 * v1 - ic1eq;
ic2eq = 2.0 * v2 - ic2eq;
return v2;
}
double Filter::svfBand (double in, double g, double k) noexcept
{
updateSvf (g, k);
const double v3 = in - ic2eq;
const double v1 = a1 * ic1eq + a2 * v3;
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
ic1eq = 2.0 * v1 - ic1eq;
ic2eq = 2.0 * v2 - ic2eq;
return v1;
}
double Filter::svfHigh (double in, double g, double k) noexcept
{
updateSvf (g, k);
const double v3 = in - ic2eq;
const double v1 = a1 * ic1eq + a2 * v3;
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
ic1eq = 2.0 * v1 - ic1eq;
ic2eq = 2.0 * v2 - ic2eq;
return in - k * v1 - v2;
}
double Filter::ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept
{
stages = juce::jlimit (1, 4, stages);
res = clampD (res, 0.0, 0.97);
double x;
if (diode)
{
// Diode ladder: softer, asymmetric feedback and diode clipping.
x = in - 3.0 * res * (stage[(size_t) (stages - 1)] - in * 0.5);
x = x / (1.0 + std::abs (x)); // diode curve
x = x * (1.0 + drive * 3.0);
}
else
{
x = in - 4.0 * res * stage[(size_t) (stages - 1)];
x = std::tanh (x * (1.0 + drive * 6.0)); // Moog-ish input saturation
}
for (int i = 0; i < stages; ++i)
{
stage[(size_t) i] += g * (x - stage[(size_t) i]);
x = stage[(size_t) i];
}
x = clampD (x, -8.0, 8.0);
return x;
}
double Filter::comb (double in, double freqHz, double res, double drive) noexcept
{
const int len = (int) combLine.size();
if (len < 4)
return in;
const double freq = clampD (freqHz, 20.0, sr * 0.45);
double delay = sr / freq;
delay = clampD (delay, 2.0, (double) len - 2.0);
int readPos = combWrite - (int) delay;
if (readPos < 0) readPos += len;
const int readPos2 = (readPos + 1) % len;
const float frac = (float) (delay - std::floor (delay));
const float y0 = combLine[(size_t) readPos];
const float y1 = combLine[(size_t) readPos2];
float y = y0 + (y1 - y0) * frac;
// Damping lowpass in the feedback path (drive controls damping).
const double dampCoef = 1.0 - clampD (drive, 0.0, 0.99);
combDamp = y * (1.0 - dampCoef) + combDamp * dampCoef;
const float feedback = clampF ((float) res * 0.9f, 0.0f, 0.98f);
combLine[(size_t) combWrite] = (float) in + (float) combDamp * feedback;
combWrite = (combWrite + 1) % len;
return (double) y;
}
double Filter::formant (double in) noexcept
{
double out = 0.0;
const double gains[3] = { 1.0, 0.8, 0.5 };
for (int i = 0; i < 3; ++i)
{
const auto& c = formantCoefficients[(size_t) i];
const double a1 = c[0];
const double a2 = c[1];
const double a3 = c[2];
const double v3 = in - formantState[(size_t) i][1];
const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3;
const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * v3;
formantState[(size_t) i][0] = 2.0 * v1 - formantState[(size_t) i][0];
formantState[(size_t) i][1] = 2.0 * v2 - formantState[(size_t) i][1];
out += v1 * gains[i];
}
return clampD (out * 0.5, -8.0, 8.0);
}
double Filter::screamer (double in, double drive) noexcept
{
const double band = svfBand (in, lastG, lastK);
const double driven = std::tanh (band * (1.0 + drive * 12.0));
return driven * (1.0 - drive * 0.4);
}
float Filter::processSample (float in, float cutoffHz, float res, float drive, int type, int slope) noexcept
{
res = clampF (res, 0.0f, 0.98f);
drive = clampF (drive, 0.0f, 1.0f);
updateCoefficients (cutoffHz, res, type);
const double g = ladderG;
double out = (double) in;
switch ((FilterModel) type)
{
case FilterModel::LadderLP:
{
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
out = ladder (in, g, res, drive, stages, false);
break;
}
case FilterModel::LadderHP:
{
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
out = (double) in - ladder (in, g, res, drive, stages, false);
break;
}
case FilterModel::LadderBP:
{
int stages = (slope == 0) ? 2 : (slope == 1) ? 2 : 4;
out = ladder (in, g, res, drive, stages, false);
out = stage[0] - stage[(size_t) (stages - 1)];
break;
}
case FilterModel::Diode:
{
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
out = ladder (in, g, res, drive, stages, true);
break;
}
case FilterModel::Comb:
out = comb (in, cutoffHz, res, drive);
break;
case FilterModel::Formant:
out = formant (in);
break;
case FilterModel::Screamer:
out = screamer (in, drive);
break;
default:
break;
}
return (float) clampD (out, -8.0, 8.0);
}
float Filter::getCutoffHz (float cutoffNorm, float keytrack, float noteHz) noexcept
{
// Keytrack shifts the cutoff with note pitch.
const float noteNumber = (noteHz > 0.0f) ? (69.0f + 12.0f * std::log2f (noteHz / 440.0f)) : 60.0f;
const float baseHz = maps::cutoffToHz (cutoffNorm);
const float keyFactor = std::pow (2.0f, keytrack * (noteNumber - 60.0f) / 12.0f);
return clampF (baseHz * keyFactor, 20.0f, 18000.0f);
}
void Filter::process (float* samples, int numSamples, float cutoffNorm, float res,
float drive, float keytrack, float noteHz, int type, int slope) noexcept
{
if (samples == nullptr || numSamples <= 0)
return;
const float cutoffHz = getCutoffHz (cutoffNorm, keytrack, noteHz);
for (int i = 0; i < numSamples; ++i)
samples[i] = processSample (samples[i], cutoffHz, res, drive, type, slope);
}
} // namespace serum
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Mono filter with 7 models (Ladder LP/HP/BP, Diode, Comb, Formant, Screamer),
// 6/12/24 dB slopes (ladder family) and per-sample drive + keytrack. All state
// is clamped to prevent NaN/inf at extreme settings.
// ===========================================================================
class Filter
{
public:
void prepare (double sampleRate, int maxBlockSize);
void reset();
// Process a mono buffer in-place. cutoffNorm is 0..1 (mapped to 20Hz..20kHz).
void process (float* samples, int numSamples, float cutoffNorm, float res,
float drive, float keytrack, float noteHz, int type, int slope) noexcept;
// Single-sample version (used by the comb/naive paths where convenient).
float processSample (float in, float cutoffHz, float res, float drive, int type, int slope) noexcept;
static float getCutoffHz (float cutoffNorm, float keytrack, float noteHz) noexcept;
private:
double sr = 44100.0;
float lastCutoffHz = -1.0f, lastRes = -1.0f;
int lastType = -1;
double ladderG = 0.0;
// TPT SVF state (also reused by formant/screamer).
double ic1eq = 0.0, ic2eq = 0.0;
double lastG = 0.0, lastK = 0.0;
double a1 = 0.0, a2 = 0.0, a3 = 0.0;
// Ladder stage state.
std::array<double, 4> stage { { 0.0, 0.0, 0.0, 0.0 } };
// Comb delay line.
std::vector<float> combLine;
int combWrite = 0;
double combDamp = 0.0;
// Formant: three parallel bandpass SVFs (state pairs).
std::array<std::array<double, 2>, 3> formantState { { { { 0.0, 0.0 } }, { { 0.0, 0.0 } }, { { 0.0, 0.0 } } } };
std::array<std::array<double, 3>, 3> formantCoefficients {};
void updateCoefficients (float cutoffHz, float res, int type) noexcept;
void updateSvf (double g, double k) noexcept;
double svfLow (double in, double g, double k) noexcept;
double svfBand (double in, double g, double k) noexcept;
double svfHigh (double in, double g, double k) noexcept;
double ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept;
double comb (double in, double freqHz, double res, double drive) noexcept;
double formant (double in) noexcept;
double screamer (double in, double drive) noexcept;
};
} // namespace serum
+81
View File
@@ -0,0 +1,81 @@
#include "FilterBank.h"
namespace serum
{
void FilterBank::prepare (double sampleRate, int maxBlockSize)
{
f1L.prepare (sampleRate, maxBlockSize);
f1R.prepare (sampleRate, maxBlockSize);
f2L.prepare (sampleRate, maxBlockSize);
f2R.prepare (sampleRate, maxBlockSize);
}
void FilterBank::reset()
{
f1L.reset(); f1R.reset(); f2L.reset(); f2R.reset();
}
void FilterBank::applySlot (Filter& f, float* buf, int n, bool on, int type, float cutoff,
float res, float drive, float key, int slope, float noteHz) noexcept
{
if (on)
f.process (buf, n, cutoff, res, drive, key, noteHz, type, slope);
}
void FilterBank::process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept
{
const bool anyFilter = p.f1On || p.f2On;
if (! anyFilter)
return;
if (p.route == (int) FilterRoute::Serial)
{
applySlot (f1L, l, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
applySlot (f1R, r, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
applySlot (f2L, l, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
}
else if (p.route == (int) FilterRoute::Parallel)
{
// Run both filters on copies and crossfade.
const float cutoff1 = p.f1On ? Filter::getCutoffHz (p.f1Cutoff, p.f1Key, noteHz) : 0.0f;
const float cutoff2 = p.f2On ? Filter::getCutoffHz (p.f2Cutoff, p.f2Key, noteHz) : 0.0f;
float f1l = 0.0f, f1r = 0.0f, f2l = 0.0f, f2r = 0.0f;
for (int i = 0; i < numSamples; ++i)
{
f1l = l[i]; f1r = r[i];
f2l = l[i]; f2r = r[i];
if (p.f1On)
{
f1l = f1L.processSample (f1l, cutoff1, p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
f1r = f1R.processSample (f1r, cutoff1, p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
}
if (p.f2On)
{
f2l = f2L.processSample (f2l, cutoff2, p.f2Res, p.f2Drive, p.f2Type, p.f2Slope);
f2r = f2R.processSample (f2r, cutoff2, p.f2Res, p.f2Drive, p.f2Type, p.f2Slope);
}
const float m = p.mix;
l[i] = f1l * (1.0f - m) + f2l * m;
r[i] = f1r * (1.0f - m) + f2r * m;
}
}
else // Split: filter 1 -> left, filter 2 -> right
{
applySlot (f1L, l, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
}
// Post-filter output level.
if (p.out != 1.0f)
{
for (int i = 0; i < numSamples; ++i)
{
l[i] *= p.out;
r[i] *= p.out;
}
}
}
} // namespace serum
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
#include "Filter.h"
namespace serum
{
// ===========================================================================
// Snapshot of the two-slot filter section.
// ===========================================================================
struct FilterBankParams
{
bool f1On = false, f2On = false;
int f1Type = 0, f2Type = 0;
float f1Cutoff = 0.5f, f2Cutoff = 0.5f;
float f1Res = 0.0f, f2Res = 0.0f;
float f1Drive = 0.0f, f2Drive = 0.0f;
float f1Key = 0.0f, f2Key = 0.0f;
int f1Slope = 2, f2Slope = 2;
int route = 0;
float mix = 0.5f;
float out = 1.0f;
};
// ===========================================================================
// Two filter slots with serial / parallel / split routing, processed stereo.
// ===========================================================================
class FilterBank
{
public:
void prepare (double sampleRate, int maxBlockSize);
void reset();
void process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept;
private:
Filter f1L, f1R, f2L, f2R;
static void applySlot (Filter& f, float* buf, int n, bool on, int type, float cutoff,
float res, float drive, float key, int slope, float noteHz) noexcept;
};
} // namespace serum
+47
View File
@@ -0,0 +1,47 @@
# gui module
## Owned files
- Related editor implementation: `../PluginEditor.h`, `../PluginEditor.cpp`
- SerumLookAndFeel.h
- Knob.h, Knob.cpp
- Slider.h, Slider.cpp
- ToggleButton.h, ToggleButton.cpp
- Display.h, Display.cpp
- Panel.h, Panel.cpp
- WaveformDisplay.h, WaveformDisplay.cpp
- FilterDisplay.h, FilterDisplay.cpp
- EnvelopeDisplay.h, EnvelopeDisplay.cpp
- LFODisplay.h, LFODisplay.cpp
## Rules
- Draw all controls through `SerumLookAndFeel`.
- Use `theme::` colours from Resources.h instead of hardcoded colour values.
- Own GUI attachments with `std::unique_ptr`.
- Own component children by value or with `std::unique_ptr`; `addAndMakeVisible` only attaches and shows them, and does not manage their lifetime.
- Refresh visuals from a `juce::Timer` callback on the message thread, not from the audio thread.
- Keep layout constants in the `layout` namespace and derive every position from them.
- Use `std::function` formatters for value readouts.
- Reference `ids::` for APVTS parameter IDs.
## IF-THEN
- IF a control reads a DSP value THEN refresh it on the message thread from a thread-safe snapshot or atomic value. A `timerCallback` alone does not make a shared DSP read safe.
- IF a raw parameter pointer is dereferenced THEN null-guard it first.
## Examples
```cpp
// BAD: unchecked raw parameter dereference
const float v = processor.parameters.getRawParameterValue (ids::f1Cutoff)->load();
```
```cpp
// GOOD: null guard with fallback
const float v = processor.parameters.getRawParameterValue (ids::f1Cutoff)
? processor.parameters.getRawParameterValue (ids::f1Cutoff)->load()
: 0.0f;
```
This file overrides /AGENT.md where they conflict.
+27
View File
@@ -0,0 +1,27 @@
#include "Display.h"
namespace serum
{
void Display::paint (juce::Graphics& g)
{
const auto b = getLocalBounds().toFloat();
g.setColour (juce::Colours::black.withAlpha (0.45f));
g.fillRoundedRectangle (b, 4.0f);
g.setColour (theme::outline);
g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f);
if (title.isNotEmpty())
{
g.setColour (theme::textDim);
g.setFont (juce::Font (10.0f));
g.drawText (title, 0, 3, getWidth(), 14, juce::Justification::centred, false);
}
g.setColour (theme::accent);
g.setFont (juce::Font (14.0f, juce::Font::bold));
g.drawText (value, 0, title.isNotEmpty() ? 16 : 0, getWidth(), getHeight() - (title.isNotEmpty() ? 16 : 0),
juce::Justification::centred, false);
}
} // namespace serum
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <JuceHeader.h>
#include "../Resources.h"
namespace serum
{
// ===========================================================================
// LED-style text readout (e.g. preset name / master level).
// ===========================================================================
class Display : public juce::Component
{
public:
void setTitle (const juce::String& t) { if (title != t) { title = t; repaint(); } }
void setValue (const juce::String& v) { if (value != v) { value = v; repaint(); } }
void paint (juce::Graphics& g) override;
private:
juce::String title, value;
};
} // namespace serum
+82
View File
@@ -0,0 +1,82 @@
#include "EnvelopeDisplay.h"
namespace serum
{
void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c)
{
if (attack == a && decay == d && sustain == s && release == r && curve == c)
return;
attack = a; decay = d; sustain = s; release = r; curve = c;
repaint();
}
void EnvelopeDisplay::paint (juce::Graphics& g)
{
const auto b = getLocalBounds().toFloat().reduced (4.0f);
g.setColour (juce::Colours::black.withAlpha (0.4f));
g.fillRoundedRectangle (b.expanded (4.0f), 4.0f);
g.setColour (theme::outline);
g.drawRoundedRectangle (b.expanded (4.0f).reduced (0.5f), 4.0f, 1.0f);
const float atkShape = 0.3f + curve * 2.7f;
const float decShape = 3.0f - curve * 2.7f;
// Normalise durations for display, allowing a short sustain plateau.
const float aSec = maps::toSeconds (attack);
const float dSec = maps::toSeconds (decay);
const float rSec = maps::toSeconds (release);
const float total = aSec + dSec + rSec + 0.01f;
const float ax = (aSec / total);
const float dx = (dSec / total);
const float rx = (rSec / total);
const float left = b.getX();
const float right = b.getRight();
const float bottom = b.getBottom();
const float top = b.getY();
const float sustainY = bottom - sustain * b.getHeight();
juce::Path path;
path.startNewSubPath (left, bottom);
// Attack (curve-shaped).
const int steps = 48;
const float peakX = left + ax * b.getWidth();
for (int i = 0; i <= steps; ++i)
{
const float p = (float) i / steps;
const float y = bottom - (bottom - top) * std::pow (p, atkShape);
path.lineTo (left + p * (peakX - left), y);
}
// Decay to sustain.
const float decX = peakX + dx * b.getWidth();
for (int i = 0; i <= steps; ++i)
{
const float p = (float) i / steps;
const float y = sustainY + (top - sustainY) * std::pow (1.0f - p, decShape);
path.lineTo (peakX + p * (decX - peakX), y);
}
path.lineTo (decX, sustainY);
path.lineTo (right - rx * b.getWidth(), sustainY);
// Release.
const float relStartX = right - rx * b.getWidth();
for (int i = 0; i <= steps; ++i)
{
const float p = (float) i / steps;
const float y = bottom - (bottom - sustainY) * std::pow (1.0f - p, decShape);
path.lineTo (relStartX + p * (right - relStartX), y);
}
g.setColour (theme::accent2.withAlpha (0.3f));
juce::Path fill = path;
fill.lineTo (right, bottom);
fill.closeSubPath();
g.fillPath (fill);
g.setColour (theme::accent2);
g.strokePath (path, juce::PathStrokeType (1.5f));
}
} // namespace serum
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <JuceHeader.h>
#include "../Resources.h"
#include "../Params.h"
namespace serum
{
// ===========================================================================
// ADSR curve preview.
// ===========================================================================
class EnvelopeDisplay : public juce::Component
{
public:
void setParams (float attack, float decay, float sustain, float release, float curve);
void paint (juce::Graphics& g) override;
private:
float attack = 0.05f, decay = 0.25f, sustain = 0.7f, release = 0.3f, curve = 0.5f;
};
} // namespace serum
+82
View File
@@ -0,0 +1,82 @@
#include "FilterDisplay.h"
namespace serum
{
float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int type)
{
const float w = (cutoffHz > 1.0f) ? freqHz / cutoffHz : 1.0f;
const float q = 0.6f + res * 14.0f;
const float w2 = w * w;
const float denom = std::sqrt ((1.0f - w2) * (1.0f - w2) + (w / q) * (w / q));
switch ((FilterModel) type)
{
case FilterModel::LadderHP: return (denom > 1e-6f) ? w2 / denom : 0.0f;
case FilterModel::LadderBP: return (denom > 1e-6f) ? (w / q) / denom : 0.0f;
case FilterModel::Screamer: return (denom > 1e-6f) ? (w / (q * 0.7f)) / denom : 0.0f;
case FilterModel::Comb:
return 0.5f + 0.5f * std::cos (2.0f * juce::MathConstants<float>::pi * freqHz / cutoffHz);
case FilterModel::Formant:
{
float m = 0.0f;
const float fc[3] = { cutoffHz * 0.5f, cutoffHz * 1.6f, cutoffHz * 3.2f };
for (int i = 0; i < 3; ++i)
{
const float ww = freqHz / fc[i];
const float dd = std::sqrt ((1.0f - ww * ww) * (1.0f - ww * ww) + (ww / q) * (ww / q));
m += (ww / q) / (dd + 1e-6f);
}
return m / 3.0f;
}
case FilterModel::Diode:
default:
return (denom > 1e-6f) ? 1.0f / denom : 0.0f;
}
}
void FilterDisplay::setParams (int t, float c, float r, float d, int s)
{
if (type == t && cutoff == c && res == r && drive == d && slope == s)
return;
type = t; cutoff = c; res = r; drive = d; slope = s;
repaint();
}
void FilterDisplay::paint (juce::Graphics& g)
{
const auto b = getLocalBounds().toFloat();
g.setColour (juce::Colours::black.withAlpha (0.4f));
g.fillRoundedRectangle (b, 4.0f);
g.setColour (theme::outline);
g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f);
if (! enabled)
return;
const float cutoffHz = maps::cutoffToHz (cutoff);
const int n = 300;
juce::Path path;
path.startNewSubPath (b.getX() + 4.0f, b.getBottom() - 4.0f);
for (int i = 0; i <= n; ++i)
{
const float logF = std::log10 (20.0f) + ((float) i / (float) n) * (std::log10 (20000.0f) - std::log10 (20.0f));
const float freq = std::pow (10.0f, logF);
const float mag = magnitude (freq, cutoffHz, res, type);
const float db = juce::Decibels::gainToDecibels (mag + 1e-5f);
const float x = b.getX() + 4.0f + ((float) i / (float) n) * (b.getWidth() - 8.0f);
const float y = juce::jmap (db, -30.0f, 12.0f, b.getBottom() - 4.0f, b.getY() + 4.0f);
path.lineTo (x, y);
}
path.lineTo (b.getRight() - 4.0f, b.getBottom() - 4.0f);
path.closeSubPath();
g.setColour (theme::accent.withAlpha (0.25f));
g.fillPath (path);
g.setColour (theme::accent);
g.strokePath (path, juce::PathStrokeType (1.5f));
}
} // namespace serum
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <JuceHeader.h>
#include "../Resources.h"
#include "../Params.h"
namespace serum
{
// ===========================================================================
// Approximate filter frequency-response view (magnitude vs log frequency).
// ===========================================================================
class FilterDisplay : public juce::Component,
public juce::SettableTooltipClient
{
public:
FilterDisplay() { setTooltip ("Approximate response preview; slope, drive and modulation are not modelled."); }
void setParams (int type, float cutoff, float res, float drive, int slope);
void setEnabled (bool e) { if (enabled != e) { enabled = e; repaint(); } }
void paint (juce::Graphics& g) override;
static float magnitude (float freqHz, float cutoffHz, float res, int type);
private:
int type = 0;
float cutoff = 0.5f, res = 0.0f, drive = 0.0f;
int slope = 2;
bool enabled = true;
};
} // namespace serum
+86
View File
@@ -0,0 +1,86 @@
#include "Knob.h"
namespace serum
{
Knob::Knob (const juce::String& name, std::function<juce::String (float)> fmt)
: formatter (std::move (fmt))
{
setSliderStyle (juce::Slider::RotaryVerticalDrag);
setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
setRange (0.0, 1.0);
setDoubleClickReturnValue (true, 0.5);
setName (name);
setComponentID (name);
}
juce::String Knob::getTextFromValue (double value)
{
if (formatter)
return formatter ((float) value);
return formatPercent ((float) value);
}
void Knob::mouseDown (const juce::MouseEvent& e)
{
juce::Slider::mouseDown (e);
showValuePopup (e);
}
void Knob::mouseDrag (const juce::MouseEvent& e)
{
juce::Slider::mouseDrag (e);
showValuePopup (e);
}
void Knob::mouseUp (const juce::MouseEvent& e)
{
juce::Slider::mouseUp (e);
hideValuePopup();
}
void Knob::mouseExit (const juce::MouseEvent& e)
{
juce::Slider::mouseExit (e);
hideValuePopup();
}
// ---------------------------------------------------------------------------
// The floating readout lives as a child of the top-level component so it can
// follow the cursor outside this knob's own bounds without being clipped, and
// it never intercepts mouse clicks so it can't block interaction.
// ---------------------------------------------------------------------------
void Knob::showValuePopup (const juce::MouseEvent& e)
{
auto* topLevel = getTopLevelComponent();
if (topLevel == nullptr)
return;
if (valuePopup == nullptr)
{
valuePopup = std::make_unique<juce::Label>();
valuePopup->setAlwaysOnTop (true);
valuePopup->setInterceptsMouseClicks (false, false);
valuePopup->setColour (juce::Label::backgroundColourId, juce::Colours::transparentBlack);
valuePopup->setColour (juce::Label::textColourId, theme::text);
valuePopup->setFont (juce::Font (14.0f, juce::Font::bold));
valuePopup->setJustificationType (juce::Justification::centred);
topLevel->addAndMakeVisible (valuePopup.get());
}
valuePopup->setText (getTextFromValue (getValue()), juce::dontSendNotification);
constexpr int w = 84;
constexpr int h = 24;
constexpr int gap = 10;
const auto cursor = topLevel->getLocalPoint (nullptr, e.getScreenPosition());
valuePopup->setBounds (cursor.getX() - w / 2, cursor.getY() - h - gap, w, h);
}
void Knob::hideValuePopup()
{
valuePopup.reset();
}
} // namespace serum
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <JuceHeader.h>
#include <memory>
#include "../Resources.h"
namespace serum
{
// ===========================================================================
// Rotary knob — a juce::Slider styled by SerumLookAndFeel. The value readout is
// formatted by a user-provided function via getTextFromValue().
//
// While the knob is being dragged it also shows a small floating label near the
// mouse cursor with the live (formatted) value, giving immediate feedback
// without changing the slider's normal behaviour.
// ===========================================================================
class Knob : public juce::Slider
{
public:
explicit Knob (const juce::String& name,
std::function<juce::String (float)> formatter = {});
void setFormatter (std::function<juce::String (float)> f) { formatter = std::move (f); }
juce::String getTextFromValue (double value) override;
void mouseDown (const juce::MouseEvent& e) override;
void mouseDrag (const juce::MouseEvent& e) override;
void mouseUp (const juce::MouseEvent& e) override;
void mouseExit (const juce::MouseEvent& e) override;
private:
void showValuePopup (const juce::MouseEvent& e);
void hideValuePopup();
std::function<juce::String (float)> formatter;
std::unique_ptr<juce::Label> valuePopup;
};
} // namespace serum
+134
View File
@@ -0,0 +1,134 @@
#include "LFODisplay.h"
namespace serum
{
void LFODisplay::setShapeData (const std::vector<float>& data, int s)
{
const int newSteps = juce::jlimit (2, 64, s);
const size_t dataSize = std::min (data.size(), size_t (64));
if (steps == newSteps && shapeData.size() == 64
&& std::equal (data.begin(), data.begin() + dataSize, shapeData.begin())
&& std::all_of (shapeData.begin() + dataSize, shapeData.end(), [] (float v) { return v == 0.0f; }))
return;
steps = newSteps;
shapeData.assign (data.begin(), data.begin() + dataSize);
shapeData.resize (64, 0.0f);
repaint();
}
float LFODisplay::valueAt (float phase) const noexcept
{
switch ((LfoShape) shape)
{
case LfoShape::Sine: return std::sin (phase * 6.2831853f);
case LfoShape::Triangle: return 1.0f - 4.0f * std::abs (phase - 0.5f);
case LfoShape::Saw: return 2.0f * phase - 1.0f;
case LfoShape::Square: return (phase < 0.5f) ? 1.0f : -1.0f;
case LfoShape::SampleHold:
{
const int step = juce::jlimit (0, 15, (int) (phase * 16.0f));
return ((step * 2654435761u) & 0xffffu) / 32768.0f - 1.0f;
}
case LfoShape::StepSeq:
{
const int idx = juce::jlimit (0, steps - 1, (int) (phase * steps));
return shapeData.empty() ? 0.0f : shapeData[(size_t) idx];
}
case LfoShape::Freehand:
{
if (shapeData.empty()) return 0.0f;
const float pos = phase * (float) (steps - 1);
const int i0 = (int) pos;
const int i1 = juce::jmin (i0 + 1, steps - 1);
const float frac = pos - (float) i0;
return shapeData[(size_t) i0] * (1.0f - frac) + shapeData[(size_t) i1] * frac;
}
default: return 0.0f;
}
}
void LFODisplay::paint (juce::Graphics& g)
{
const auto b = getLocalBounds().toFloat();
g.setColour (juce::Colours::black.withAlpha (0.4f));
g.fillRoundedRectangle (b, 4.0f);
g.setColour (theme::outline);
g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f);
const float midY = b.getCentreY();
g.setColour (theme::outline);
g.drawLine (b.getX() + 4.0f, midY, b.getRight() - 4.0f, midY, 1.0f);
// Editable shapes show step markers.
if (shape == (int) LfoShape::StepSeq)
{
const float stepW = (b.getWidth() - 8.0f) / (float) steps;
for (int i = 0; i < steps; ++i)
{
const float v = shapeData.empty() ? 0.0f : shapeData[(size_t) i];
const float x = b.getX() + 4.0f + stepW * i;
const float y = midY - v * (b.getHeight() * 0.42f);
g.setColour (theme::amber);
g.fillRect (x + 1.0f, juce::jmin (y, midY), stepW - 2.0f, std::abs (midY - y));
}
}
juce::Path path;
path.startNewSubPath (b.getX() + 4.0f, midY);
const int n = 256;
for (int i = 0; i <= n; ++i)
{
const float phase = (float) i / (float) n;
const float v = valueAt (phase);
const float x = b.getX() + 4.0f + phase * (b.getWidth() - 8.0f);
const float y = midY - v * (b.getHeight() * 0.42f);
if (i == 0) path.startNewSubPath (x, y);
else path.lineTo (x, y);
}
g.setColour (theme::green);
g.strokePath (path, juce::PathStrokeType (1.5f));
}
void LFODisplay::editAt (float x, float y)
{
const auto b = getLocalBounds().toFloat();
const float midY = b.getCentreY();
const float normX = juce::jlimit (0.0f, 1.0f, (x - b.getX() - 4.0f) / (b.getWidth() - 8.0f));
const float v = juce::jlimit (-1.0f, 1.0f, (midY - y) / (b.getHeight() * 0.42f));
if (shapeData.empty())
shapeData.assign (64, 0.0f);
if (shape == (int) LfoShape::StepSeq)
{
const int idx = juce::jlimit (0, steps - 1, (int) (normX * steps));
shapeData[(size_t) idx] = v;
}
else if (shape == (int) LfoShape::Freehand)
{
const int idx = juce::jlimit (0, steps - 1, (int) (normX * steps));
shapeData[(size_t) idx] = v;
}
else
{
return;
}
if (onEdited)
onEdited (shapeData, steps);
repaint();
}
void LFODisplay::mouseDown (const juce::MouseEvent& e)
{
editAt (e.x, e.y);
}
void LFODisplay::mouseDrag (const juce::MouseEvent& e)
{
editAt (e.x, e.y);
}
} // namespace serum
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <JuceHeader.h>
#include "../Resources.h"
#include "../Params.h"
namespace serum
{
// ===========================================================================
// LFO shape view. Step-sequencer and freehand shapes are click/drag editable.
// ===========================================================================
class LFODisplay : public juce::Component
{
public:
void setShape (int s) { if (shape != s) { shape = s; repaint(); } }
void setShapeData (const std::vector<float>& data, int steps);
void setOnShapeEdited (std::function<void (const std::vector<float>&, int)> cb) { onEdited = std::move (cb); }
void paint (juce::Graphics& g) override;
void mouseDown (const juce::MouseEvent& e) override;
void mouseDrag (const juce::MouseEvent& e) override;
private:
int shape = 0;
std::vector<float> shapeData;
int steps = 16;
std::function<void (const std::vector<float>&, int)> onEdited;
float valueAt (float phase) const noexcept;
void editAt (float x, float y);
};
} // namespace serum
+26
View File
@@ -0,0 +1,26 @@
#include "Panel.h"
namespace serum
{
Panel::Panel (const juce::String& t) : title (t)
{
}
void Panel::paint (juce::Graphics& g)
{
const auto b = getLocalBounds().toFloat();
g.setColour (theme::panel);
g.fillRoundedRectangle (b, 8.0f);
g.setColour (theme::outline);
g.drawRoundedRectangle (b.reduced (0.5f), 8.0f, 1.0f);
if (title.isNotEmpty())
{
g.setColour (theme::textDim);
g.setFont (juce::Font (11.0f, juce::Font::bold));
g.drawText (title, 10, 6, getWidth() - 20, 16, juce::Justification::left, false);
}
}
} // namespace serum
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <JuceHeader.h>
#include "../Resources.h"
namespace serum
{
// ===========================================================================
// Rounded dark panel with an optional title bar.
// ===========================================================================
class Panel : public juce::Component
{
public:
explicit Panel (const juce::String& title = {});
void paint (juce::Graphics& g) override;
private:
juce::String title;
};
} // namespace serum
+156
View File
@@ -0,0 +1,156 @@
#pragma once
#include <JuceHeader.h>
#include "../Resources.h"
namespace serum
{
// ===========================================================================
// Dark vector look-and-feel. Rotary and linear sliders are drawn with juce::Path
// (arcs + indicator + thumb) so they stay crisp at every UI scale.
// ===========================================================================
class SerumLookAndFeel : public juce::LookAndFeel_V4
{
public:
SerumLookAndFeel()
{
setColour (juce::Slider::backgroundColourId, theme::panel);
setColour (juce::Slider::thumbColourId, theme::accent);
setColour (juce::Slider::trackColourId, theme::outline);
setColour (juce::Slider::rotarySliderFillColourId, theme::accent);
setColour (juce::Slider::rotarySliderOutlineColourId, theme::panelRaised);
setColour (juce::Slider::textBoxTextColourId, theme::text);
setColour (juce::Slider::textBoxBackgroundColourId, theme::panel);
setColour (juce::Slider::textBoxHighlightColourId, theme::accent);
setColour (juce::Slider::textBoxOutlineColourId, theme::outline);
setColour (juce::ComboBox::backgroundColourId, theme::panelRaised);
setColour (juce::ComboBox::textColourId, theme::text);
setColour (juce::ComboBox::arrowColourId, theme::textDim);
setColour (juce::ComboBox::outlineColourId, theme::outline);
setColour (juce::ComboBox::buttonColourId, theme::panelRaised);
setColour (juce::ComboBox::focusedOutlineColourId, theme::accent);
setColour (juce::PopupMenu::backgroundColourId, theme::panelRaised);
setColour (juce::PopupMenu::textColourId, theme::text);
setColour (juce::PopupMenu::highlightedBackgroundColourId, theme::accent);
setColour (juce::PopupMenu::highlightedTextColourId, juce::Colours::black);
setColour (juce::TextButton::buttonColourId, theme::panelRaised);
setColour (juce::TextButton::buttonOnColourId, theme::accent);
setColour (juce::TextButton::textColourOffId, theme::textDim);
setColour (juce::TextButton::textColourOnId, juce::Colours::black);
setColour (juce::TextEditor::backgroundColourId, theme::panel);
setColour (juce::TextEditor::textColourId, theme::text);
setColour (juce::TextEditor::outlineColourId, theme::outline);
setColour (juce::TextEditor::focusedOutlineColourId, theme::accent);
}
void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
float sliderPos, float, float, juce::Slider& slider) override
{
const int labelH = 18;
const int knobH = height - labelH;
const int knobSize = juce::jmin (width, knobH) - 6;
const int cx = x + width / 2;
const int cy = y + knobH / 2;
const float radius = knobSize * 0.5f;
const juce::Rectangle<float> area ((float) cx - radius, (float) cy - radius,
radius * 2.0f, radius * 2.0f);
// Body.
g.setColour (theme::panelRaised);
g.fillEllipse (area);
g.setColour (theme::outline);
g.drawEllipse (area, 1.0f);
const float startAngle = juce::MathConstants<float>::pi * 1.25f; // 225 deg
const float endAngle = juce::MathConstants<float>::pi * -0.25f;
const float valueAngle = startAngle + sliderPos * (endAngle - startAngle);
// Track arc.
juce::Path track;
track.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f,
0.0f, startAngle, endAngle, true);
g.setColour (theme::outline);
g.strokePath (track, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved));
// Value arc.
juce::Path valueArc;
valueArc.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f,
0.0f, startAngle, valueAngle, true);
g.setColour (theme::accent);
g.strokePath (valueArc, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved));
// Indicator.
const float indX = (float) cx + (radius - 9.0f) * std::cos (valueAngle);
const float indY = (float) cy + (radius - 9.0f) * std::sin (valueAngle);
g.setColour (theme::text);
g.drawLine ((float) cx, (float) cy, indX, indY, 2.0f);
g.setColour (theme::text);
g.fillEllipse (indX - 2.0f, indY - 2.0f, 4.0f, 4.0f);
// Value readout.
g.setColour (theme::text);
g.setFont (juce::Font (10.0f, juce::Font::bold));
g.drawText (slider.getTextFromValue (slider.getValue()),
juce::Rectangle<int> (x, y, width, knobH - (int) radius + 8),
juce::Justification::centred, false);
// Name label.
g.setColour (theme::textDim);
g.setFont (juce::Font (11.0f));
g.drawText (slider.getName(), juce::Rectangle<int> (x, y + knobH, width, labelH),
juce::Justification::centred, false);
}
void drawLinearSlider (juce::Graphics& g, int x, int y, int width, int height,
float sliderPos, float, float, juce::Slider::SliderStyle, juce::Slider& slider) override
{
const bool vertical = height > width;
juce::Rectangle<int> track;
if (vertical)
{
track = juce::Rectangle<int> (x + width / 2 - 3, y + 8, 6, height - 16);
}
else
{
track = juce::Rectangle<int> (x + 8, y + height / 2 - 3, width - 16, 6);
}
g.setColour (theme::outline);
g.fillRoundedRectangle (track.toFloat(), 3.0f);
juce::Rectangle<float> fill;
if (vertical)
{
const float h = track.getHeight() * sliderPos;
fill = juce::Rectangle<float> ((float) track.getX(), (float) track.getBottom() - h,
(float) track.getWidth(), h);
}
else
{
fill = juce::Rectangle<float> ((float) track.getX(), (float) track.getY(),
track.getWidth() * sliderPos, (float) track.getHeight());
}
g.setColour (theme::accent);
g.fillRoundedRectangle (fill, 3.0f);
// Thumb.
const float thumbC = vertical ? (track.getBottom() - track.getHeight() * sliderPos)
: (track.getX() + track.getWidth() * sliderPos);
juce::Point<float> thumb (vertical ? (float) track.getCentreX() : thumbC,
vertical ? thumbC : (float) track.getCentreY());
g.setColour (theme::text);
g.fillEllipse (thumb.x - 6.0f, thumb.y - 6.0f, 12.0f, 12.0f);
g.setColour (theme::textDim);
g.setFont (juce::Font (10.0f));
g.drawText (slider.getName(), x, y, width, height, juce::Justification::bottomLeft, false);
}
};
} // namespace serum
+13
View File
@@ -0,0 +1,13 @@
#include "Slider.h"
namespace serum
{
Slider::Slider (const juce::String& name, std::function<juce::String (float)> formatter)
: Knob (name, std::move (formatter))
{
setSliderStyle (juce::Slider::LinearHorizontal);
setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
}
} // namespace serum
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "Knob.h"
namespace serum
{
// ===========================================================================
// Horizontal slider (same formatting machinery as Knob).
// ===========================================================================
class Slider : public Knob
{
public:
explicit Slider (const juce::String& name,
std::function<juce::String (float)> formatter = {});
};
} // namespace serum
+71
View File
@@ -0,0 +1,71 @@
#include "ToggleButton.h"
namespace serum
{
ToggleButton::ToggleButton (const juce::String& lbl) : label (lbl)
{
setRepaintsOnMouseActivity (true);
}
void ToggleButton::attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId)
{
attachment.reset();
if (auto* param = apvts.getParameter (paramId))
{
attachment = std::make_unique<juce::ParameterAttachment> (*param,
[this] (float newValue) { setToggleState (newValue > 0.5f); });
attachment->sendInitialUpdate();
}
}
void ToggleButton::setToggleState (bool s)
{
if (state == s)
return;
state = s;
repaint();
}
void ToggleButton::paint (juce::Graphics& g)
{
const auto bounds = getLocalBounds().toFloat();
const float ledSize = juce::jmin (bounds.getHeight() - 16.0f, 16.0f);
const juce::Rectangle<float> led ((bounds.getWidth() - ledSize) * 0.5f, 4.0f, ledSize, ledSize);
if (state)
{
g.setColour (onColour.withAlpha (0.35f));
g.fillEllipse (led.expanded (6.0f));
g.setColour (onColour);
}
else
{
g.setColour (theme::outline);
}
g.fillEllipse (led);
g.setColour (state ? juce::Colours::black : theme::textDim);
g.drawEllipse (led, 1.0f);
g.setColour (state ? theme::text : theme::textDim);
g.setFont (juce::Font (11.0f));
g.drawText (label, 0, (int) (led.getBottom() + 2), getWidth(), 14, juce::Justification::centred, false);
}
void ToggleButton::mouseDown (const juce::MouseEvent&)
{
const bool newState = ! state;
if (onClick)
{
onClick (newState);
}
else if (attachment != nullptr)
{
attachment->setValueAsCompleteGesture (newState ? 1.0f : 0.0f);
}
setToggleState (newState);
}
} // namespace serum
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <JuceHeader.h>
#include "../Resources.h"
namespace serum
{
// ===========================================================================
// LED-style toggle (power) button that drives a float parameter (0/1) or an
// optional callback (used by RAVE).
//
// Inherits SettableTooltipClient so setTooltip() works exactly like it does on
// juce::Button / juce::Slider / juce::ComboBox (juce::Component itself has no
// tooltip support).
// ===========================================================================
class ToggleButton : public juce::Component,
public juce::SettableTooltipClient
{
public:
explicit ToggleButton (const juce::String& label = {});
void attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId);
void setOnClick (std::function<void (bool)> cb) { onClick = std::move (cb); }
void setOnColour (juce::Colour c) { onColour = c; repaint(); }
void setLabel (const juce::String& lbl) { label = lbl; repaint(); }
void setToggleState (bool s);
bool getToggleState() const noexcept { return state; }
void paint (juce::Graphics& g) override;
void mouseDown (const juce::MouseEvent&) override;
private:
bool state = false;
juce::String label;
std::unique_ptr<juce::ParameterAttachment> attachment;
std::function<void (bool)> onClick;
juce::Colour onColour = theme::accent;
};
} // namespace serum
+45
View File
@@ -0,0 +1,45 @@
#include "WaveformDisplay.h"
namespace serum
{
void WaveformDisplay::paint (juce::Graphics& g)
{
const auto b = getLocalBounds().toFloat();
g.setColour (juce::Colours::black.withAlpha (0.4f));
g.fillRoundedRectangle (b, 4.0f);
g.setColour (theme::outline);
g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f);
const float midY = b.getCentreY();
g.setColour (theme::outline);
g.drawLine (b.getX() + 4.0f, midY, b.getRight() - 4.0f, midY, 1.0f);
if (wtLib == nullptr || ! enabled)
return;
const Wavetable& wt = wtLib->getTable (wave);
if (! wt.isValid())
return;
const int n = 512;
juce::Path path;
path.startNewSubPath (b.getX() + 4.0f, midY);
for (int i = 0; i <= n; ++i)
{
const float phase = (float) i / (float) n;
const float sample = wt.readSafe (wtPos * 255.0f, phase);
const float x = b.getX() + 4.0f + ((float) i / (float) n) * (b.getWidth() - 8.0f);
const float y = midY - sample * (b.getHeight() * 0.42f);
if (i == 0)
path.startNewSubPath (x, y);
else
path.lineTo (x, y);
}
g.setColour (theme::accent);
g.strokePath (path, juce::PathStrokeType (1.5f));
}
} // namespace serum
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <JuceHeader.h>
#include "../Resources.h"
#include "../Wavetable.h"
namespace serum
{
// ===========================================================================
// Single-cycle wavetable view (morphs with the WT position knob).
// ===========================================================================
class WaveformDisplay : public juce::Component
{
public:
void setWavetables (const WavetableLibrary* lib) { if (wtLib != lib) { wtLib = lib; repaint(); } }
void setWaveIndex (int index) { if (wave != index) { wave = index; repaint(); } }
void setFramePosition (float pos) { if (wtPos != pos) { wtPos = pos; repaint(); } }
void setEnabled (bool e) { if (enabled != e) { enabled = e; repaint(); } }
void paint (juce::Graphics& g) override;
private:
const WavetableLibrary* wtLib = nullptr;
int wave = 0;
float wtPos = 0.0f;
bool enabled = true;
};
} // namespace serum
+139
View File
@@ -0,0 +1,139 @@
#include "LFO.h"
namespace serum
{
void LFO::reset()
{
phase = 0.0;
value = 0.0f;
delayCounter = 0.0;
fadeCounter = 0.0;
fadeVal = 1.0f;
holdValue = rng.nextFloat() * 2.0f - 1.0f;
prevDelayParam = -1.0f;
prevFadeParam = -1.0f;
if (shapeBuffer.empty())
{
shapeBuffer.resize ((size_t) kShapePoints);
// default step sequence
for (int i = 0; i < kShapePoints; ++i)
shapeBuffer[(size_t) i] = (i % 2 == 0) ? 1.0f : -1.0f;
}
}
void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
float fade, float delay)
{
sync = s;
beat = b;
shape = juce::jlimit (0, (int) LfoShape::Count - 1, shp);
phaseOffset = juce::jlimit (0.0f, 1.0f, ph);
if (sync)
rateHz = (tempo / 60.0) / maps::beatToMultiplier (beat);
else
rateHz = maps::rateToHz (rateNorm);
delaySeconds = juce::jlimit (0.0f, 1.0f, delay) * 4.0;
fadeSeconds = juce::jlimit (0.0f, 1.0f, fade) * 8.0;
// Only restart the delay/fade timing when those knobs actually change,
// so repeated block-rate setParams calls don't keep resetting the LFO.
if (delay != prevDelayParam || fade != prevFadeParam)
{
delayCounter = delaySeconds * sr;
fadeCounter = 0.0;
fadeVal = (fadeSeconds <= 0.0) ? 1.0f : 0.0f;
prevDelayParam = delay;
prevFadeParam = fade;
}
value = delayCounter > 0.0 ? 0.0f : shapeValue() * fadeVal;
}
void LFO::setShapeData (const std::vector<float>& data, int steps)
{
if (data.empty())
return;
shapeSteps = juce::jlimit (2, kShapePoints, steps);
for (size_t i = 0; i < shapeBuffer.size(); ++i)
shapeBuffer[i] = i < data.size() && std::isfinite (data[i])
? juce::jlimit (-1.0f, 1.0f, data[i]) : 0.0f;
}
float LFO::shapeValue() noexcept
{
const float p = getPhase();
switch ((LfoShape) shape)
{
case LfoShape::Sine:
return std::sin (p * juce::MathConstants<float>::twoPi);
case LfoShape::Triangle:
return 1.0f - 4.0f * std::abs (p - 0.5f);
case LfoShape::Saw:
return 2.0f * p - 1.0f;
case LfoShape::Square:
return (p < 0.5f) ? 1.0f : -1.0f;
case LfoShape::SampleHold:
return holdValue;
case LfoShape::StepSeq:
{
const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps));
return shapeBuffer[(size_t) idx];
}
case LfoShape::Freehand:
{
const float pos = p * (float) (shapeSteps - 1);
const int i0 = (int) pos;
const int i1 = juce::jmin (i0 + 1, shapeSteps - 1);
const float frac = pos - (float) i0;
return shapeBuffer[(size_t) i0] * (1.0f - frac) + shapeBuffer[(size_t) i1] * frac;
}
default:
return 0.0f;
}
}
float LFO::process() noexcept
{
return advance (1);
}
float LFO::advance (int numSamples) noexcept
{
if (numSamples <= 0)
return value;
// Start delay.
if (delayCounter > 0.0)
{
const int skipped = (int) juce::jmin ((double) numSamples, std::ceil (delayCounter));
delayCounter = juce::jmax (0.0, delayCounter - skipped);
numSamples -= skipped;
if (numSamples == 0)
return value = 0.0f;
}
// Fade-in ramp.
if (fadeVal < 1.0f)
{
fadeCounter += numSamples;
if (fadeSeconds > 0.0)
fadeVal = (float) juce::jlimit (0.0, 1.0, fadeCounter / (fadeSeconds * sr));
else
fadeVal = 1.0f;
}
phase += rateHz * numSamples / sr;
const double cycles = std::floor (phase);
phase -= cycles;
if (shape == (int) LfoShape::SampleHold)
for (int i = 0; i < (int) cycles; ++i)
holdValue = rng.nextFloat() * 2.0f - 1.0f;
value = shapeValue() * fadeVal;
return value;
}
} // namespace serum
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Free-running LFO with tempo sync, seven shapes (sine/tri/saw/square/S&H/
// step-sequencer/freehand), adjustable phase, fade-in and start delay.
// ===========================================================================
class LFO
{
public:
static constexpr int kShapePoints = 64;
LFO() { reset(); }
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
void setParams (float rateNorm, bool sync, float beat, int shape, float phase,
float fade, float delay);
void setTempo (double bpm) noexcept { tempo = bpm; }
void setShapeData (const std::vector<float>& data, int steps);
float process() noexcept; // advance and return current value
float advance (int numSamples) noexcept;
float getPhase() const noexcept { return (float) (phase + phaseOffset - std::floor (phase + phaseOffset)); }
float getValue() const noexcept { return value; }
const std::vector<float>& getShapeData() const noexcept { return shapeBuffer; }
int getShapeSteps() const noexcept { return shapeSteps; }
private:
double sr = 44100.0;
double tempo = 120.0;
double phase = 0.0;
double phaseOffset = 0.0;
double rateHz = 1.0;
float value = 0.0f;
int shape = 0;
bool sync = false;
float beat = 0.25f;
double delaySeconds = 0.0;
double fadeSeconds = 0.0;
double delayCounter = 0.0; // samples remaining before start
double fadeCounter = 0.0; // samples elapsed in fade
float fadeVal = 1.0f;
float prevDelayParam = -1.0f;
float prevFadeParam = -1.0f;
std::vector<float> shapeBuffer;
int shapeSteps = 16;
juce::Random rng;
float holdValue = 0.0f;
float shapeValue() noexcept;
};
} // namespace serum
+99
View File
@@ -0,0 +1,99 @@
#include "MacroControls.h"
namespace serum
{
bool MacroControls::addAssignment (int macro, ModTarget target, float depth)
{
if (macro < 0 || macro >= kNumMacros
|| (int) target < 0 || (int) target >= kNumModTargets
|| ! std::isfinite (depth) || (int) assignments[(size_t) macro].size() >= kMaxAssignments)
return false;
assignments[(size_t) macro].push_back ({ target, juce::jlimit (-1.0f, 1.0f, depth) });
return true;
}
void MacroControls::removeAssignment (int macro, int index)
{
macro = juce::jlimit (0, kNumMacros - 1, macro);
auto& vec = assignments[(size_t) macro];
if (index >= 0 && index < (int) vec.size())
vec.erase (vec.begin() + index);
}
void MacroControls::clear()
{
for (auto& a : assignments)
a.clear();
}
juce::String MacroControls::macroName (int index)
{
switch (index)
{
case 0: return "Energy";
case 1: return "Width";
case 2: return "Drive";
case 3: return "Atmosphere";
default: return {};
}
}
juce::ValueTree MacroControls::toValueTree() const
{
MacroControls validated;
for (int m = 0; m < kNumMacros; ++m)
for (const auto& a : assignments[(size_t) m])
{
if ((int) validated.assignments[(size_t) m].size() >= kMaxAssignments)
break;
if (! validated.addAssignment (m, a.target, a.depth))
continue;
}
juce::ValueTree tree ("MACROS");
for (int m = 0; m < kNumMacros; ++m)
{
juce::ValueTree mac ("MACRO");
mac.setProperty ("index", m, nullptr);
for (const auto& a : validated.assignments[(size_t) m])
{
juce::ValueTree asg ("ASSIGN");
asg.setProperty ("target", modTargetToString (a.target), nullptr);
asg.setProperty ("depth", a.depth, nullptr);
mac.appendChild (asg, nullptr);
}
tree.appendChild (mac, nullptr);
}
return tree;
}
void MacroControls::fromValueTree (const juce::ValueTree& tree)
{
clear();
if (! tree.hasType ("MACROS"))
return;
for (const auto& mac : tree)
{
if (! mac.hasType ("MACRO"))
continue;
const double savedIndex = (double) mac.getProperty ("index", -1);
if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumMacros
|| std::floor (savedIndex) != savedIndex)
continue;
const int index = (int) savedIndex;
for (const auto& asg : mac)
{
if ((int) assignments[(size_t) index].size() >= kMaxAssignments)
break;
if (! asg.hasType ("ASSIGN"))
continue;
if (! addAssignment (index,
modTargetFromString (asg.getProperty ("target").toString()),
(float) asg.getProperty ("depth", 0.0)))
continue;
}
}
}
} // namespace serum
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// One assignable destination for a macro knob.
// ===========================================================================
struct MacroAssignment
{
ModTarget target = ModTarget::None;
float depth = 0.0f;
};
// ===========================================================================
// Four macros (Energy, Width, Drive, Atmosphere). Macro *values* are APVTS
// parameters; this class stores the assignable destinations and serialises
// them with the preset.
// ===========================================================================
class MacroControls
{
public:
static constexpr int kMaxAssignments = 8;
std::array<std::vector<MacroAssignment>, kNumMacros> assignments;
bool addAssignment (int macro, ModTarget target, float depth);
void removeAssignment (int macro, int index);
void clear();
juce::ValueTree toValueTree() const;
void fromValueTree (const juce::ValueTree& tree);
static juce::String macroName (int index);
};
} // namespace serum
+76
View File
@@ -0,0 +1,76 @@
#include "ModulationMatrix.h"
namespace serum
{
bool ModulationMatrix::addConnection (ModSource source, ModTarget target, float depth, bool bipolar)
{
if ((int) source < 0 || (int) source >= kNumModSources
|| (int) target < 0 || (int) target >= kNumModTargets
|| ! std::isfinite (depth) || (int) connections.size() >= kMaxConnections)
return false;
connections.push_back ({ source, target, juce::jlimit (-1.0f, 1.0f, depth), bipolar });
return true;
}
void ModulationMatrix::removeConnection (int index)
{
if (index >= 0 && index < (int) connections.size())
connections.erase (connections.begin() + index);
}
void ModulationMatrix::removeAllWithTarget (ModTarget target)
{
connections.erase (std::remove_if (connections.begin(), connections.end(),
[target] (const ModConnection& c) { return c.target == target; }),
connections.end());
}
juce::ValueTree ModulationMatrix::toValueTree() const
{
ModulationMatrix validated;
for (const auto& c : connections)
{
if (validated.size() >= kMaxConnections)
break;
if (! validated.addConnection (c.source, c.target, c.depth, c.bipolar))
continue;
}
juce::ValueTree tree ("MODMATRIX");
for (const auto& c : validated.connections)
{
juce::ValueTree con ("CONNECTION");
con.setProperty ("source", modSourceToString (c.source), nullptr);
con.setProperty ("target", modTargetToString (c.target), nullptr);
con.setProperty ("depth", c.depth, nullptr);
con.setProperty ("bipolar", c.bipolar, nullptr);
tree.appendChild (con, nullptr);
}
return tree;
}
void ModulationMatrix::fromValueTree (const juce::ValueTree& tree)
{
connections.clear();
if (! tree.hasType ("MODMATRIX"))
return;
for (const auto& con : tree)
{
if ((int) connections.size() >= kMaxConnections)
break;
if (! con.hasType ("CONNECTION"))
continue;
const auto sourceName = con.getProperty ("source").toString();
const auto source = modSourceFromString (sourceName);
if (sourceName.isEmpty() || modSourceToString (source) != sourceName)
continue;
if (! addConnection (source,
modTargetFromString (con.getProperty ("target").toString()),
(float) con.getProperty ("depth", 0.0),
(bool) con.getProperty ("bipolar", false)))
continue;
}
}
} // namespace serum
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// A single modulation connection: source -> target with a static depth and a
// bipolar flag. One level of modulation only (no modulation of modulation).
// ===========================================================================
struct ModConnection
{
ModSource source = ModSource::Lfo1;
ModTarget target = ModTarget::None;
float depth = 0.0f;
bool bipolar = false;
};
// ===========================================================================
// Ordered collection of modulation connections. Serialisable to/from a
// juce::ValueTree so that connections survive preset save/load.
// ===========================================================================
class ModulationMatrix
{
public:
static constexpr int kMaxConnections = 32;
std::vector<ModConnection> connections;
bool addConnection (ModSource source, ModTarget target, float depth, bool bipolar);
void removeConnection (int index);
void removeAllWithTarget (ModTarget target);
void clear() { connections.clear(); }
int size() const noexcept { return (int) connections.size(); }
juce::ValueTree toValueTree() const;
void fromValueTree (const juce::ValueTree& tree);
};
} // namespace serum
+36
View File
@@ -0,0 +1,36 @@
#include "NoiseOscillator.h"
namespace serum
{
void NoiseOscillator::reset()
{
pinkB0 = pinkB1 = pinkB2 = 0.0f;
}
void NoiseOscillator::noteOn (juce::uint32 seed)
{
rng.setSeed (seed);
}
void NoiseOscillator::processAdd (int type, float level, float& out) noexcept
{
if (level <= 0.0f)
return;
const float white = rng.nextFloat() * 2.0f - 1.0f;
float s = white;
if (type == (int) NoiseType::Pink)
{
// Paul Kellet's economy pink-noise filter.
pinkB0 = 0.99765f * pinkB0 + white * 0.0990460f;
pinkB1 = 0.96300f * pinkB1 + white * 0.2965164f;
pinkB2 = 0.57000f * pinkB2 + white * 1.0526913f;
s = pinkB0 + pinkB1 + pinkB2 + white * 0.1848f;
}
out += s * level;
}
} // namespace serum
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Per-voice white/pink noise generator.
// ===========================================================================
class NoiseOscillator
{
public:
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
void noteOn (juce::uint32 seed);
// Accumulate into out.
void processAdd (int type, float level, float& out) noexcept;
private:
double sr = 44100.0;
juce::Random rng;
float pinkB0 = 0.0f, pinkB1 = 0.0f, pinkB2 = 0.0f;
};
} // namespace serum
+157
View File
@@ -0,0 +1,157 @@
#include "Oscillator.h"
namespace serum
{
void Oscillator::reset()
{
for (auto& v : voices)
v = SubVoice {};
activeUnison = 1;
initializedUnison = 0;
paramsValid = false;
}
void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed)
{
jassert (freqHz > 0.0);
rng.setSeed (seed);
initializedUnison = 0;
paramsValid = false;
setParams (p);
}
void Oscillator::setParams (const OscParams& p) noexcept
{
const int uni = juce::jlimit (1, kMaxUnison, p.unison);
const bool countChanged = ! paramsValid || uni != activeUnison;
const bool detuneChanged = countChanged || p.detune != cachedParams.detune;
const bool panChanged = countChanged || p.pan != cachedParams.pan || p.spread != cachedParams.spread;
if (! detuneChanged && ! panChanged)
return;
const double basePhase = (double) p.phase * kTwoPi;
for (int v = initializedUnison; v < uni; ++v)
{
// Even phase spacing distributes the initial unison phases.
double offset = (uni > 1) ? ((double) v / (double) uni) * kTwoPi : 0.0;
double random = rng.nextFloat() * (double) p.randPhase * kTwoPi;
voices[(size_t) v].phase = basePhase + offset + random;
}
initializedUnison = juce::jmax (initializedUnison, uni);
const float unisonLevel = 1.0f / std::sqrt ((float) uni);
for (int v = 0; v < uni; ++v)
{
auto& sv = voices[(size_t) v];
if (detuneChanged)
{
// Detune: linear spread in cents, 0..50 cents at full depth.
double detuneCents = 0.0;
if (uni > 1)
detuneCents = (double) p.detune * 50.0 * ((double) (v - (uni - 1) / 2.0) / (double) ((uni - 1) / 2.0));
sv.detuneRatio = std::pow (2.0, detuneCents / 1200.0);
}
if (panChanged)
{
// Stereo spread.
float panPos = (uni > 1) ? ((float) v / (float) (uni - 1) - 0.5f) * 2.0f * p.spread : 0.0f;
panPos = juce::jlimit (-1.0f, 1.0f, panPos + p.pan);
// Constant-power pan.
const float panAngle = (panPos + 1.0f) * 0.5f * juce::MathConstants<float>::halfPi;
sv.panL = std::cos (panAngle);
sv.panR = std::sin (panAngle);
}
if (countChanged)
{
// Gain scaling with a centre emphasis for odd unison counts.
float lvl = unisonLevel;
if ((uni & 1) && v == uni / 2)
lvl *= 1.3f;
sv.level = lvl;
}
}
activeUnison = uni;
cachedParams = p;
paramsValid = true;
}
float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept
{
const float amt = p.warpAmt;
switch ((WarpMode) p.warp)
{
case WarpMode::None: return phase;
case WarpMode::BendPlus: return phase + amt * 0.5f * std::sin (phase * (float) kTwoPi);
case WarpMode::BendMinus: return phase - amt * 0.5f * std::sin (phase * (float) kTwoPi);
case WarpMode::Sync: return std::fmod (phase * (1.0f + amt * 7.0f), 1.0f);
case WarpMode::Asym:
if (amt < 0.001f) return phase;
return std::pow (phase, 1.0f + amt * 3.0f);
case WarpMode::Mirror:
{
const float mirror = 2.0f * std::abs (phase - 0.5f);
return phase + (mirror - phase) * amt;
}
case WarpMode::PWM: return phase;
case WarpMode::Fold: return phase;
default: return phase;
}
}
float Oscillator::warpSample (float sample, const OscParams& p) const noexcept
{
const float amt = p.warpAmt;
switch ((WarpMode) p.warp)
{
case WarpMode::PWM:
{
const float threshold = (2.0f * amt - 1.0f) * 0.9f;
return std::tanh ((sample - threshold) * 4.0f);
}
case WarpMode::Fold:
return std::sin (sample * (1.0f + amt * 5.0f) * 1.5707963267948966f);
default:
return sample;
}
}
void Oscillator::processAdd (const Wavetable& wt, const OscParams& p, double freqHz,
float& outL, float& outR) noexcept
{
if (! p.enabled || p.level <= 0.0f || freqHz <= 0.0)
return;
const int uni = activeUnison;
const float framePos = p.wtPos * 255.0f;
const double phaseInc = kTwoPi * freqHz / sr;
float accL = 0.0f;
float accR = 0.0f;
for (int v = 0; v < uni; ++v)
{
auto& sv = voices[(size_t) v];
sv.phase += phaseInc * sv.detuneRatio;
sv.phase -= std::floor (sv.phase * (1.0 / kTwoPi)) * kTwoPi;
if (sv.phase >= kTwoPi) sv.phase -= kTwoPi;
if (sv.phase < 0.0) sv.phase += kTwoPi;
float phase01 = (float) (sv.phase * (1.0 / kTwoPi));
float sample = wt.readSafe (framePos, warpPhase (phase01, p));
sample = warpSample (sample, p);
const float gain = sv.level * p.level;
accL += sample * gain * sv.panL;
accR += sample * gain * sv.panR;
}
outL += accL;
outR += accR;
}
} // namespace serum
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
#include "Wavetable.h"
namespace serum
{
// ===========================================================================
// Per-oscillator parameters (snapshot read from the APVTS each block).
// ===========================================================================
struct OscParams
{
bool enabled = true;
int wave = 0;
float wtPos = 0.0f;
int warp = 0;
float warpAmt = 0.0f;
int coarse = 0;
int fine = 0;
float level = 1.0f;
float pan = 0.0f;
int unison = 1;
float detune = 0.0f;
float spread = 0.0f;
float phase = 0.0f;
float randPhase = 0.0f;
};
// ===========================================================================
// A polyphonic oscillator with wavetable morphing, warp modes and unison
// (1..16 sub-voices). Per-voice state (phase) lives here; params are read each
// block from the OscParams snapshot.
// ===========================================================================
class Oscillator
{
public:
Oscillator() = default;
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
// 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 setParams (const OscParams& p) noexcept;
// Accumulate this oscillator's contribution into outL/outR.
void processAdd (const Wavetable& wt, const OscParams& p, double freqHz,
float& outL, float& outR) noexcept;
int getActiveUnison() const noexcept { return activeUnison; }
private:
struct SubVoice
{
double phase = 0.0;
double detuneRatio = 1.0;
float panL = 0.70710678f;
float panR = 0.70710678f;
float level = 1.0f;
};
std::array<SubVoice, kMaxUnison> voices;
int activeUnison = 1;
int initializedUnison = 0;
bool paramsValid = false;
OscParams cachedParams;
double sr = 44100.0;
juce::Random rng;
static constexpr double kTwoPi = juce::MathConstants<double>::twoPi;
float warpPhase (float phase, const OscParams& p) const noexcept;
float warpSample (float sample, const OscParams& p) const noexcept;
};
} // namespace serum
+156
View File
@@ -0,0 +1,156 @@
#include "Params.h"
namespace serum
{
juce::String modSourceName (ModSource s)
{
switch (s)
{
case ModSource::Lfo1: return "LFO 1";
case ModSource::Lfo2: return "LFO 2";
case ModSource::Lfo3: return "LFO 3";
case ModSource::Lfo4: return "LFO 4";
case ModSource::Env1: return "Env 1";
case ModSource::Env2: return "Env 2";
case ModSource::Env3: return "Env 3";
case ModSource::Env4: return "Env 4";
case ModSource::Velocity: return "Velocity";
case ModSource::Note: return "Note";
case ModSource::ModWheel: return "Mod Wheel";
case ModSource::PitchBend: return "Pitch Bend";
case ModSource::Macro1: return "Macro 1";
case ModSource::Macro2: return "Macro 2";
case ModSource::Macro3: return "Macro 3";
case ModSource::Macro4: return "Macro 4";
case ModSource::Random: return "Random";
default: return {};
}
}
juce::String modSourceToString (ModSource s)
{
switch (s)
{
case ModSource::Lfo1: return "lfo1"; case ModSource::Lfo2: return "lfo2";
case ModSource::Lfo3: return "lfo3"; case ModSource::Lfo4: return "lfo4";
case ModSource::Env1: return "env1"; case ModSource::Env2: return "env2";
case ModSource::Env3: return "env3"; case ModSource::Env4: return "env4";
case ModSource::Velocity: return "velocity"; case ModSource::Note: return "note";
case ModSource::ModWheel: return "modwheel"; case ModSource::PitchBend: return "pitchbend";
case ModSource::Macro1: return "macro1"; case ModSource::Macro2: return "macro2";
case ModSource::Macro3: return "macro3"; case ModSource::Macro4: return "macro4";
case ModSource::Random: return "random";
default: return {};
}
}
ModSource modSourceFromString (const juce::String& s)
{
for (int i = 0; i < kNumModSources; ++i)
if (s == modSourceToString ((ModSource) i))
return (ModSource) i;
return ModSource::Count;
}
juce::String modTargetName (ModTarget t)
{
switch (t)
{
case ModTarget::Master: return "Master Volume";
case ModTarget::Pitch: return "Pitch";
case ModTarget::Amp: return "Amp";
case ModTarget::OscALevel: return "Osc A Level";
case ModTarget::OscAPan: return "Osc A Pan";
case ModTarget::OscAWtPos: return "Osc A WT Pos";
case ModTarget::OscAUnison: return "Osc A Unison";
case ModTarget::OscADetune: return "Osc A Detune";
case ModTarget::OscASpread: return "Osc A Spread";
case ModTarget::OscAWarpAmt: return "Osc A Warp Amt";
case ModTarget::OscBLevel: return "Osc B Level";
case ModTarget::OscBPan: return "Osc B Pan";
case ModTarget::OscBWtPos: return "Osc B WT Pos";
case ModTarget::OscBUnison: return "Osc B Unison";
case ModTarget::OscBDetune: return "Osc B Detune";
case ModTarget::OscBSpread: return "Osc B Spread";
case ModTarget::OscBWarpAmt: return "Osc B Warp Amt";
case ModTarget::SubLevel: return "Sub Level";
case ModTarget::NoiseLevel: return "Noise Level";
case ModTarget::Filter1Cutoff: return "Filter 1 Cutoff";
case ModTarget::Filter1Res: return "Filter 1 Res";
case ModTarget::Filter1Drive: return "Filter 1 Drive";
case ModTarget::Filter2Cutoff: return "Filter 2 Cutoff";
case ModTarget::Filter2Res: return "Filter 2 Res";
case ModTarget::Filter2Drive: return "Filter 2 Drive";
case ModTarget::FilterMix: return "Filter Mix";
case ModTarget::FilterOut: return "Filter Out";
case ModTarget::Fx1Mix: return "FX 1 Mix"; case ModTarget::Fx2Mix: return "FX 2 Mix";
case ModTarget::Fx3Mix: return "FX 3 Mix"; case ModTarget::Fx4Mix: return "FX 4 Mix";
case ModTarget::Fx5Mix: return "FX 5 Mix"; case ModTarget::Fx6Mix: return "FX 6 Mix";
case ModTarget::Fx7Mix: return "FX 7 Mix"; case ModTarget::Fx8Mix: return "FX 8 Mix";
default: return {};
}
}
juce::String modTargetParamId (ModTarget t)
{
switch (t)
{
case ModTarget::Master: return ids::master;
case ModTarget::Pitch: return {};
case ModTarget::Amp: return {};
case ModTarget::OscALevel: return ids::oscALevel;
case ModTarget::OscAPan: return ids::oscAPan;
case ModTarget::OscAWtPos: return ids::oscAWtPos;
case ModTarget::OscAUnison: return ids::oscAUnison;
case ModTarget::OscADetune: return ids::oscADetune;
case ModTarget::OscASpread: return ids::oscASpread;
case ModTarget::OscAWarpAmt: return ids::oscAWarpAmt;
case ModTarget::OscBLevel: return ids::oscBLevel;
case ModTarget::OscBPan: return ids::oscBPan;
case ModTarget::OscBWtPos: return ids::oscBWtPos;
case ModTarget::OscBUnison: return ids::oscBUnison;
case ModTarget::OscBDetune: return ids::oscBDetune;
case ModTarget::OscBSpread: return ids::oscBSpread;
case ModTarget::OscBWarpAmt: return ids::oscBWarpAmt;
case ModTarget::SubLevel: return ids::subLevel;
case ModTarget::NoiseLevel: return ids::noiseLevel;
case ModTarget::Filter1Cutoff: return ids::f1Cutoff;
case ModTarget::Filter1Res: return ids::f1Res;
case ModTarget::Filter1Drive: return ids::f1Drive;
case ModTarget::Filter2Cutoff: return ids::f2Cutoff;
case ModTarget::Filter2Res: return ids::f2Res;
case ModTarget::Filter2Drive: return ids::f2Drive;
case ModTarget::FilterMix: return ids::fMix;
case ModTarget::FilterOut: return ids::fOut;
case ModTarget::Fx1Mix: return ids::fx1Mix; case ModTarget::Fx2Mix: return ids::fx2Mix;
case ModTarget::Fx3Mix: return ids::fx3Mix; case ModTarget::Fx4Mix: return ids::fx4Mix;
case ModTarget::Fx5Mix: return ids::fx5Mix; case ModTarget::Fx6Mix: return ids::fx6Mix;
case ModTarget::Fx7Mix: return ids::fx7Mix; case ModTarget::Fx8Mix: return ids::fx8Mix;
default: return {};
}
}
ModTarget modTargetFromParamId (const juce::String& id)
{
for (int i = 0; i < kNumModTargets; ++i)
if (id.isNotEmpty() && modTargetParamId ((ModTarget) i) == id)
return (ModTarget) i;
return ModTarget::None;
}
juce::String modTargetToString (ModTarget t)
{
if (t == ModTarget::Pitch) return "pitch";
if (t == ModTarget::Amp) return "amp";
return modTargetParamId (t);
}
ModTarget modTargetFromString (const juce::String& s)
{
if (s == "pitch") return ModTarget::Pitch;
if (s == "amp") return ModTarget::Amp;
return modTargetFromParamId (s);
}
} // namespace serum
+388
View File
@@ -0,0 +1,388 @@
#pragma once
#include <JuceHeader.h>
// ===========================================================================
// Central registry of parameter IDs, enums and mapping helpers for SerumAlt.
// Every audio parameter (APVTS) and every modulation destination is declared
// here so that the DSP engine, the mod matrix and the GUI share one source of
// truth.
// ===========================================================================
namespace serum
{
// ---------------------------------------------------------------------------
// Parameter IDs (APVTS)
// ---------------------------------------------------------------------------
namespace ids
{
// Global
inline constexpr const char* master = "master";
inline constexpr const char* uiScale = "uiScale";
inline constexpr const char* rave = "rave";
// Oscillator A / B
inline constexpr const char* oscAOn = "oscAOn";
inline constexpr const char* oscAWave = "oscAWave";
inline constexpr const char* oscAWtPos = "oscAWtPos";
inline constexpr const char* oscAWarp = "oscAWarp";
inline constexpr const char* oscAWarpAmt = "oscAWarpAmt";
inline constexpr const char* oscACoarse = "oscACoarse";
inline constexpr const char* oscAFine = "oscAFine";
inline constexpr const char* oscALevel = "oscALevel";
inline constexpr const char* oscAPan = "oscAPan";
inline constexpr const char* oscAUnison = "oscAUnison";
inline constexpr const char* oscADetune = "oscADetune";
inline constexpr const char* oscASpread = "oscASpread";
inline constexpr const char* oscAPhase = "oscAPhase";
inline constexpr const char* oscARandPh = "oscARandPh";
inline constexpr const char* oscBOn = "oscBOn";
inline constexpr const char* oscBWave = "oscBWave";
inline constexpr const char* oscBWtPos = "oscBWtPos";
inline constexpr const char* oscBWarp = "oscBWarp";
inline constexpr const char* oscBWarpAmt = "oscBWarpAmt";
inline constexpr const char* oscBCoarse = "oscBCoarse";
inline constexpr const char* oscBFine = "oscBFine";
inline constexpr const char* oscBLevel = "oscBLevel";
inline constexpr const char* oscBPan = "oscBPan";
inline constexpr const char* oscBUnison = "oscBUnison";
inline constexpr const char* oscBDetune = "oscBDetune";
inline constexpr const char* oscBSpread = "oscBSpread";
inline constexpr const char* oscBPhase = "oscBPhase";
inline constexpr const char* oscBRandPh = "oscBRandPh";
// Sub oscillator
inline constexpr const char* subOn = "subOn";
inline constexpr const char* subShape = "subShape";
inline constexpr const char* subOct = "subOct";
inline constexpr const char* subLevel = "subLevel";
// Noise
inline constexpr const char* noiseOn = "noiseOn";
inline constexpr const char* noiseType = "noiseType";
inline constexpr const char* noiseLevel = "noiseLevel";
// Filters
inline constexpr const char* f1On = "f1On";
inline constexpr const char* f1Type = "f1Type";
inline constexpr const char* f1Cutoff = "f1Cutoff";
inline constexpr const char* f1Res = "f1Res";
inline constexpr const char* f1Drive = "f1Drive";
inline constexpr const char* f1Key = "f1Key";
inline constexpr const char* f1Slope = "f1Slope";
inline constexpr const char* f2On = "f2On";
inline constexpr const char* f2Type = "f2Type";
inline constexpr const char* f2Cutoff = "f2Cutoff";
inline constexpr const char* f2Res = "f2Res";
inline constexpr const char* f2Drive = "f2Drive";
inline constexpr const char* f2Key = "f2Key";
inline constexpr const char* f2Slope = "f2Slope";
inline constexpr const char* fRoute = "fRoute";
inline constexpr const char* fMix = "fMix";
inline constexpr const char* fOut = "fOut";
// Envelopes 1..4
inline constexpr const char* env1A = "env1A";
inline constexpr const char* env1D = "env1D";
inline constexpr const char* env1S = "env1S";
inline constexpr const char* env1R = "env1R";
inline constexpr const char* env1Curve = "env1Curve";
inline constexpr const char* env2A = "env2A";
inline constexpr const char* env2D = "env2D";
inline constexpr const char* env2S = "env2S";
inline constexpr const char* env2R = "env2R";
inline constexpr const char* env2Curve = "env2Curve";
inline constexpr const char* env3A = "env3A";
inline constexpr const char* env3D = "env3D";
inline constexpr const char* env3S = "env3S";
inline constexpr const char* env3R = "env3R";
inline constexpr const char* env3Curve = "env3Curve";
inline constexpr const char* env4A = "env4A";
inline constexpr const char* env4D = "env4D";
inline constexpr const char* env4S = "env4S";
inline constexpr const char* env4R = "env4R";
inline constexpr const char* env4Curve = "env4Curve";
// LFOs 1..4
inline constexpr const char* lfo1Rate = "lfo1Rate";
inline constexpr const char* lfo1Sync = "lfo1Sync";
inline constexpr const char* lfo1Beat = "lfo1Beat";
inline constexpr const char* lfo1Shape = "lfo1Shape";
inline constexpr const char* lfo1Phase = "lfo1Phase";
inline constexpr const char* lfo1Fade = "lfo1Fade";
inline constexpr const char* lfo1Delay = "lfo1Delay";
inline constexpr const char* lfo2Rate = "lfo2Rate";
inline constexpr const char* lfo2Sync = "lfo2Sync";
inline constexpr const char* lfo2Beat = "lfo2Beat";
inline constexpr const char* lfo2Shape = "lfo2Shape";
inline constexpr const char* lfo2Phase = "lfo2Phase";
inline constexpr const char* lfo2Fade = "lfo2Fade";
inline constexpr const char* lfo2Delay = "lfo2Delay";
inline constexpr const char* lfo3Rate = "lfo3Rate";
inline constexpr const char* lfo3Sync = "lfo3Sync";
inline constexpr const char* lfo3Beat = "lfo3Beat";
inline constexpr const char* lfo3Shape = "lfo3Shape";
inline constexpr const char* lfo3Phase = "lfo3Phase";
inline constexpr const char* lfo3Fade = "lfo3Fade";
inline constexpr const char* lfo3Delay = "lfo3Delay";
inline constexpr const char* lfo4Rate = "lfo4Rate";
inline constexpr const char* lfo4Sync = "lfo4Sync";
inline constexpr const char* lfo4Beat = "lfo4Beat";
inline constexpr const char* lfo4Shape = "lfo4Shape";
inline constexpr const char* lfo4Phase = "lfo4Phase";
inline constexpr const char* lfo4Fade = "lfo4Fade";
inline constexpr const char* lfo4Delay = "lfo4Delay";
// FX slots (8)
inline constexpr const char* fx1Type = "fx1Type";
inline constexpr const char* fx1Mix = "fx1Mix";
inline constexpr const char* fx1P1 = "fx1P1";
inline constexpr const char* fx1P2 = "fx1P2";
inline constexpr const char* fx1P3 = "fx1P3";
inline constexpr const char* fx1P4 = "fx1P4";
inline constexpr const char* fx2Type = "fx2Type";
inline constexpr const char* fx2Mix = "fx2Mix";
inline constexpr const char* fx2P1 = "fx2P1";
inline constexpr const char* fx2P2 = "fx2P2";
inline constexpr const char* fx2P3 = "fx2P3";
inline constexpr const char* fx2P4 = "fx2P4";
inline constexpr const char* fx3Type = "fx3Type";
inline constexpr const char* fx3Mix = "fx3Mix";
inline constexpr const char* fx3P1 = "fx3P1";
inline constexpr const char* fx3P2 = "fx3P2";
inline constexpr const char* fx3P3 = "fx3P3";
inline constexpr const char* fx3P4 = "fx3P4";
inline constexpr const char* fx4Type = "fx4Type";
inline constexpr const char* fx4Mix = "fx4Mix";
inline constexpr const char* fx4P1 = "fx4P1";
inline constexpr const char* fx4P2 = "fx4P2";
inline constexpr const char* fx4P3 = "fx4P3";
inline constexpr const char* fx4P4 = "fx4P4";
inline constexpr const char* fx5Type = "fx5Type";
inline constexpr const char* fx5Mix = "fx5Mix";
inline constexpr const char* fx5P1 = "fx5P1";
inline constexpr const char* fx5P2 = "fx5P2";
inline constexpr const char* fx5P3 = "fx5P3";
inline constexpr const char* fx5P4 = "fx5P4";
inline constexpr const char* fx6Type = "fx6Type";
inline constexpr const char* fx6Mix = "fx6Mix";
inline constexpr const char* fx6P1 = "fx6P1";
inline constexpr const char* fx6P2 = "fx6P2";
inline constexpr const char* fx6P3 = "fx6P3";
inline constexpr const char* fx6P4 = "fx6P4";
inline constexpr const char* fx7Type = "fx7Type";
inline constexpr const char* fx7Mix = "fx7Mix";
inline constexpr const char* fx7P1 = "fx7P1";
inline constexpr const char* fx7P2 = "fx7P2";
inline constexpr const char* fx7P3 = "fx7P3";
inline constexpr const char* fx7P4 = "fx7P4";
inline constexpr const char* fx8Type = "fx8Type";
inline constexpr const char* fx8Mix = "fx8Mix";
inline constexpr const char* fx8P1 = "fx8P1";
inline constexpr const char* fx8P2 = "fx8P2";
inline constexpr const char* fx8P3 = "fx8P3";
inline constexpr const char* fx8P4 = "fx8P4";
// Macros 1..4
inline constexpr const char* macro1 = "macro1";
inline constexpr const char* macro2 = "macro2";
inline constexpr const char* macro3 = "macro3";
inline constexpr const char* macro4 = "macro4";
}
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
enum class WarpMode : int { None = 0, BendPlus, BendMinus, Sync, PWM, Asym, Mirror, Fold, Count };
enum class SubShape : int { Sine = 0, Triangle, Count };
enum class NoiseType : int { White = 0, Pink, Count };
enum class FilterModel: int { LadderLP = 0, LadderHP, LadderBP, Diode, Comb, Formant, Screamer, Count };
enum class FilterRoute: int { Serial = 0, Parallel, Split, Count };
enum class LfoShape : int { Sine = 0, Triangle, Saw, Square, SampleHold, StepSeq, Freehand, Count };
enum class FxType : int { Off = 0, Hyper, Chorus, Flanger, Phaser, Distortion, EQ, Compressor, Delay, Reverb, Count };
inline constexpr int kNumOscillators = 2;
inline constexpr int kNumEnvelopes = 4;
inline constexpr int kNumLfos = 4;
inline constexpr int kNumMacros = 4;
inline constexpr int kNumFxSlots = 8;
inline constexpr int kMaxUnison = 16;
inline constexpr int kNumVoices = 32;
namespace paramIds
{
struct Oscillator
{
const char *on, *wave, *wtPos, *warp, *warpAmt, *coarse, *fine, *level;
const char *pan, *unison, *detune, *spread, *phase, *randPhase;
};
inline constexpr Oscillator oscillators[kNumOscillators] = {
{ ids::oscAOn, ids::oscAWave, ids::oscAWtPos, ids::oscAWarp, ids::oscAWarpAmt,
ids::oscACoarse, ids::oscAFine, ids::oscALevel, ids::oscAPan, ids::oscAUnison,
ids::oscADetune, ids::oscASpread, ids::oscAPhase, ids::oscARandPh },
{ ids::oscBOn, ids::oscBWave, ids::oscBWtPos, ids::oscBWarp, ids::oscBWarpAmt,
ids::oscBCoarse, ids::oscBFine, ids::oscBLevel, ids::oscBPan, ids::oscBUnison,
ids::oscBDetune, ids::oscBSpread, ids::oscBPhase, ids::oscBRandPh }
};
inline constexpr const char* envAttack[] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
inline constexpr const char* envDecay[] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
inline constexpr const char* envSustain[] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
inline constexpr const char* envRelease[] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
inline constexpr const char* envCurve[] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
inline constexpr const char* lfoRate[] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate };
inline constexpr const char* lfoSync[] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync };
inline constexpr const char* lfoBeat[] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat };
inline constexpr const char* lfoShape[] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
inline constexpr const char* lfoPhase[] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase };
inline constexpr const char* lfoFade[] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade };
inline constexpr const char* lfoDelay[] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay };
inline constexpr const char* fxType[] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
inline constexpr const char* fxMix[] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
inline constexpr const char* fxP1[] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1,
ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 };
inline constexpr const char* fxP2[] = { ids::fx1P2, ids::fx2P2, ids::fx3P2, ids::fx4P2,
ids::fx5P2, ids::fx6P2, ids::fx7P2, ids::fx8P2 };
inline constexpr const char* fxP3[] = { ids::fx1P3, ids::fx2P3, ids::fx3P3, ids::fx4P3,
ids::fx5P3, ids::fx6P3, ids::fx7P3, ids::fx8P3 };
inline constexpr const char* fxP4[] = { ids::fx1P4, ids::fx2P4, ids::fx3P4, ids::fx4P4,
ids::fx5P4, ids::fx6P4, ids::fx7P4, ids::fx8P4 };
inline constexpr const char* macros[] = { ids::macro1, ids::macro2, ids::macro3, ids::macro4 };
}
// ---------------------------------------------------------------------------
// Modulation sources / destinations
// ---------------------------------------------------------------------------
enum class ModSource : int
{
Lfo1 = 0, Lfo2, Lfo3, Lfo4,
Env1, Env2, Env3, Env4,
Velocity, Note, ModWheel, PitchBend,
Macro1, Macro2, Macro3, Macro4,
Random,
Count
};
enum class ModTarget : int
{
None = -1,
Master = 0,
Pitch,
Amp,
OscALevel, OscAPan, OscAWtPos, OscAUnison, OscADetune, OscASpread, OscAWarpAmt,
OscBLevel, OscBPan, OscBWtPos, OscBUnison, OscBDetune, OscBSpread, OscBWarpAmt,
SubLevel, NoiseLevel,
Filter1Cutoff, Filter1Res, Filter1Drive,
Filter2Cutoff, Filter2Res, Filter2Drive,
FilterMix, FilterOut,
Fx1Mix, Fx2Mix, Fx3Mix, Fx4Mix, Fx5Mix, Fx6Mix, Fx7Mix, Fx8Mix,
Count
};
inline constexpr int kNumModTargets = (int) ModTarget::Count;
inline constexpr int kNumModSources = (int) ModSource::Count;
// Is a given source per-voice (i.e. needs a value for each voice)?
inline bool isPerVoiceSource (ModSource s)
{
return (s >= ModSource::Env1 && s <= ModSource::Note) || s == ModSource::Random;
}
// Does a source already span -1..1 (bipolar range)?
inline bool isBipolarSource (ModSource s)
{
return (s >= ModSource::Lfo1 && s <= ModSource::Lfo4) || s == ModSource::PitchBend;
}
// Is a given target a per-voice parameter?
inline bool isPerVoiceTarget (ModTarget t)
{
return t != ModTarget::Master && ! (t >= ModTarget::Fx1Mix && t <= ModTarget::Fx8Mix);
}
juce::String modSourceName (ModSource s);
juce::String modTargetName (ModTarget t);
juce::String modTargetParamId(ModTarget t); // APVTS param id for the target
ModTarget modTargetFromParamId (const juce::String& id);
ModSource modSourceFromString (const juce::String& s);
juce::String modSourceToString (ModSource s);
juce::String modTargetToString (ModTarget t);
ModTarget modTargetFromString (const juce::String& s);
// ---------------------------------------------------------------------------
// Mapping helpers (raw 0..1 <-> physical units)
// ---------------------------------------------------------------------------
namespace maps
{
// Envelope times: 0..1 -> 0.5ms .. 12s (cubic taper for fine control at short times)
inline float toSeconds (float x) { return 0.0005f + std::pow (juce::jlimit (0.0f, 1.0f, x), 3.0f) * 12.0f; }
inline float fromSeconds (float s) { return std::pow (juce::jlimit (0.0005f, 12.0f, s) / 12.0f, 1.0f / 3.0f); }
// Cutoff: 0..1 -> 20Hz .. 20kHz (log)
inline float cutoffToHz (float x) { return 20.0f * std::pow (1000.0f, juce::jlimit (0.0f, 1.0f, x)); }
inline float hzToCutoff (float hz) { return std::log (juce::jlimit (20.0f, 20000.0f, hz) / 20.0f) / std::log (1000.0f); }
// LFO rate: 0..1 -> 0.02Hz .. 30Hz (log)
inline float rateToHz (float x) { return 0.02f * std::pow (1500.0f, juce::jlimit (0.0f, 1.0f, x)); }
inline float hzToRate (float hz) { return std::log (juce::jlimit (0.02f, 30.0f, hz) / 0.02f) / std::log (1500.0f); }
// LFO beat division: 0..1 -> 1/32 .. 4 bars (log stepped)
inline float beatToMultiplier (float x)
{
// 0 -> 1/32, 0.5 -> 1/4, 1 -> 4 bars
static constexpr float divisions[16] =
{
1.0f/32.0f, 1.0f/16.0f, 1.0f/8.0f, 1.0f/6.0f, 1.0f/4.0f, 1.0f/3.0f,
1.0f/2.0f, 1.0f, 3.0f/2.0f, 2.0f, 3.0f, 4.0f, 6.0f, 8.0f, 12.0f, 16.0f
};
const int idx = juce::jlimit (0, 15, (int) std::llround (x * 15.0f));
return divisions[idx];
}
// FX delay time: 0..1 -> 1ms .. 2000ms (log)
inline float delayToMs (float x) { return 1.0f * std::pow (2000.0f, juce::jlimit (0.0f, 1.0f, x)); }
inline float msToDelay (float ms) { return std::log (juce::jlimit (1.0f, 2000.0f, ms)) / std::log (2000.0f); }
// Reverb size: 0..1 -> 0.2 .. 0.99
inline float sizeToValue (float x) { return 0.2f + juce::jlimit (0.0f, 1.0f, x) * 0.79f; }
}
// ---------------------------------------------------------------------------
// Factory wavetable names (index == oscWave param value)
// ---------------------------------------------------------------------------
inline const char* const kWavetableNames[] =
{
"Basic Shapes",
"Saw PWM",
"Square Sync",
"Triangle Fold",
"Vowel",
"Organ",
"Warm Saw",
"Digital",
"Glass",
"Bass"
};
inline constexpr int kNumWavetables = 10;
} // namespace serum
+968
View File
@@ -0,0 +1,968 @@
#include "PluginEditor.h"
namespace serum
{
namespace
{
// -----------------------------------------------------------------------
// Consistent spacing system. Every panel, row and control position below is
// derived from these constants so the tabs share identical margins, padding
// and row pitches.
// -----------------------------------------------------------------------
namespace layout
{
constexpr int margin = 10; // panels inset from the tab view edge
constexpr int padding = 8; // controls inset inside panels + row rhythm
constexpr int gap = 6; // horizontal spacing between standard knobs
constexpr int knobSize = 76; // standard knob footprint (incl. label)
constexpr int knobSmall = 44; // small knob width (env / LFO rows)
constexpr int rowHeight = knobSize + padding; // vertical pitch of knob rows
constexpr int panelSpacing = 10; // gap between adjacent panels
constexpr int labelHeight = 16; // label strip height
// Derived from the visual style (panel title bar and control sizes) so the
// content grid stays aligned across every tab.
constexpr int titleHeight = 22; // panel title bar height
constexpr int comboHeight = 24; // combo box height
constexpr int buttonHeight = 26; // text button height
constexpr int toggleWidth = 40; // "On"/"Sync" toggle width
constexpr int toggleHeight = 36; // toggle height (LED + visible label)
constexpr int headerHeight = toggleHeight; // top control row height
constexpr int displayHeight = 66; // waveform / filter display height
constexpr int envDisplayHeight = 56; // envelope display height
constexpr int lfoDisplayHeight = 64; // LFO display height
constexpr int smallKnobHeight = 60; // height of 44px-wide small knobs
constexpr int smallGap = 3; // horizontal spacing between small knobs
constexpr int arrowWidth = 26; // FX up/down arrow button width
constexpr int macroKnobSize = 120; // macro knob footprint
}
// Horizontal position of a grid column (0-based).
inline int gridX (int col, int cellW, int cellGap)
{
return layout::margin + col * (cellW + cellGap);
}
// Y position of a combo box centred on a panel's header row.
inline int comboRowY()
{
return layout::titleHeight + (layout::headerHeight - layout::comboHeight) / 2;
}
// Y position of the first content row below a panel's header row.
inline int bodyTop()
{
return layout::titleHeight + layout::headerHeight + layout::padding;
}
juce::StringArray wavetableItems()
{
juce::StringArray a;
for (auto n : kWavetableNames) a.add (n);
return a;
}
juce::StringArray modSourceItems()
{
juce::StringArray a;
for (int i = 0; i < kNumModSources; ++i)
a.add (modSourceName ((ModSource) i));
return a;
}
juce::StringArray modTargetItems (std::vector<ModTarget>& enums)
{
juce::StringArray a;
for (int i = 0; i < kNumModTargets; ++i)
{
const auto t = (ModTarget) i;
if (t == ModTarget::None) continue;
a.add (modTargetName (t));
enums.push_back (t);
}
return a;
}
juce::String fmtUnison (float v) { return juce::String (1 + (int) std::llround (v * 15.0f)); }
juce::String fmtSlope (float v) { const int s = (int) std::llround (v * 2.0f); return juce::String (s == 0 ? 6 : (s == 1 ? 12 : 24)) + " dB"; }
juce::String fmtDepth (float v) { return juce::String (v, 2); }
void placeKnobs (std::vector<Knob*>& knobs, int x, int y, int w, int h, int gap = layout::gap)
{
for (int i = 0; i < (int) knobs.size(); ++i)
knobs[(size_t) i]->setBounds (x + i * (w + gap), y, w, h);
}
constexpr auto& kFxType = paramIds::fxType;
constexpr auto& kFxMix = paramIds::fxMix;
constexpr auto& kFxP1 = paramIds::fxP1;
constexpr auto& kFxP2 = paramIds::fxP2;
constexpr auto& kFxP3 = paramIds::fxP3;
constexpr auto& kFxP4 = paramIds::fxP4;
constexpr const char* kFxParamNames[(int) FxType::Count][4] = {
{ "P1", "P2", "P3", "P4" },
{ "Intensity", "Low Amount", "High Amount", "Output" },
{ "Rate", "Depth", "Width", "Unused" },
{ "Rate", "Depth", "Feedback", "Unused" },
{ "Rate", "Depth", "Feedback", "Stages" },
{ "Drive", "Shape", "Tone", "Output" },
{ "Low Gain", "Mid Gain", "High Gain", "Mid Freq" },
{ "Threshold", "Ratio", "Attack", "Release" },
{ "Time", "Feedback", "Damping", "Ping-Pong" },
{ "Size", "Damping", "Width", "Predelay" }
};
}
// ---------------------------------------------------------------------------
PluginEditor::PluginEditor (SerumAltAudioProcessor& p)
: AudioProcessorEditor (p), processor (p),
tooltipWindow (this, 500),
masterKnob ("Master", formatPercent)
{
// Tab / Shift+Tab cycling is handled by keyPressed(); this lets the editor
// receive keyboard focus when the user clicks anywhere on it.
setWantsKeyboardFocus (true);
root.setBounds (0, 0, kBaseW, kBaseH);
addAndMakeVisible (root);
logo = createLogoDrawable();
buildTopBar();
buildOscTab();
buildFilterTab();
buildModTab();
buildFxTab();
buildMacroTab();
setTab (0);
startTimerHz (30);
}
PluginEditor::~PluginEditor()
{
stopTimer();
}
// ---------------------------------------------------------------------------
void PluginEditor::paint (juce::Graphics& g)
{
g.fillAll (theme::bg);
g.setGradientFill (juce::ColourGradient (theme::panel, 0.0f, 0.0f,
theme::bg, 0.0f, (float) getHeight(), false));
g.fillAll();
if (logo != nullptr)
logo->drawWithin (g, juce::Rectangle<float> (10.0f, 8.0f, 180.0f, 44.0f),
juce::RectanglePlacement::centred, 1.0f);
}
void PluginEditor::resized()
{
root.setBounds (0, 0, kBaseW, kBaseH);
}
// ---------------------------------------------------------------------------
Knob* PluginEditor::makeKnob (juce::Component* parent, const juce::String& name,
const juce::String& paramId, std::function<juce::String (float)> fmt,
const juce::String& tooltip)
{
auto control = std::make_unique<Knob> (name, std::move (fmt));
auto* k = control.get();
ownedControls.push_back (std::move (control));
parent->addAndMakeVisible (k);
if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr)
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
processor.parameters, paramId, *k));
k->setTooltip (tooltip.isNotEmpty() ? tooltip : name);
return k;
}
juce::ComboBox* PluginEditor::makeCombo (juce::Component* parent, const juce::String& paramId,
const juce::StringArray& items, const juce::String& tooltip)
{
auto control = std::make_unique<juce::ComboBox>();
auto* c = control.get();
ownedControls.push_back (std::move (control));
c->addItemList (items, 1);
c->setSelectedItemIndex (0, juce::dontSendNotification);
parent->addAndMakeVisible (c);
if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr)
comboAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::ComboBoxAttachment> (
processor.parameters, paramId, *c));
if (tooltip.isNotEmpty())
c->setTooltip (tooltip);
return c;
}
ToggleButton* PluginEditor::makeToggle (juce::Component* parent, const juce::String& label,
const juce::String& paramId, const juce::String& tooltip)
{
auto control = std::make_unique<ToggleButton> (label);
auto* t = control.get();
ownedControls.push_back (std::move (control));
parent->addAndMakeVisible (t);
if (paramId.isNotEmpty())
t->attach (processor.parameters, paramId);
t->setTooltip (tooltip.isNotEmpty() ? tooltip : label);
return t;
}
// ---------------------------------------------------------------------------
void PluginEditor::buildTopBar()
{
// Preset selector.
juce::StringArray presetNames;
for (int i = 0; i < processor.getNumPrograms(); ++i)
presetNames.add (processor.getProgramName (i));
presetCombo.addItemList (presetNames, 1);
presetCombo.setSelectedItemIndex (processor.getCurrentProgram(), juce::dontSendNotification);
presetCombo.onChange = [this] { processor.setCurrentProgram (presetCombo.getSelectedItemIndex()); };
root.addAndMakeVisible (presetCombo);
presetCombo.setBounds (200, 16, 220, 24);
presetCombo.setTooltip ("Select a factory preset");
// UI scale.
scaleCombo.addItemList ({ "75%", "100%", "125%", "150%", "200%" }, 1);
scaleCombo.setSelectedItemIndex (processor.getUiScaleIndex(), juce::dontSendNotification);
scaleCombo.onChange = [this] { processor.setUiScaleIndex (scaleCombo.getSelectedItemIndex()); };
root.addAndMakeVisible (scaleCombo);
scaleCombo.setBounds (980, 16, 70, 24);
scaleCombo.setTooltip ("Interface scale (75% - 200%)");
// RAVE.
raveButton.setLabel ("RAVE");
raveButton.setOnColour (theme::raveGlow);
raveButton.setBounds (1062, 2, 50, 56);
root.addAndMakeVisible (raveButton);
raveButton.setTooltip ("RAVE one-click boost (unison, width, drive, OTT, reverb)");
// RAVE uses a callback rather than a direct parameter attachment.
raveButton.setOnClick ([this] (bool on) { processor.setRaveEnabled (on); });
// Tab bar.
juce::TextButton* tabs[5] = { &tabOsc, &tabFilter, &tabMod, &tabFx, &tabMacro };
const char* tabNames[5] = { "OSC", "FILTER", "MOD", "FX", "MACRO" };
const char* tabTips[5] = { "Oscillators (Tab cycles sections)",
"Filters (Tab cycles sections)",
"Modulation: envelopes, LFOs and matrix (Tab cycles sections)",
"Effects rack (Tab cycles sections)",
"Macros (Tab cycles sections)" };
for (int i = 0; i < 5; ++i)
{
tabs[i]->setButtonText (tabNames[i]);
tabs[i]->setClickingTogglesState (true);
tabs[i]->setRadioGroupId (1001);
tabs[i]->setBounds (10 + i * 92, 36, 84, 22);
tabs[i]->onClick = [this, i] { setTab (i); };
tabs[i]->setTooltip (tabTips[i]);
root.addAndMakeVisible (tabs[i]);
}
// Master.
masterKnob.setBounds (906, 0, 64, 60);
masterKnob.setTooltip ("Master output volume (0-100%)");
root.addAndMakeVisible (masterKnob);
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
processor.parameters, ids::master, masterKnob));
masterDisplay.setTitle ("MASTER");
root.addAndMakeVisible (masterDisplay);
masterDisplay.setBounds (846, 8, 56, 44);
}
void PluginEditor::buildOscTab()
{
oscView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (oscView);
constexpr int colW = 545; // two equal columns filling kBaseW
constexpr int oscH = 308;
constexpr int subH = 150;
oscAPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, oscH);
oscBPanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, oscH);
subPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin + oscH + layout::panelSpacing, colW, subH);
noisePanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin + oscH + layout::panelSpacing, colW, subH);
for (auto* panel : { &oscAPanel, &oscBPanel, &subPanel, &noisePanel })
oscView.addAndMakeVisible (panel);
const int comboY = comboRowY();
const int displayY = bodyTop(); // below header row
const int row1Y = displayY + layout::displayHeight + layout::padding;
const int row2Y = row1Y + layout::rowHeight;
// --- Oscillator A ---
makeToggle (&oscAPanel, "On", ids::oscAOn, "Enable oscillator A")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&oscAPanel, ids::oscAWave, wavetableItems(), "Oscillator A wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
makeCombo (&oscAPanel, ids::oscAWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator A warp mode")
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
oscAWave.setWavetables (&processor.engine.getWavetables());
oscAPanel.addAndMakeVisible (oscAWave);
oscAWave.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> rowA1 = {
makeKnob (&oscAPanel, "Wavetable Position", ids::oscAWtPos, formatPercent, "Wavetable position (0-100%)"),
makeKnob (&oscAPanel, "Warp Amount", ids::oscAWarpAmt, formatPercent, "Warp amount (0-100%)"),
makeKnob (&oscAPanel, "Coarse", ids::oscACoarse, formatSemis, "Pitch coarse (-24 to +24 semitones)"),
makeKnob (&oscAPanel, "Fine", ids::oscAFine, formatCents, "Pitch fine (-100 to +100 cents)"),
makeKnob (&oscAPanel, "Level", ids::oscALevel, formatPercent, "Oscillator A level (0-100%)"),
makeKnob (&oscAPanel, "Pan", ids::oscAPan, formatPan, "Oscillator A pan (L100-R100)")
};
placeKnobs (rowA1, layout::padding, row1Y, layout::knobSize, layout::knobSize);
std::vector<Knob*> rowA2 = {
makeKnob (&oscAPanel, "Unison", ids::oscAUnison, fmtUnison, "Unison voices (1-16)"),
makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatPercent, "Unison detune (0-100%)"),
makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison spread (0-100%)"),
makeKnob (&oscAPanel, "Phase", ids::oscAPhase, formatPercent, "Phase (0-100%)"),
makeKnob (&oscAPanel, "Random Phase", ids::oscARandPh, formatPercent, "Random phase (0-100%)")
};
placeKnobs (rowA2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
// --- Oscillator B ---
makeToggle (&oscBPanel, "On", ids::oscBOn, "Enable oscillator B")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&oscBPanel, ids::oscBWave, wavetableItems(), "Oscillator B wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
makeCombo (&oscBPanel, ids::oscBWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator B warp mode")
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
oscBWave.setWavetables (&processor.engine.getWavetables());
oscBPanel.addAndMakeVisible (oscBWave);
oscBWave.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> rowB1 = {
makeKnob (&oscBPanel, "Wavetable Position", ids::oscBWtPos, formatPercent, "Wavetable position (0-100%)"),
makeKnob (&oscBPanel, "Warp Amount", ids::oscBWarpAmt, formatPercent, "Warp amount (0-100%)"),
makeKnob (&oscBPanel, "Coarse", ids::oscBCoarse, formatSemis, "Pitch coarse (-24 to +24 semitones)"),
makeKnob (&oscBPanel, "Fine", ids::oscBFine, formatCents, "Pitch fine (-100 to +100 cents)"),
makeKnob (&oscBPanel, "Level", ids::oscBLevel, formatPercent, "Oscillator B level (0-100%)"),
makeKnob (&oscBPanel, "Pan", ids::oscBPan, formatPan, "Oscillator B pan (L100-R100)")
};
placeKnobs (rowB1, layout::padding, row1Y, layout::knobSize, layout::knobSize);
std::vector<Knob*> rowB2 = {
makeKnob (&oscBPanel, "Unison", ids::oscBUnison, fmtUnison, "Unison voices (1-16)"),
makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatPercent, "Unison detune (0-100%)"),
makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison spread (0-100%)"),
makeKnob (&oscBPanel, "Phase", ids::oscBPhase, formatPercent, "Phase (0-100%)"),
makeKnob (&oscBPanel, "Random Phase", ids::oscBRandPh, formatPercent, "Random phase (0-100%)")
};
placeKnobs (rowB2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
// --- Sub ---
makeToggle (&subPanel, "On", ids::subOn, "Enable sub oscillator")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&subPanel, ids::subShape, { "Sine", "Triangle" }, "Sub oscillator waveform")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
makeCombo (&subPanel, ids::subOct, { "-2 oct", "-1 oct", "0 oct" }, "Sub oscillator octave")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 130 + layout::gap, comboY, 90, layout::comboHeight);
makeKnob (&subPanel, "Level", ids::subLevel, formatPercent, "Sub oscillator level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
// --- Noise ---
makeToggle (&noisePanel, "On", ids::noiseOn, "Enable noise")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&noisePanel, ids::noiseType, { "White", "Pink" }, "Noise type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
makeKnob (&noisePanel, "Level", ids::noiseLevel, formatPercent, "Noise level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
}
void PluginEditor::buildFilterTab()
{
filterView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (filterView);
constexpr int colW = 360;
constexpr int panelH = 300; // retains the original panel height (content top-aligned)
filter1Panel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, panelH);
filter2Panel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, panelH);
routingPanel.setBounds (gridX (2, colW, layout::panelSpacing), layout::margin, colW, panelH);
for (auto* panel : { &filter1Panel, &filter2Panel, &routingPanel })
filterView.addAndMakeVisible (panel);
const juce::StringArray filterTypes = { "Ladder LP", "Ladder HP", "Ladder BP", "Diode", "Comb", "Formant", "Screamer" };
const juce::StringArray slopes = { "6 dB", "12 dB", "24 dB" };
const int comboY = comboRowY();
const int displayY = bodyTop();
const int knobY = displayY + layout::displayHeight + layout::padding; // aligns with OSC row 1
// Filter 1
makeToggle (&filter1Panel, "On", ids::f1On, "Enable filter 1")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&filter1Panel, ids::f1Type, filterTypes, "Filter 1 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
makeCombo (&filter1Panel, ids::f1Slope, slopes, "Filter 1 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
filter1Panel.addAndMakeVisible (filter1Display);
filter1Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> f1 = {
makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff (20 Hz - 20 kHz)"),
makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance (0-100%)"),
makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive (0-100%)"),
makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 keytrack (0-100%)")
};
placeKnobs (f1, layout::padding, knobY, layout::knobSize, layout::knobSize);
// Filter 2
makeToggle (&filter2Panel, "On", ids::f2On, "Enable filter 2")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
makeCombo (&filter2Panel, ids::f2Type, filterTypes, "Filter 2 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
makeCombo (&filter2Panel, ids::f2Slope, slopes, "Filter 2 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
filter2Panel.addAndMakeVisible (filter2Display);
filter2Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
std::vector<Knob*> f2 = {
makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff (20 Hz - 20 kHz)"),
makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance (0-100%)"),
makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive (0-100%)"),
makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 keytrack (0-100%)")
};
placeKnobs (f2, layout::padding, knobY, layout::knobSize, layout::knobSize);
// Routing
makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing (serial / parallel / split)")->setBounds (layout::padding, comboY, 160, layout::comboHeight);
makeKnob (&routingPanel, "Mix", ids::fMix, formatPercent, "Filter mix (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
makeKnob (&routingPanel, "Output", ids::fOut, formatPercent, "Filter output (0-100%)")->setBounds (layout::padding + layout::knobSize + layout::gap, displayY, layout::knobSize, layout::knobSize);
}
void PluginEditor::buildModTab()
{
modView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (modView);
const juce::StringArray lfoShapes = { "Sine", "Triangle", "Saw", "Square", "S&H", "Step", "Freehand" };
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
constexpr int colGap = 8;
constexpr int envH = 154;
constexpr int lfoH = 206;
// Envelopes (4 across).
for (int i = 0; i < kNumEnvelopes; ++i)
{
envPanels[(size_t) i].setBounds (gridX (i, colW, colGap), layout::margin, colW, envH);
modView.addAndMakeVisible (envPanels[(size_t) i]);
envPanels[(size_t) i].addAndMakeVisible (envDisplays[(size_t) i]);
envDisplays[(size_t) i].setBounds (layout::padding, layout::titleHeight,
colW - 2 * layout::padding, layout::envDisplayHeight);
const auto& idsA = paramIds::envAttack;
const auto& idsD = paramIds::envDecay;
const auto& idsS = paramIds::envSustain;
const auto& idsR = paramIds::envRelease;
const auto& idsC = paramIds::envCurve;
std::vector<Knob*> knobs = {
makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time (0.5 ms - 12 s)"),
makeKnob (&envPanels[(size_t) i], "Decay", idsD[i], formatSeconds, "Decay time (0.5 ms - 12 s)"),
makeKnob (&envPanels[(size_t) i], "Sustain", idsS[i], formatPercent, "Sustain level (0-100%)"),
makeKnob (&envPanels[(size_t) i], "Release", idsR[i], formatSeconds, "Release time (0.5 ms - 12 s)"),
makeKnob (&envPanels[(size_t) i], "Curve", idsC[i], formatPercent, "Envelope curve (0-100%)")
};
placeKnobs (knobs, layout::padding,
layout::titleHeight + layout::envDisplayHeight + layout::padding,
layout::knobSmall, layout::smallKnobHeight, layout::smallGap);
}
// LFOs (4 across).
const int lfoTop = layout::margin + envH + layout::panelSpacing;
for (int i = 0; i < kNumLfos; ++i)
{
lfoPanels[(size_t) i].setBounds (gridX (i, colW, colGap), lfoTop, colW, lfoH);
modView.addAndMakeVisible (lfoPanels[(size_t) i]);
const auto& rate = paramIds::lfoRate;
const auto& sync = paramIds::lfoSync;
const auto& beat = paramIds::lfoBeat;
const auto& shape = paramIds::lfoShape;
const auto& phase = paramIds::lfoPhase;
const auto& fade = paramIds::lfoFade;
const auto& delay = paramIds::lfoDelay;
makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "LFO shape")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight);
makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Tempo-sync LFO")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
lfoPanels[(size_t) i].addAndMakeVisible (lfoDisplays[(size_t) i]);
lfoDisplays[(size_t) i].setBounds (layout::padding, bodyTop(), colW - 2 * layout::padding, layout::lfoDisplayHeight);
std::vector<Knob*> knobs = {
makeKnob (&lfoPanels[(size_t) i], "Rate", rate[i], formatPercent, "LFO rate (0.02 Hz - 30 Hz)"),
makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], formatPercent, "Beat division (1/32 - 4 bars)"),
makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], formatPercent, "Phase (0-100%)"),
makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], formatPercent, "Fade in (0-100%)"),
makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], formatPercent, "Start delay (0-100%)")
};
placeKnobs (knobs, layout::padding,
bodyTop() + layout::lfoDisplayHeight + layout::padding,
layout::knobSmall, layout::smallKnobHeight, layout::smallGap);
lfoDisplays[(size_t) i].setOnShapeEdited ([this, i] (const std::vector<float>& data, int steps)
{
processor.engine.setLfoShapeData (i, data, steps);
});
}
// Modulation matrix.
const int matrixTop = lfoTop + lfoH + layout::panelSpacing;
const int matrixH = kBaseH - 60 - matrixTop - layout::margin;
matrixPanel.setBounds (layout::margin, matrixTop, kBaseW - 2 * layout::margin, matrixH);
modView.addAndMakeVisible (matrixPanel);
std::vector<ModTarget> targetEnums;
const juce::StringArray targetItems = modTargetItems (targetEnums);
const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob
modSourceCombo.addItemList (modSourceItems(), 1);
modSourceCombo.setSelectedItemIndex (0, juce::dontSendNotification);
modSourceCombo.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 160, layout::comboHeight);
modSourceCombo.setTooltip ("Modulation source");
matrixPanel.addAndMakeVisible (modSourceCombo);
modTargetCombo.addItemList (targetItems, 1);
modTargetCombo.setSelectedItemIndex (0, juce::dontSendNotification);
modTargetCombo.setBounds (layout::padding + 160 + layout::gap, mCenter - layout::comboHeight / 2, 200, layout::comboHeight);
modTargetCombo.setTooltip ("Modulation destination");
matrixPanel.addAndMakeVisible (modTargetCombo);
const int depthX = layout::padding + 160 + layout::gap + 200 + layout::gap;
modDepthKnob.setRange (-1.0, 1.0);
modDepthKnob.setValue (0.5, juce::dontSendNotification);
modDepthKnob.setFormatter (fmtDepth);
modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60);
modDepthKnob.setTooltip ("Modulation depth (-1 to +1)");
matrixPanel.addAndMakeVisible (modDepthKnob);
modBipolarToggle.setLabel ("Bipolar");
modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, 60, layout::toggleHeight);
modBipolarToggle.setTooltip ("Bipolar modulation depth");
matrixPanel.addAndMakeVisible (modBipolarToggle);
modBipolarToggle.setToggleState (true);
const int addX = depthX + layout::knobSize + layout::gap + 60 + layout::gap;
modAddButton.setButtonText ("Add");
modAddButton.setBounds (addX, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
modAddButton.setTooltip ("Add modulation connection");
modAddButton.onClick = [this, targetEnums]
{
const ModSource src = (ModSource) modSourceCombo.getSelectedItemIndex();
const int tid = modTargetCombo.getSelectedItemIndex();
if (tid >= 0 && tid < (int) targetEnums.size())
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
(float) modDepthKnob.getValue(),
modBipolarToggle.getToggleState());
}
updateModList();
}
};
matrixPanel.addAndMakeVisible (modAddButton);
modRemoveButton.setButtonText ("Remove Last");
modRemoveButton.setBounds (addX + 70 + layout::gap, mCenter - layout::buttonHeight / 2, 110, layout::buttonHeight);
modRemoveButton.setTooltip ("Remove last modulation connection");
modRemoveButton.onClick = [this]
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
auto& matrix = processor.engine.getMatrix();
if (matrix.size() > 0)
matrix.removeConnection (matrix.size() - 1);
}
updateModList();
};
matrixPanel.addAndMakeVisible (modRemoveButton);
modClearButton.setButtonText ("Clear");
modClearButton.setBounds (addX + 70 + layout::gap + 110 + layout::gap, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
modClearButton.setTooltip ("Clear all modulation connections");
modClearButton.onClick = [this]
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMatrix().clear();
}
updateModList();
};
matrixPanel.addAndMakeVisible (modClearButton);
const int listTop = layout::titleHeight + 60 + layout::padding;
modList.setBounds (layout::padding, listTop,
kBaseW - 2 * layout::margin - 2 * layout::padding,
matrixH - listTop - layout::padding);
modList.setReadOnly (true);
modList.setMultiLine (true, false);
modList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
modList.setColour (juce::TextEditor::textColourId, theme::text);
modList.setColour (juce::TextEditor::outlineColourId, juce::Colours::transparentBlack);
matrixPanel.addAndMakeVisible (modList);
}
void PluginEditor::buildFxTab()
{
fxView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (fxView);
const juce::StringArray fxTypes = { "Off", "Hyper", "Chorus", "Flanger", "Phaser",
"Distortion", "EQ", "Compressor", "Delay", "Reverb" };
constexpr int colW = 545;
constexpr int slotH = 150;
for (int i = 0; i < kNumFxSlots; ++i)
{
const int col = i % 2;
const int row = i / 2;
const int x = gridX (col, colW, layout::panelSpacing);
const int y = layout::margin + row * (slotH + layout::panelSpacing);
fxPanels[(size_t) i].setBounds (x, y, colW, slotH);
fxView.addAndMakeVisible (fxPanels[(size_t) i]);
fxTypeCombos[(size_t) i] = makeCombo (&fxPanels[(size_t) i], kFxType[i], fxTypes,
"FX slot " + juce::String (i + 1) + " effect type");
fxTypeCombos[(size_t) i]->setBounds (layout::padding, comboRowY(), 160, layout::comboHeight);
const int upX = layout::padding + 160 + layout::gap;
auto up = std::make_unique<juce::TextButton> ("\xe2\x86\x91");
fxUp[(size_t) i] = up.get();
ownedControls.push_back (std::move (up));
fxUp[(size_t) i]->setBounds (upX, comboRowY(), layout::arrowWidth, layout::comboHeight);
fxUp[(size_t) i]->setTooltip ("Move effect up");
fxPanels[(size_t) i].addAndMakeVisible (fxUp[(size_t) i]);
auto down = std::make_unique<juce::TextButton> ("\xe2\x86\x93");
fxDown[(size_t) i] = down.get();
ownedControls.push_back (std::move (down));
fxDown[(size_t) i]->setBounds (upX + layout::arrowWidth + layout::gap, comboRowY(), layout::arrowWidth, layout::comboHeight);
fxDown[(size_t) i]->setTooltip ("Move effect down");
fxPanels[(size_t) i].addAndMakeVisible (fxDown[(size_t) i]);
fxUp[(size_t) i]->onClick = [this, i] { if (i > 0) swapFxSlots (i, i - 1); };
fxDown[(size_t) i]->onClick = [this, i] { if (i < kNumFxSlots - 1) swapFxSlots (i, i + 1); };
std::vector<Knob*> knobs = {
makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Effect mix (0-100%)"),
makeKnob (&fxPanels[(size_t) i], "P1", kFxP1[i], formatPercent, "Effect parameter 1 (0-100%)"),
makeKnob (&fxPanels[(size_t) i], "P2", kFxP2[i], formatPercent, "Effect parameter 2 (0-100%)"),
makeKnob (&fxPanels[(size_t) i], "P3", kFxP3[i], formatPercent, "Effect parameter 3 (0-100%)"),
makeKnob (&fxPanels[(size_t) i], "P4", kFxP4[i], formatPercent, "Effect parameter 4 (0-100%)")
};
placeKnobs (knobs, layout::padding, bodyTop(), layout::knobSize, layout::knobSize);
fxKnobs[(size_t) i] = knobs;
}
}
void PluginEditor::buildMacroTab()
{
macroView.setBounds (0, 60, kBaseW, kBaseH - 60);
root.addChildComponent (macroView);
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
constexpr int colGap = 8;
constexpr int macroH = 158;
for (int i = 0; i < kNumMacros; ++i)
{
macroPanels[(size_t) i].setBounds (gridX (i, colW, colGap), layout::margin, colW, macroH);
macroView.addAndMakeVisible (macroPanels[(size_t) i]);
const auto& macroIds = paramIds::macros;
macroKnobs[(size_t) i] = makeKnob (&macroPanels[(size_t) i], MacroControls::macroName (i), macroIds[i], formatPercent,
MacroControls::macroName (i) + " macro (0-100%)");
macroKnobs[(size_t) i]->setBounds ((colW - layout::macroKnobSize) / 2,
layout::titleHeight + layout::padding,
layout::macroKnobSize, layout::macroKnobSize);
}
// Macro assignment editor.
const int assignTop = layout::margin + macroH + layout::panelSpacing;
const int assignH = kBaseH - 60 - assignTop - layout::margin;
macroAssignPanel.setBounds (layout::margin, assignTop, kBaseW - 2 * layout::margin, assignH);
macroView.addAndMakeVisible (macroAssignPanel);
std::vector<ModTarget> targetEnums;
const juce::StringArray targetItems = modTargetItems (targetEnums);
const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob
macroAssignIndex.addItemList ({ "Macro 1", "Macro 2", "Macro 3", "Macro 4" }, 1);
macroAssignIndex.setSelectedItemIndex (0, juce::dontSendNotification);
macroAssignIndex.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 140, layout::comboHeight);
macroAssignIndex.setTooltip ("Macro to assign");
macroAssignPanel.addAndMakeVisible (macroAssignIndex);
macroAssignTarget.addItemList (targetItems, 1);
macroAssignTarget.setSelectedItemIndex (0, juce::dontSendNotification);
macroAssignTarget.setBounds (layout::padding + 140 + layout::gap, mCenter - layout::comboHeight / 2, 220, layout::comboHeight);
macroAssignTarget.setTooltip ("Destination parameter");
macroAssignPanel.addAndMakeVisible (macroAssignTarget);
const int depthX = layout::padding + 140 + layout::gap + 220 + layout::gap;
macroDepthKnob.setFormatter (fmtDepth);
macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60);
macroDepthKnob.setTooltip ("Macro assignment depth (0 to 1)");
macroAssignPanel.addAndMakeVisible (macroDepthKnob);
const int assignBtnX = depthX + layout::knobSize + layout::gap;
macroAssignButton.setButtonText ("Assign");
macroAssignButton.setBounds (assignBtnX, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight);
macroAssignButton.setTooltip ("Assign destination to the selected macro");
macroAssignButton.onClick = [this, targetEnums]
{
const int macro = macroAssignIndex.getSelectedItemIndex();
const int tid = macroAssignTarget.getSelectedItemIndex();
if (tid >= 0 && tid < (int) targetEnums.size())
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
(float) macroDepthKnob.getValue());
}
updateMacroList();
}
};
macroAssignPanel.addAndMakeVisible (macroAssignButton);
macroClearButton.setButtonText ("Clear All");
macroClearButton.setBounds (assignBtnX + 80 + layout::gap, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight);
macroClearButton.setTooltip ("Clear all macro assignments");
macroClearButton.onClick = [this]
{
{
const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMacros().clear();
}
updateMacroList();
};
macroAssignPanel.addAndMakeVisible (macroClearButton);
const int listTop = layout::titleHeight + 60 + layout::padding;
macroList.setBounds (layout::padding, listTop,
kBaseW - 2 * layout::margin - 2 * layout::padding,
assignH - listTop - layout::padding);
macroList.setReadOnly (true);
macroList.setMultiLine (true, false);
macroList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
macroList.setColour (juce::TextEditor::textColourId, theme::text);
macroList.setColour (juce::TextEditor::outlineColourId, juce::Colours::transparentBlack);
macroAssignPanel.addAndMakeVisible (macroList);
}
void PluginEditor::swapFxSlots (int a, int b)
{
if (a < 0 || b < 0 || a >= kNumFxSlots || b >= kNumFxSlots || a == b)
return;
auto swapParam = [this] (const char* pa, const char* pb)
{
auto* p1 = processor.parameters.getParameter (pa);
auto* p2 = processor.parameters.getParameter (pb);
if (p1 == nullptr || p2 == nullptr)
return;
const float v1 = p1->getValue();
const float v2 = p2->getValue();
p1->setValueNotifyingHost (v2);
p2->setValueNotifyingHost (v1);
};
swapParam (kFxType[a], kFxType[b]);
swapParam (kFxMix[a], kFxMix[b]);
swapParam (kFxP1[a], kFxP1[b]);
swapParam (kFxP2[a], kFxP2[b]);
swapParam (kFxP3[a], kFxP3[b]);
swapParam (kFxP4[a], kFxP4[b]);
}
// ---------------------------------------------------------------------------
void PluginEditor::setTab (int index)
{
currentTab = juce::jlimit (0, 4, index);
oscView.setVisible (currentTab == 0);
filterView.setVisible(currentTab == 1);
modView.setVisible (currentTab == 2);
fxView.setVisible (currentTab == 3);
macroView.setVisible (currentTab == 4);
tabOsc.setToggleState (currentTab == 0, juce::dontSendNotification);
tabFilter.setToggleState(currentTab == 1, juce::dontSendNotification);
tabMod.setToggleState (currentTab == 2, juce::dontSendNotification);
tabFx.setToggleState (currentTab == 3, juce::dontSendNotification);
tabMacro.setToggleState (currentTab == 4, juce::dontSendNotification);
timerCallback();
}
void PluginEditor::cycleTab (int delta)
{
setTab ((currentTab + delta + 5) % 5);
}
bool PluginEditor::keyPressed (const juce::KeyPress& key)
{
// Only handle plain Tab / Shift+Tab (never Ctrl/Cmd/Alt+Tab).
if (key.getKeyCode() == juce::KeyPress::tabKey
&& ! key.getModifiers().isCtrlDown()
&& ! key.getModifiers().isAltDown()
&& ! key.getModifiers().isCommandDown())
{
// Leave Tab alone while a text editor or combo box is being edited so we
// don't break typing / focus traversal.
for (auto* c = juce::Component::getCurrentlyFocusedComponent(); c != nullptr; c = c->getParentComponent())
{
if (dynamic_cast<juce::TextEditor*> (c) != nullptr
|| dynamic_cast<juce::ComboBox*> (c) != nullptr)
return false;
}
cycleTab (key.getModifiers().isShiftDown() ? -1 : +1);
return true;
}
return false;
}
void PluginEditor::applyUiScale()
{
const float scale = processor.getUiScale();
currentScaleIndex = processor.getUiScaleIndex();
root.setTransform (juce::AffineTransform::scale (scale));
setSize ((int) (kBaseW * scale), (int) (kBaseH * scale));
}
// ---------------------------------------------------------------------------
void PluginEditor::updateVisuals()
{
const auto& apvts = processor.parameters;
auto gv = [&] (const char* id)
{
if (auto* value = apvts.getRawParameterValue (id))
return value->load();
return 0.0f;
};
masterDisplay.setValue (formatPercent (gv (ids::master)));
// Waveforms.
if (currentTab == 0)
{
const int waveA = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (gv (ids::oscAWave) * (kNumWavetables - 1)));
const int waveB = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (gv (ids::oscBWave) * (kNumWavetables - 1)));
oscAWave.setWaveIndex (waveA);
oscAWave.setFramePosition (gv (ids::oscAWtPos));
oscAWave.setEnabled (gv (ids::oscAOn) > 0.5f);
oscBWave.setWaveIndex (waveB);
oscBWave.setFramePosition (gv (ids::oscBWtPos));
oscBWave.setEnabled (gv (ids::oscBOn) > 0.5f);
}
// Filters.
if (currentTab == 1)
{
filter1Display.setParams ((int) std::llround (gv (ids::f1Type) * 6.0f), gv (ids::f1Cutoff), gv (ids::f1Res),
gv (ids::f1Drive), (int) std::llround (gv (ids::f1Slope) * 2.0f));
filter1Display.setEnabled (gv (ids::f1On) > 0.5f);
filter2Display.setParams ((int) std::llround (gv (ids::f2Type) * 6.0f), gv (ids::f2Cutoff), gv (ids::f2Res),
gv (ids::f2Drive), (int) std::llround (gv (ids::f2Slope) * 2.0f));
filter2Display.setEnabled (gv (ids::f2On) > 0.5f);
}
// Envelopes.
if (currentTab == 2)
{
const auto& a = paramIds::envAttack;
const auto& d = paramIds::envDecay;
const auto& s = paramIds::envSustain;
const auto& r = paramIds::envRelease;
const auto& c = paramIds::envCurve;
for (int i = 0; i < kNumEnvelopes; ++i)
envDisplays[(size_t) i].setParams (gv (a[i]), gv (d[i]), gv (s[i]), gv (r[i]), gv (c[i]));
}
// LFOs.
if (currentTab == 2)
{
const auto& lshape = paramIds::lfoShape;
const juce::ScopedLock lock (processor.engine.getControlLock());
for (int i = 0; i < kNumLfos; ++i)
{
const auto& lfo = processor.engine.getLfos()[(size_t) i];
lfoDisplays[(size_t) i].setShape ((int) std::llround (gv (lshape[i]) * 6.0f));
lfoDisplays[(size_t) i].setShapeData (lfo.getShapeData(), lfo.getShapeSteps());
}
}
if (currentTab == 3)
{
for (int i = 0; i < kNumFxSlots; ++i)
{
const int type = juce::jlimit (0, (int) FxType::Count - 1,
(int) std::llround (gv (kFxType[i]) * ((int) FxType::Count - 1)));
for (int p = 0; p < 4; ++p)
{
auto* knob = fxKnobs[(size_t) i][(size_t) p + 1];
const juce::String name = kFxParamNames[type][p];
if (knob->getName() != name)
{
knob->setName (name);
knob->setTooltip (name == "Unused" ? "Not used by this effect"
: name + " (normalised 0-100%)");
knob->repaint();
}
}
}
}
// RAVE state.
raveButton.setToggleState (processor.isRaveEnabled());
const int program = processor.getCurrentProgram();
if (presetCombo.getSelectedItemIndex() != program)
presetCombo.setSelectedItemIndex (program, juce::dontSendNotification);
// Scale change.
if (processor.getUiScaleIndex() != currentScaleIndex)
applyUiScale();
if (scaleCombo.getSelectedItemIndex() != currentScaleIndex)
scaleCombo.setSelectedItemIndex (currentScaleIndex, juce::dontSendNotification);
}
void PluginEditor::updateModList()
{
juce::String text;
{
const juce::ScopedLock lock (processor.engine.getControlLock());
const auto& cons = processor.engine.getMatrix().connections;
for (const auto& c : cons)
text += modSourceName (c.source) + " -> " + modTargetName (c.target)
+ " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n";
}
if (modList.getText() != text)
modList.setText (text, false);
}
void PluginEditor::updateMacroList()
{
juce::String text;
{
const juce::ScopedLock lock (processor.engine.getControlLock());
for (int m = 0; m < kNumMacros; ++m)
{
text += MacroControls::macroName (m) + ":\n";
const auto& assigns = processor.engine.getMacros().assignments[(size_t) m];
if (assigns.empty())
text += " (none)\n";
for (const auto& a : assigns)
text += " " + modTargetName (a.target) + " [" + juce::String (a.depth, 2) + "]\n";
}
}
if (macroList.getText() != text)
macroList.setText (text, false);
}
void PluginEditor::timerCallback()
{
updateVisuals();
if (currentTab == 2)
updateModList();
else if (currentTab == 4)
updateMacroList();
}
} // namespace serum
+134
View File
@@ -0,0 +1,134 @@
#pragma once
#include <JuceHeader.h>
#include "PluginProcessor.h"
#include "GUI/SerumLookAndFeel.h"
#include "GUI/Knob.h"
#include "GUI/Slider.h"
#include "GUI/ToggleButton.h"
#include "GUI/Display.h"
#include "GUI/Panel.h"
#include "GUI/WaveformDisplay.h"
#include "GUI/FilterDisplay.h"
#include "GUI/EnvelopeDisplay.h"
#include "GUI/LFODisplay.h"
namespace serum
{
// ===========================================================================
// SerumAlt editor: dark vector GUI with a tabbed layout, real-time
// visualisations, RAVE, macro controls and preset-scale (75%..200%) scaling.
// ===========================================================================
class PluginEditor : public juce::AudioProcessorEditor,
public juce::Timer
{
public:
explicit PluginEditor (SerumAltAudioProcessor& p);
~PluginEditor() override;
void paint (juce::Graphics&) override;
void resized() override;
void timerCallback() override;
bool keyPressed (const juce::KeyPress& key) override;
void setTab (int index);
private:
SerumAltAudioProcessor& processor;
SerumLookAndFeel laf;
static constexpr int kBaseW = 1120;
static constexpr int kBaseH = 740;
juce::Component root;
// --- tooltips ---
juce::TooltipWindow tooltipWindow;
// --- top bar ---
std::unique_ptr<juce::Drawable> logo;
juce::ComboBox presetCombo;
juce::ComboBox scaleCombo;
ToggleButton raveButton;
Knob masterKnob;
Display masterDisplay;
// --- tabs ---
juce::TextButton tabOsc, tabFilter, tabMod, tabFx, tabMacro;
juce::Component oscView, filterView, modView, fxView, macroView;
int currentTab = 0;
// --- OSC ---
Panel oscAPanel { "Oscillator A" }, oscBPanel { "Oscillator B" };
Panel subPanel { "Sub" }, noisePanel { "Noise" };
WaveformDisplay oscAWave, oscBWave;
std::vector<juce::Component*> oscAComponents, oscBComponents;
// --- FILTER ---
Panel filter1Panel { "Filter 1" }, filter2Panel { "Filter 2" }, routingPanel { "Routing" };
FilterDisplay filter1Display, filter2Display;
// --- MOD ---
std::array<Panel, kNumEnvelopes> envPanels { { Panel ("Env 1 (Amp)"), Panel ("Env 2 (Filter)"),
Panel ("Env 3"), Panel ("Env 4") } };
std::array<EnvelopeDisplay, kNumEnvelopes> envDisplays;
std::array<Panel, kNumLfos> lfoPanels { { Panel ("LFO 1"), Panel ("LFO 2"), Panel ("LFO 3"), Panel ("LFO 4") } };
std::array<LFODisplay, kNumLfos> lfoDisplays;
Panel matrixPanel { "Modulation Matrix" };
juce::ComboBox modSourceCombo, modTargetCombo;
Knob modDepthKnob { "Depth" };
ToggleButton modBipolarToggle;
juce::TextButton modAddButton, modRemoveButton, modClearButton;
juce::TextEditor modList;
// --- FX ---
std::array<Panel, kNumFxSlots> fxPanels;
std::array<juce::ComboBox*, kNumFxSlots> fxTypeCombos;
std::array<std::vector<Knob*>, kNumFxSlots> fxKnobs;
std::array<juce::TextButton*, kNumFxSlots> fxUp, fxDown;
// --- MACRO ---
std::array<Panel, kNumMacros> macroPanels;
std::array<Knob*, kNumMacros> macroKnobs;
Panel macroAssignPanel { "Macro Assignments" };
juce::ComboBox macroAssignTarget;
juce::ComboBox macroAssignIndex;
Knob macroDepthKnob { "Depth" };
juce::TextButton macroAssignButton, macroClearButton;
juce::TextEditor macroList;
std::vector<std::unique_ptr<juce::Component>> ownedControls;
// --- attachments ---
std::vector<std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment>> sliderAttachments;
std::vector<std::unique_ptr<juce::AudioProcessorValueTreeState::ComboBoxAttachment>> comboAttachments;
int currentScaleIndex = -1;
// --- helpers ---
Knob* makeKnob (juce::Component* parent, const juce::String& name, const juce::String& paramId,
std::function<juce::String (float)> fmt = {}, const juce::String& tooltip = {});
juce::ComboBox* makeCombo (juce::Component* parent, const juce::String& paramId, const juce::StringArray& items,
const juce::String& tooltip = {});
ToggleButton* makeToggle (juce::Component* parent, const juce::String& label, const juce::String& paramId,
const juce::String& tooltip = {});
void cycleTab (int delta);
void buildTopBar();
void buildOscTab();
void buildFilterTab();
void buildModTab();
void buildFxTab();
void buildMacroTab();
void swapFxSlots (int a, int b);
void applyUiScale();
void updateVisuals();
void updateModList();
void updateMacroList();
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PluginEditor)
};
} // namespace serum
+453
View File
@@ -0,0 +1,453 @@
#include "PluginProcessor.h"
#include "PluginEditor.h"
#include "Presets/FactoryPresets.h"
namespace serum
{
namespace
{
std::unique_ptr<juce::AudioParameterFloat> f (const char* id, const juce::String& name, float def)
{
return std::make_unique<juce::AudioParameterFloat> (juce::ParameterID { id, 1 }, name,
juce::NormalisableRange<float> (0.0f, 1.0f), def);
}
}
// ---------------------------------------------------------------------------
SerumAltAudioProcessor::SerumAltAudioProcessor()
: AudioProcessor (BusesProperties().withInput ("Input", juce::AudioChannelSet::stereo(), false)
.withOutput ("Output", juce::AudioChannelSet::stereo(), true)),
parameters (*this, nullptr, juce::Identifier ("SerumAlt"), createParameterLayout())
{
}
SerumAltAudioProcessor::~SerumAltAudioProcessor() = default;
juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::createParameterLayout()
{
std::vector<std::unique_ptr<juce::RangedAudioParameter>> params;
// Global
params.push_back (f (ids::master, "Master", 0.8f));
params.push_back (f (ids::uiScale, "UI Scale", 0.25f));
params.push_back (f (ids::rave, "RAVE", 0.0f));
// Oscillator A
params.push_back (f (ids::oscAOn, "Osc A On", 1.0f));
params.push_back (f (ids::oscAWave, "Osc A Wave", 0.0f));
params.push_back (f (ids::oscAWtPos, "Osc A WT Pos", 0.0f));
params.push_back (f (ids::oscAWarp, "Osc A Warp", 0.0f));
params.push_back (f (ids::oscAWarpAmt, "Osc A Warp Amt", 0.0f));
params.push_back (f (ids::oscACoarse, "Osc A Coarse", 0.5f));
params.push_back (f (ids::oscAFine, "Osc A Fine", 0.5f));
params.push_back (f (ids::oscALevel, "Osc A Level", 0.8f));
params.push_back (f (ids::oscAPan, "Osc A Pan", 0.5f));
params.push_back (f (ids::oscAUnison, "Osc A Unison", 0.0f));
params.push_back (f (ids::oscADetune, "Osc A Detune", 0.0f));
params.push_back (f (ids::oscASpread, "Osc A Spread", 0.0f));
params.push_back (f (ids::oscAPhase, "Osc A Phase", 0.0f));
params.push_back (f (ids::oscARandPh, "Osc A Rand Phase", 0.0f));
// Oscillator B
params.push_back (f (ids::oscBOn, "Osc B On", 0.0f));
params.push_back (f (ids::oscBWave, "Osc B Wave", 1.0f / 9.0f));
params.push_back (f (ids::oscBWtPos, "Osc B WT Pos", 0.0f));
params.push_back (f (ids::oscBWarp, "Osc B Warp", 0.0f));
params.push_back (f (ids::oscBWarpAmt, "Osc B Warp Amt", 0.0f));
params.push_back (f (ids::oscBCoarse, "Osc B Coarse", 0.5f));
params.push_back (f (ids::oscBFine, "Osc B Fine", 0.5f));
params.push_back (f (ids::oscBLevel, "Osc B Level", 0.5f));
params.push_back (f (ids::oscBPan, "Osc B Pan", 0.5f));
params.push_back (f (ids::oscBUnison, "Osc B Unison", 0.0f));
params.push_back (f (ids::oscBDetune, "Osc B Detune", 0.0f));
params.push_back (f (ids::oscBSpread, "Osc B Spread", 0.0f));
params.push_back (f (ids::oscBPhase, "Osc B Phase", 0.0f));
params.push_back (f (ids::oscBRandPh, "Osc B Rand Phase", 0.0f));
// Sub
params.push_back (f (ids::subOn, "Sub On", 0.0f));
params.push_back (f (ids::subShape, "Sub Shape", 0.0f));
params.push_back (f (ids::subOct, "Sub Octave", 1.0f / 2.0f));
params.push_back (f (ids::subLevel, "Sub Level", 0.5f));
// Noise
params.push_back (f (ids::noiseOn, "Noise On", 0.0f));
params.push_back (f (ids::noiseType, "Noise Type", 0.0f));
params.push_back (f (ids::noiseLevel, "Noise Level", 0.5f));
// Filter 1
params.push_back (f (ids::f1On, "Filter 1 On", 1.0f));
params.push_back (f (ids::f1Type, "Filter 1 Type", 0.0f));
params.push_back (f (ids::f1Cutoff, "Filter 1 Cutoff", 0.65f));
params.push_back (f (ids::f1Res, "Filter 1 Res", 0.05f));
params.push_back (f (ids::f1Drive, "Filter 1 Drive", 0.0f));
params.push_back (f (ids::f1Key, "Filter 1 Keytrack", 0.0f));
params.push_back (f (ids::f1Slope, "Filter 1 Slope", 1.0f));
// Filter 2
params.push_back (f (ids::f2On, "Filter 2 On", 0.0f));
params.push_back (f (ids::f2Type, "Filter 2 Type", 0.0f));
params.push_back (f (ids::f2Cutoff, "Filter 2 Cutoff", 0.5f));
params.push_back (f (ids::f2Res, "Filter 2 Res", 0.0f));
params.push_back (f (ids::f2Drive, "Filter 2 Drive", 0.0f));
params.push_back (f (ids::f2Key, "Filter 2 Keytrack", 0.0f));
params.push_back (f (ids::f2Slope, "Filter 2 Slope", 1.0f));
params.push_back (f (ids::fRoute, "Filter Route", 0.0f));
params.push_back (f (ids::fMix, "Filter Mix", 0.5f));
params.push_back (f (ids::fOut, "Filter Out", 0.667f));
// Envelopes 1..4
const char* envA[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
const char* envD[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
const char* envS[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
const char* envR[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
const char* envC[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
const float envADef[4] = { 0.05f, 0.1f, 0.2f, 0.2f };
const float envDDef[4] = { 0.25f, 0.3f, 0.3f, 0.3f };
const float envSDef[4] = { 0.8f, 0.5f, 0.5f, 0.5f };
const float envRDef[4] = { 0.3f, 0.3f, 0.4f, 0.4f };
for (int i = 0; i < 4; ++i)
{
params.push_back (f (envA[i], juce::String ("Env ") + juce::String (i + 1) + " Attack", envADef[i]));
params.push_back (f (envD[i], juce::String ("Env ") + juce::String (i + 1) + " Decay", envDDef[i]));
params.push_back (f (envS[i], juce::String ("Env ") + juce::String (i + 1) + " Sustain", envSDef[i]));
params.push_back (f (envR[i], juce::String ("Env ") + juce::String (i + 1) + " Release", envRDef[i]));
params.push_back (f (envC[i], juce::String ("Env ") + juce::String (i + 1) + " Curve", 0.5f));
}
// LFOs 1..4
const char* lfoRate[4] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate };
const char* lfoSync[4] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync };
const char* lfoBeat[4] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat };
const char* lfoShape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
const char* lfoPhase[4] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase };
const char* lfoFade[4] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade };
const char* lfoDelay[4] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay };
const float lfoShapeDef[4] = { 0.0f, 1.0f / 6.0f, 2.0f / 6.0f, 3.0f / 6.0f };
for (int i = 0; i < 4; ++i)
{
const juce::String n = juce::String ("LFO ") + juce::String (i + 1);
params.push_back (f (lfoRate[i], n + " Rate", 0.5f));
params.push_back (f (lfoSync[i], n + " Sync", 0.0f));
params.push_back (f (lfoBeat[i], n + " Beat", 0.5f));
params.push_back (f (lfoShape[i], n + " Shape", lfoShapeDef[i]));
params.push_back (f (lfoPhase[i], n + " Phase", 0.0f));
params.push_back (f (lfoFade[i], n + " Fade", 0.0f));
params.push_back (f (lfoDelay[i], n + " Delay", 0.0f));
}
// FX slots 1..8
const char* fxType[8] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
const char* fxMix[8] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
const char* fxP[8][4] = {
{ ids::fx1P1, ids::fx1P2, ids::fx1P3, ids::fx1P4 },
{ ids::fx2P1, ids::fx2P2, ids::fx2P3, ids::fx2P4 },
{ ids::fx3P1, ids::fx3P2, ids::fx3P3, ids::fx3P4 },
{ ids::fx4P1, ids::fx4P2, ids::fx4P3, ids::fx4P4 },
{ ids::fx5P1, ids::fx5P2, ids::fx5P3, ids::fx5P4 },
{ ids::fx6P1, ids::fx6P2, ids::fx6P3, ids::fx6P4 },
{ ids::fx7P1, ids::fx7P2, ids::fx7P3, ids::fx7P4 },
{ ids::fx8P1, ids::fx8P2, ids::fx8P3, ids::fx8P4 }
};
for (int i = 0; i < 8; ++i)
{
const juce::String n = juce::String ("FX ") + juce::String (i + 1);
params.push_back (f (fxType[i], n + " Type", 0.0f));
params.push_back (f (fxMix[i], n + " Mix", 0.5f));
params.push_back (f (fxP[i][0], n + " P1", 0.5f));
params.push_back (f (fxP[i][1], n + " P2", 0.5f));
params.push_back (f (fxP[i][2], n + " P3", 0.5f));
params.push_back (f (fxP[i][3], n + " P4", 0.5f));
}
// Macros
params.push_back (f (ids::macro1, "Macro 1", 0.0f));
params.push_back (f (ids::macro2, "Macro 2", 0.0f));
params.push_back (f (ids::macro3, "Macro 3", 0.0f));
params.push_back (f (ids::macro4, "Macro 4", 0.0f));
return { params.begin(), params.end() };
}
// ---------------------------------------------------------------------------
void SerumAltAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
engine.prepare (sampleRate, samplesPerBlock, parameters);
}
void SerumAltAudioProcessor::releaseResources()
{
engine.reset();
}
void SerumAltAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi)
{
juce::ScopedNoDenormals noDenormals;
engine.processBlock (buffer, midi, parameters, getPlayHead());
}
juce::AudioProcessorEditor* SerumAltAudioProcessor::createEditor()
{
return new PluginEditor (*this);
}
// ---------------------------------------------------------------------------
// Programs / presets
// ---------------------------------------------------------------------------
int SerumAltAudioProcessor::getNumPrograms()
{
return (int) getFactoryPresets().size();
}
int SerumAltAudioProcessor::getCurrentProgram()
{
return currentProgram.load();
}
void SerumAltAudioProcessor::setCurrentProgram (int index)
{
if (getNumPrograms() > 0)
loadFactoryPreset (juce::jlimit (0, getNumPrograms() - 1, index));
}
const juce::String SerumAltAudioProcessor::getProgramName (int index)
{
const auto& presets = getFactoryPresets();
if (index >= 0 && index < (int) presets.size())
return presets[(size_t) index].name;
return {};
}
void SerumAltAudioProcessor::changeProgramName (int, const juce::String&)
{
}
int SerumAltAudioProcessor::getNumFactoryPresets() const
{
return (int) getFactoryPresets().size();
}
void SerumAltAudioProcessor::loadFactoryPreset (int index)
{
const auto& presets = getFactoryPresets();
if (index < 0 || index >= (int) presets.size())
return;
const juce::ScopedLock lock (engine.getControlLock());
const FactoryPreset& preset = presets[(size_t) index];
const auto* uiScaleParam = parameters.getParameter (ids::uiScale);
for (auto* param : getParameters())
if (param != nullptr && param != uiScaleParam)
param->setValueNotifyingHost (param->getDefaultValue());
for (const auto& kv : preset.params)
if (auto* param = parameters.getParameter (kv.first))
if (param != uiScaleParam && std::isfinite (kv.second))
param->setValueNotifyingHost (juce::jlimit (0.0f, 1.0f, kv.second));
// RAVE should start off for a freshly loaded preset.
if (auto* raveParam = parameters.getParameter (ids::rave))
raveParam->setValueNotifyingHost (0.0f);
auto& matrix = engine.getMatrix();
matrix.clear();
for (const auto& mod : preset.mods)
if (! matrix.addConnection (mod.source, mod.target, mod.depth, mod.bipolar))
continue;
auto& macros = engine.getMacros();
macros.clear();
for (const auto& ma : preset.macroAssigns)
if (! macros.addAssignment (ma.macro, ma.target, ma.depth))
continue;
restoreLfoShapesFromState ({});
currentProgram.store (index);
}
// ---------------------------------------------------------------------------
// RAVE / UI scale
// ---------------------------------------------------------------------------
void SerumAltAudioProcessor::setRaveEnabled (bool enabled)
{
const juce::ScopedLock lock (engine.getControlLock());
if (auto* raveParam = parameters.getParameter (ids::rave))
{
raveParam->beginChangeGesture();
raveParam->setValueNotifyingHost (enabled ? 1.0f : 0.0f);
raveParam->endChangeGesture();
}
}
bool SerumAltAudioProcessor::isRaveEnabled() const
{
if (auto* raveParam = parameters.getRawParameterValue (ids::rave))
return raveParam->load() > 0.5f;
return false;
}
int SerumAltAudioProcessor::getUiScaleIndex() const
{
if (auto* p = parameters.getRawParameterValue (ids::uiScale))
{
const float value = p->load();
if (std::isfinite (value))
return (int) std::llround (juce::jlimit (0.0f, 1.0f, value) * 4.0f);
}
return 1;
}
void SerumAltAudioProcessor::setUiScaleIndex (int index)
{
const juce::ScopedLock lock (engine.getControlLock());
index = juce::jlimit (0, 4, index);
if (auto* p = parameters.getParameter (ids::uiScale))
p->setValueNotifyingHost ((float) index / 4.0f);
}
float SerumAltAudioProcessor::getUiScale() const
{
static constexpr float scales[5] = { 0.75f, 1.0f, 1.25f, 1.5f, 2.0f };
return scales[getUiScaleIndex()];
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
void SerumAltAudioProcessor::getStateInformation (juce::MemoryBlock& destData)
{
const juce::ScopedLock lock (engine.getControlLock());
auto state = parameters.copyState();
for (int i = state.getNumChildren(); --i >= 0;)
{
const auto child = state.getChild (i);
if (child.hasType ("MODMATRIX") || child.hasType ("MACROS") || child.hasType ("LFOSHAPES"))
state.removeChild (i, nullptr);
}
state.setProperty ("currentProgram", currentProgram.load(), nullptr);
state.appendChild (engine.getMatrix().toValueTree(), nullptr);
state.appendChild (engine.getMacros().toValueTree(), nullptr);
saveLfoShapesToState (state);
std::unique_ptr<juce::XmlElement> xml (state.createXml());
if (xml != nullptr)
copyXmlToBinary (*xml, destData);
}
void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBytes)
{
if (data == nullptr || sizeInBytes <= 0)
return;
std::unique_ptr<juce::XmlElement> xml (getXmlFromBinary (data, sizeInBytes));
if (xml == nullptr || ! xml->hasTagName ("SerumAlt"))
return;
juce::ValueTree state = juce::ValueTree::fromXml (*xml);
if (! state.hasType ("SerumAlt"))
return;
const juce::ScopedLock lock (engine.getControlLock());
// A persisted RAVE toggle is rendered non-destructively, so retain it.
parameters.replaceState (state);
engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX"));
engine.getMacros().fromValueTree (state.getChildWithName ("MACROS"));
restoreLfoShapesFromState (state);
const double savedProgram = (double) state.getProperty ("currentProgram", 0);
currentProgram.store (std::isfinite (savedProgram)
? (int) juce::jlimit (0.0, (double) juce::jmax (0, getNumPrograms() - 1), savedProgram) : 0);
}
void SerumAltAudioProcessor::saveLfoShapesToState (juce::ValueTree& state) const
{
const juce::ScopedLock lock (engine.getControlLock());
juce::ValueTree tree ("LFOSHAPES");
for (int i = 0; i < kNumLfos; ++i)
{
const auto& source = engine.getLfos()[(size_t) i];
const auto& data = source.getShapeData();
juce::ValueTree lfo ("LFO");
lfo.setProperty ("index", i, nullptr);
lfo.setProperty ("steps", juce::jlimit (2, LFO::kShapePoints, source.getShapeSteps()), nullptr);
juce::Array<juce::var> arr;
for (int point = 0; point < juce::jmin (LFO::kShapePoints, (int) data.size()); ++point)
{
const float value = data[(size_t) point];
arr.add (std::isfinite (value) ? juce::jlimit (-1.0f, 1.0f, value) : 0.0f);
}
lfo.setProperty ("data", juce::JSON::toString (juce::var (arr), true), nullptr);
tree.appendChild (lfo, nullptr);
}
state.appendChild (tree, nullptr);
}
void SerumAltAudioProcessor::restoreLfoShapesFromState (const juce::ValueTree& state)
{
const juce::ScopedLock lock (engine.getControlLock());
std::vector<float> defaultShape ((size_t) LFO::kShapePoints);
for (int point = 0; point < LFO::kShapePoints; ++point)
defaultShape[(size_t) point] = (point % 2 == 0) ? 1.0f : -1.0f;
for (int index = 0; index < kNumLfos; ++index)
engine.setLfoShapeData (index, defaultShape, 16);
const juce::ValueTree tree = state.getChildWithName ("LFOSHAPES");
if (! tree.isValid())
return;
std::array<bool, kNumLfos> restored {};
for (const auto& lfo : tree)
{
if (! lfo.hasType ("LFO"))
continue;
const double savedIndex = (double) lfo.getProperty ("index", -1);
if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumLfos
|| std::floor (savedIndex) != savedIndex)
continue;
const int index = (int) savedIndex;
if (restored[(size_t) index])
continue;
const double savedSteps = (double) lfo.getProperty ("steps", 16);
const int steps = std::isfinite (savedSteps)
? (int) juce::jlimit (2.0, (double) LFO::kShapePoints, savedSteps) : 16;
juce::var shape = lfo.getProperty ("data");
if (shape.isString())
{
const auto text = shape.toString();
if (text.length() > 8192)
continue;
shape = juce::JSON::parse (text);
}
const auto* arr = shape.getArray();
if (arr == nullptr || arr->isEmpty())
continue;
const int numPoints = juce::jmin (LFO::kShapePoints, arr->size());
std::vector<float> data;
data.reserve ((size_t) numPoints);
for (int point = 0; point < numPoints; ++point)
{
const auto& savedValue = arr->getReference (point);
const double value = (savedValue.isDouble() || savedValue.isInt() || savedValue.isInt64())
? (double) savedValue : 0.0;
data.push_back (std::isfinite (value) ? (float) juce::jlimit (-1.0, 1.0, value) : 0.0f);
}
engine.setLfoShapeData (index, data, steps);
restored[(size_t) index] = true;
}
}
} // namespace serum
// ===========================================================================
// Plugin entry point (required by the JUCE plugin clients).
// ===========================================================================
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()
{
return new serum::SerumAltAudioProcessor();
}
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <JuceHeader.h>
#include <atomic>
#include "Params.h"
#include "Engine.h"
namespace serum
{
// ===========================================================================
// SerumAlt — a wavetable synthesiser (VST3 / AU). Hosts the APVTS, the audio
// engine, preset management and the RAVE toggle.
// ===========================================================================
class SerumAltAudioProcessor : public juce::AudioProcessor
{
public:
SerumAltAudioProcessor();
~SerumAltAudioProcessor() override;
// --- AudioProcessor ---
void prepareToPlay (double sampleRate, int samplesPerBlock) override;
void releaseResources() override;
void processBlock (juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override { return true; }
const juce::String getName() const override { return "SerumAlt"; }
bool acceptsMidi() const override { return true; }
bool producesMidi() const override { return false; }
bool isMidiEffect() const override { return false; }
double getTailLengthSeconds() const override { return 2.0; }
int getNumPrograms() override;
int getCurrentProgram() override;
void setCurrentProgram (int index) override;
const juce::String getProgramName (int index) override;
void changeProgramName (int index, const juce::String& newName) override;
void getStateInformation (juce::MemoryBlock& destData) override;
void setStateInformation (const void* data, int sizeInBytes) override;
// --- SerumAlt ---
void setRaveEnabled (bool enabled);
bool isRaveEnabled() const;
int getUiScaleIndex() const;
void setUiScaleIndex (int index);
float getUiScale() const;
void loadFactoryPreset (int index);
int getNumFactoryPresets() const;
// Public control state (hold the engine control lock for matrix/macros/LFOs).
juce::AudioProcessorValueTreeState parameters;
Engine engine;
static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout();
private:
std::atomic<int> currentProgram { 0 };
void restoreLfoShapesFromState (const juce::ValueTree& state);
void saveLfoShapesToState (juce::ValueTree& state) const;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SerumAltAudioProcessor)
};
} // namespace serum
+33
View File
@@ -0,0 +1,33 @@
# presets module
## Owned files
- FactoryPresets.h
- FactoryPresets.cpp
## Rules
- Build presets as sparse maps; unset parameters keep their APVTS defaults.
- Use `ids::` for every parameter ID, never raw strings.
- Use named helpers (coarse, unison, slope, fx, lfoShape, mod) to build normalized values.
- Return the preset list as `const std::vector<FactoryPreset>&` from a static local.
- Reference `ModConnection` and macro assignment structs instead of ad hoc tuples.
## IF-THEN
- IF a preset omits a parameter THEN leave it at its default; do not write an explicit zero.
- IF a preset sets a physical value THEN encode it through the same `maps::` helpers the DSP uses.
## Examples
```cpp
// BAD: raw string ID and an opaque normalized number
pr.params = { { "oscAUnison", 0.4f } };
```
```cpp
// GOOD: ids:: and a named helper
pr.params = { p (ids::oscAUnison, unison (7)) };
```
This file overrides /AGENT.md where they conflict.
+250
View File
@@ -0,0 +1,250 @@
#include "FactoryPresets.h"
namespace serum
{
namespace
{
using P = std::pair<const char*, float>;
inline P p (const char* id, float v) { return { id, v }; }
// Semitones -> normalised coarse value (param range -24..+24).
inline float coarse (int semitones) { return (float) (semitones + 24) / 48.0f; }
// Unison count -> normalised value.
inline float unison (int n) { return (float) (n - 1) / 15.0f; }
// 6/12/24 dB slope -> normalised value.
inline float slope (int db) { return db == 6 ? 0.0f : (db == 12 ? 0.5f : 1.0f); }
// FxType -> normalised value.
inline float fx (FxType t) { return (float) (int) t / 9.0f; }
// LFO shape -> normalised value.
inline float lfoShape (LfoShape s) { return (float) (int) s / 6.0f; }
inline ModConnection mod (ModSource s, ModTarget t, float d, bool b = false)
{
return { s, t, d, b };
}
}
const std::vector<FactoryPreset>& getFactoryPresets()
{
static const std::vector<FactoryPreset> presets = []
{
std::vector<FactoryPreset> v;
FactoryPreset pr;
// 1. Init Saw
pr = {};
pr.name = "Init Saw";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 1.0f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.75f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 1.0f)
};
v.push_back (pr);
// 2. Supersaw Stack
pr = {};
pr.name = "Supersaw Stack";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 1.0f),
p (ids::oscAUnison, unison (7)), p (ids::oscADetune, 0.55f), p (ids::oscASpread, 0.75f), p (ids::oscALevel, 0.85f),
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 0.0f), p (ids::oscBWtPos, 1.0f),
p (ids::oscBUnison, unison (5)), p (ids::oscBDetune, 0.45f), p (ids::oscBSpread, 0.6f), p (ids::oscBLevel, 0.55f),
p (ids::oscBCoarse, coarse (12)),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.68f), p (ids::f1Res, 0.12f), p (ids::f1Slope, 1.0f),
p (ids::fx1Type, fx (FxType::Chorus)), p (ids::fx1Mix, 0.35f), p (ids::fx1P1, 0.4f), p (ids::fx1P2, 0.4f),
p (ids::fx2Type, fx (FxType::Reverb)), p (ids::fx2Mix, 0.25f), p (ids::fx2P1, 0.55f)
};
v.push_back (pr);
// 3. Wobble Bass
pr = {};
pr.name = "Wobble Bass";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 9.0f / 9.0f), p (ids::oscAWtPos, 0.6f),
p (ids::subOn, 1.0f), p (ids::subShape, 0.0f), p (ids::subOct, 0.5f), p (ids::subLevel, 0.6f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.35f), p (ids::f1Res, 0.35f), p (ids::f1Slope, 1.0f),
p (ids::lfo1Shape, lfoShape (LfoShape::Square)), p (ids::lfo1Rate, 0.55f),
p (ids::env1D, 0.35f), p (ids::env1S, 0.8f)
};
pr.mods = {
mod (ModSource::Lfo1, ModTarget::Filter1Cutoff, 0.5f, false),
mod (ModSource::Env2, ModTarget::Filter1Cutoff, 0.3f, false)
};
v.push_back (pr);
// 4. Pluck
pr = {};
pr.name = "Pluck";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 0.8f),
p (ids::env1A, 0.02f), p (ids::env1D, 0.18f), p (ids::env1S, 0.0f), p (ids::env1R, 0.2f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.7f), p (ids::f1Res, 0.15f), p (ids::f1Slope, 1.0f),
p (ids::fx1Type, fx (FxType::Delay)), p (ids::fx1Mix, 0.3f), p (ids::fx1P1, 0.45f), p (ids::fx1P2, 0.45f)
};
v.push_back (pr);
// 5. Electric Keys
pr = {};
pr.name = "Electric Keys";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 5.0f / 9.0f), p (ids::oscAWtPos, 0.5f),
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 5.0f / 9.0f), p (ids::oscBWtPos, 0.4f), p (ids::oscBLevel, 0.4f),
p (ids::env1D, 0.4f), p (ids::env1S, 0.7f), p (ids::env1R, 0.35f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.6f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 0.5f),
p (ids::lfo1Shape, lfoShape (LfoShape::Sine)), p (ids::lfo1Rate, 0.7f)
};
pr.mods = { mod (ModSource::Lfo1, ModTarget::Amp, 0.15f, true) };
v.push_back (pr);
// 6. Pad Dreams
pr = {};
pr.name = "Pad Dreams";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 8.0f / 9.0f), p (ids::oscAWtPos, 0.7f),
p (ids::oscAUnison, unison (5)), p (ids::oscADetune, 0.5f), p (ids::oscASpread, 0.8f),
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 4.0f / 9.0f), p (ids::oscBWtPos, 0.4f), p (ids::oscBLevel, 0.5f),
p (ids::env1A, 0.5f), p (ids::env1D, 0.5f), p (ids::env1S, 0.9f), p (ids::env1R, 0.7f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.5f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 0.5f),
p (ids::fx1Type, fx (FxType::Chorus)), p (ids::fx1Mix, 0.5f), p (ids::fx1P1, 0.3f), p (ids::fx1P2, 0.6f),
p (ids::fx2Type, fx (FxType::Reverb)), p (ids::fx2Mix, 0.5f), p (ids::fx2P1, 0.75f), p (ids::fx2P2, 0.5f)
};
v.push_back (pr);
// 7. Lead
pr = {};
pr.name = "Lead";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 6.0f / 9.0f), p (ids::oscAWtPos, 1.0f),
p (ids::oscAUnison, unison (3)), p (ids::oscADetune, 0.3f), p (ids::oscASpread, 0.5f),
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 1.0f / 9.0f), p (ids::oscBWtPos, 0.5f), p (ids::oscBLevel, 0.4f), p (ids::oscBCoarse, coarse (12)),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.6f), p (ids::f1Res, 0.2f), p (ids::f1Slope, 1.0f),
p (ids::env2D, 0.4f), p (ids::env2S, 0.4f),
p (ids::fx1Type, fx (FxType::Distortion)), p (ids::fx1Mix, 0.2f), p (ids::fx1P1, 0.25f),
p (ids::fx2Type, fx (FxType::Delay)), p (ids::fx2Mix, 0.3f), p (ids::fx2P1, 0.4f), p (ids::fx2P2, 0.4f)
};
v.push_back (pr);
// 8. Sub Bass
pr = {};
pr.name = "Sub Bass";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 9.0f / 9.0f), p (ids::oscAWtPos, 0.0f),
p (ids::subOn, 1.0f), p (ids::subShape, 0.0f), p (ids::subOct, 0.0f), p (ids::subLevel, 0.9f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.4f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 1.0f)
};
v.push_back (pr);
// 9. Vowel Morph
pr = {};
pr.name = "Vowel Morph";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 4.0f / 9.0f), p (ids::oscAWtPos, 0.3f),
p (ids::f1On, 1.0f), p (ids::f1Type, 5.0f / 6.0f), p (ids::f1Cutoff, 0.5f), p (ids::f1Res, 0.2f),
p (ids::lfo1Shape, lfoShape (LfoShape::Sine)), p (ids::lfo1Rate, 0.3f)
};
pr.mods = { mod (ModSource::Lfo1, ModTarget::OscAWtPos, 0.6f, false) };
v.push_back (pr);
// 10. Glass Bell
pr = {};
pr.name = "Glass Bell";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 8.0f / 9.0f), p (ids::oscAWtPos, 0.6f),
p (ids::env1A, 0.01f), p (ids::env1D, 0.5f), p (ids::env1S, 0.0f), p (ids::env1R, 0.8f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.8f), p (ids::f1Res, 0.1f), p (ids::f1Slope, 0.5f),
p (ids::fx1Type, fx (FxType::Reverb)), p (ids::fx1Mix, 0.6f), p (ids::fx1P1, 0.7f), p (ids::fx1P2, 0.35f)
};
v.push_back (pr);
// 11. Sync Riser
pr = {};
pr.name = "Sync Riser";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 2.0f / 9.0f), p (ids::oscAWtPos, 0.2f),
p (ids::oscAWarp, 3.0f / 7.0f), p (ids::oscAWarpAmt, 0.6f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.3f), p (ids::f1Res, 0.2f), p (ids::f1Slope, 1.0f),
p (ids::lfo1Shape, lfoShape (LfoShape::Saw)), p (ids::lfo1Rate, 0.2f)
};
pr.mods = { mod (ModSource::Lfo1, ModTarget::OscAWtPos, 0.8f, false) };
v.push_back (pr);
// 12. 8-Bit Blast
pr = {};
pr.name = "8-Bit Blast";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 7.0f / 9.0f), p (ids::oscAWtPos, 0.9f),
p (ids::oscAWarp, 4.0f / 7.0f), p (ids::oscAWarpAmt, 0.5f),
p (ids::env1D, 0.15f), p (ids::env1S, 0.0f), p (ids::env1R, 0.1f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.7f), p (ids::f1Res, 0.05f), p (ids::f1Slope, 1.0f)
};
v.push_back (pr);
// 13. Organ
pr = {};
pr.name = "Cathedral Organ";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 5.0f / 9.0f), p (ids::oscAWtPos, 0.8f),
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 5.0f / 9.0f), p (ids::oscBWtPos, 0.3f), p (ids::oscBLevel, 0.5f), p (ids::oscBCoarse, coarse (12)),
p (ids::env1A, 0.05f), p (ids::env1S, 1.0f), p (ids::env1R, 0.3f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.7f), p (ids::f1Res, 0.0f), p (ids::f1Slope, 0.5f),
p (ids::fx1Type, fx (FxType::Reverb)), p (ids::fx1Mix, 0.35f), p (ids::fx1P1, 0.6f), p (ids::fx1P2, 0.4f)
};
v.push_back (pr);
// 14. Choir
pr = {};
pr.name = "Choir";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 4.0f / 9.0f), p (ids::oscAWtPos, 0.5f),
p (ids::oscAUnison, unison (9)), p (ids::oscADetune, 0.4f), p (ids::oscASpread, 0.7f),
p (ids::env1A, 0.45f), p (ids::env1S, 0.85f), p (ids::env1R, 0.6f),
p (ids::f1On, 1.0f), p (ids::f1Type, 5.0f / 6.0f), p (ids::f1Cutoff, 0.45f), p (ids::f1Res, 0.15f),
p (ids::fx1Type, fx (FxType::Chorus)), p (ids::fx1Mix, 0.5f), p (ids::fx1P1, 0.3f), p (ids::fx1P2, 0.7f),
p (ids::fx2Type, fx (FxType::Reverb)), p (ids::fx2Mix, 0.55f), p (ids::fx2P1, 0.75f), p (ids::fx2P2, 0.5f)
};
v.push_back (pr);
// 15. Acid
pr = {};
pr.name = "Acid";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 1.0f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.3f), p (ids::f1Res, 0.6f), p (ids::f1Slope, 1.0f),
p (ids::env2A, 0.02f), p (ids::env2D, 0.25f), p (ids::env2S, 0.1f)
};
pr.mods = { mod (ModSource::Env2, ModTarget::Filter1Cutoff, 0.7f, false) };
v.push_back (pr);
// 16. RAVE Anthem Lead
pr = {};
pr.name = "RAVE Anthem Lead";
pr.params = {
p (ids::oscAOn, 1.0f), p (ids::oscAWave, 0.0f), p (ids::oscAWtPos, 1.0f),
p (ids::oscAUnison, unison (4)), p (ids::oscADetune, 0.4f), p (ids::oscASpread, 0.6f), p (ids::oscALevel, 0.85f),
p (ids::oscBOn, 1.0f), p (ids::oscBWave, 0.0f), p (ids::oscBWtPos, 1.0f),
p (ids::oscBUnison, unison (4)), p (ids::oscBDetune, 0.35f), p (ids::oscBSpread, 0.5f), p (ids::oscBLevel, 0.5f), p (ids::oscBCoarse, coarse (12)),
p (ids::env1D, 0.4f), p (ids::env1S, 0.8f), p (ids::env1R, 0.3f),
p (ids::f1On, 1.0f), p (ids::f1Type, 0.0f), p (ids::f1Cutoff, 0.6f), p (ids::f1Res, 0.15f), p (ids::f1Drive, 0.15f), p (ids::f1Slope, 1.0f),
p (ids::fx1Type, fx (FxType::Hyper)), p (ids::fx1Mix, 0.35f), p (ids::fx1P1, 0.5f),
p (ids::fx2Type, fx (FxType::Delay)), p (ids::fx2Mix, 0.28f), p (ids::fx2P1, 0.42f), p (ids::fx2P2, 0.4f),
p (ids::fx3Type, fx (FxType::Reverb)), p (ids::fx3Mix, 0.22f), p (ids::fx3P1, 0.55f)
};
pr.macroAssigns = {
{ 0, ModTarget::OscADetune, 0.5f },
{ 0, ModTarget::OscBDetune, 0.4f },
{ 0, ModTarget::Filter1Drive, 0.4f },
{ 1, ModTarget::OscASpread, 0.6f },
{ 1, ModTarget::OscBSpread, 0.5f },
{ 2, ModTarget::Filter1Drive, 0.5f },
{ 2, ModTarget::Filter2Drive, 0.4f },
{ 3, ModTarget::Fx3Mix, 0.4f }
};
v.push_back (pr);
return v;
}();
return presets;
}
} // namespace serum
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <JuceHeader.h>
#include "../Params.h"
#include "../ModulationMatrix.h"
namespace serum
{
// ===========================================================================
// A factory preset: a name plus a sparse map of parameter values, modulation
// connections and macro assignments. Unset parameters keep their defaults.
// ===========================================================================
struct FactoryPreset
{
juce::String name;
std::vector<std::pair<const char*, float>> params;
std::vector<ModConnection> mods;
struct MacroAssign
{
int macro = 0;
ModTarget target = ModTarget::None;
float depth = 0.0f;
};
std::vector<MacroAssign> macroAssigns;
};
// All 16 factory presets, in program order.
const std::vector<FactoryPreset>& getFactoryPresets();
} // namespace serum
+38
View File
@@ -0,0 +1,38 @@
#include "RAVEButton.h"
#include "SynthVoice.h"
#include "FXProcessor.h"
namespace serum
{
void RaveController::apply (RenderContext& context, FxSlotParams* slots, int numSlots) noexcept
{
// Boost the static rendering parameters.
context.oscA.unison = juce::jlimit (8, kMaxUnison, context.oscA.unison);
context.oscB.unison = juce::jlimit (8, kMaxUnison, context.oscB.unison);
context.oscA.spread = 1.0f;
context.oscB.spread = 1.0f;
context.oscA.detune = 1.0f;
context.oscB.detune = 0.7f;
// Apply drive boosts.
context.filters.f1Drive = 0.6f;
context.filters.f2Drive = 0.6f;
// Boost FX-specific params (Hyper intensity / Reverb mix).
if (slots == nullptr)
return;
for (int i = 0; i < juce::jlimit (0, kNumFxSlots, numSlots); ++i)
{
auto& slot = slots[i];
if (slot.type == (int) FxType::Hyper)
slot.p[0] = 1.0f; // OTT intensity 100%
else if (slot.type == (int) FxType::Reverb)
{
const float mix = std::isfinite (slot.mix) ? slot.mix : 0.0f;
slot.mix = juce::jlimit (0.0f, 1.0f, mix + 0.4f); // reverb send +6dB-ish
}
}
}
} // namespace serum
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <JuceHeader.h>
namespace serum
{
struct RenderContext;
struct FxSlotParams;
// ===========================================================================
// RAVE — non-destructive "make it huge" control. While enabled, rendering
// boosts unison, width, drive, OTT and reverb in the current block's snapshots;
// the underlying parameters remain unchanged when toggling on or off.
// ===========================================================================
class RaveController
{
public:
static void apply (RenderContext& context, FxSlotParams* slots, int numSlots) noexcept;
};
} // namespace serum
+94
View File
@@ -0,0 +1,94 @@
#include "Resources.h"
#include "Params.h"
namespace serum
{
juce::String getLogoSvg()
{
return R"svg(
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="40" viewBox="0 0 180 40">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#4fc3f7"/>
<stop offset="1" stop-color="#ff6e9c"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M20 6 L34 6 L22 34 L8 34 Z"/>
<text x="42" y="27" font-family="Verdana, sans-serif" font-size="22" font-weight="bold" fill="#e8eaf0">SerumAlt</text>
</svg>
)svg";
}
juce::String getBackgroundSvg()
{
return R"svg(
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720">
<rect width="1200" height="720" fill="#101117"/>
<radialGradient id="r" cx="0.5" cy="0" r="1.2">
<stop offset="0" stop-color="#1a1c24" stop-opacity="1"/>
<stop offset="1" stop-color="#101117" stop-opacity="0"/>
</radialGradient>
<rect width="1200" height="720" fill="url(#r)"/>
</svg>
)svg";
}
std::unique_ptr<juce::Drawable> createLogoDrawable()
{
auto xml = juce::parseXML (getLogoSvg());
return xml != nullptr ? juce::Drawable::createFromSVG (*xml) : nullptr;
}
std::unique_ptr<juce::Drawable> createBackgroundDrawable()
{
auto xml = juce::parseXML (getBackgroundSvg());
return xml != nullptr ? juce::Drawable::createFromSVG (*xml) : nullptr;
}
juce::String formatPercent (float v)
{
return juce::String (juce::roundToInt (v * 100.0f)) + " %";
}
juce::String formatHz (float v)
{
const float hz = maps::cutoffToHz (v);
if (hz >= 1000.0f)
return juce::String (hz / 1000.0f, 2) + " kHz";
return juce::String (juce::roundToInt (hz)) + " Hz";
}
juce::String formatSeconds (float v)
{
const float s = maps::toSeconds (v);
if (s < 1.0f)
return juce::String (juce::roundToInt (s * 1000.0f)) + " ms";
return juce::String (s, 2) + " s";
}
juce::String formatSemis (float v)
{
const int semi = (int) std::llround (v * 48.0f) - 24;
return juce::String (semi > 0 ? "+" : "") + juce::String (semi) + " st";
}
juce::String formatCents (float v)
{
const int ct = (int) std::llround (v * 200.0f) - 100;
return juce::String (ct > 0 ? "+" : "") + juce::String (ct) + " ct";
}
juce::String formatPan (float v)
{
const int p = (int) std::llround ((v - 0.5f) * 200.0f);
if (p == 0) return "C";
return (p < 0 ? "L" : "R") + juce::String (std::abs (p));
}
juce::String formatInt (float v, int maxValue)
{
return juce::String (juce::jlimit (0, maxValue, (int) std::llround (v * maxValue)));
}
} // namespace serum
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <JuceHeader.h>
namespace serum
{
// ===========================================================================
// Central dark theme + static SVG assets (embedded as strings so they need no
// binary-data build step; the same assets also ship under Source/Resources/).
// ===========================================================================
namespace theme
{
inline const juce::Colour bg { 0xff101117 };
inline const juce::Colour panel { 0xff1a1c24 };
inline const juce::Colour panelRaised { 0xff23262f };
inline const juce::Colour outline { 0xff2c2f39 };
inline const juce::Colour text { 0xffe8eaf0 };
inline const juce::Colour textDim { 0xff9095a2 };
inline const juce::Colour accent { 0xff4fc3f7 }; // cyan
inline const juce::Colour accent2 { 0xffff6e9c }; // pink
inline const juce::Colour amber { 0xffffb74d };
inline const juce::Colour green { 0xff7ce38b };
inline const juce::Colour raveGlow { 0xffff3d71 };
}
// SVG assets (logo + subtle background).
juce::String getLogoSvg();
juce::String getBackgroundSvg();
std::unique_ptr<juce::Drawable> createLogoDrawable();
std::unique_ptr<juce::Drawable> createBackgroundDrawable();
// Small value-formatting helpers used by the GUI controls.
juce::String formatPercent (float v); // 0..1 -> "42 %"
juce::String formatHz (float v); // 0..1 (cutoff) -> "1.2 kHz"
juce::String formatSeconds (float v); // 0..1 (env) -> "320 ms"
juce::String formatSemis (float v); // 0..1 -> "-12 st"
juce::String formatCents (float v); // 0..1 -> "+5 ct"
juce::String formatPan (float v); // 0..1 -> "L50"
juce::String formatInt (float v, int maxValue);
} // namespace serum
+34
View File
@@ -0,0 +1,34 @@
# resources module
## Owned files
- Related embedded assets and helpers: `../Resources.h`, `../Resources.cpp`
- logo.svg
- background.svg
## Rules
- Keep every theme colour in `namespace theme` in Resources.h.
- Embed SVG assets as raw string literals.
- Keep formatting helpers (formatPercent, formatHz, formatSeconds, formatSemis, formatCents, formatPan, formatInt) here.
- Use `maps::` from Params for unit conversions in formatters.
- Never hardcode a theme colour in a widget; reference `theme::`.
## IF-THEN
- IF a widget needs a colour THEN reference a `theme::` entry rather than a literal Colour.
- IF an SVG asset ships under Source/Resources THEN keep its embedded string in Resources.cpp consistent with the file.
## Examples
```cpp
// BAD: hardcoded colour in a widget
g.setColour (juce::Colour (0xff4fc3f7));
```
```cpp
// GOOD: theme is the single source
g.setColour (theme::accent);
```
This file overrides /AGENT.md where they conflict.
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720">
<rect width="1200" height="720" fill="#101117"/>
<radialGradient id="r" cx="0.5" cy="0" r="1.2">
<stop offset="0" stop-color="#1a1c24" stop-opacity="1"/>
<stop offset="1" stop-color="#101117" stop-opacity="0"/>
</radialGradient>
<rect width="1200" height="720" fill="url(#r)"/>
</svg>

After

Width:  |  Height:  |  Size: 391 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="40" viewBox="0 0 180 40">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#4fc3f7"/>
<stop offset="1" stop-color="#ff6e9c"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M20 6 L34 6 L22 34 L8 34 Z"/>
<text x="42" y="27" font-family="Verdana, sans-serif" font-size="22" font-weight="bold" fill="#e8eaf0">SerumAlt</text>
</svg>

After

Width:  |  Height:  |  Size: 459 B

+39
View File
@@ -0,0 +1,39 @@
#include "SubOscillator.h"
namespace serum
{
void SubOscillator::noteOn (double freqHz, int octaveShift)
{
// octaveShift: -2, -1 or 0 (from the subOct param).
const double mult = (octaveShift == -2) ? 0.25 : (octaveShift == -1) ? 0.5 : 1.0;
(void) freqHz;
(void) mult;
phase = 0.0;
}
void SubOscillator::processAdd (double freqHz, int shape, float level, float& out) noexcept
{
if (level <= 0.0f || freqHz <= 0.0)
return;
// The octave shift is baked into freqHz by the voice; here we just phase-accumulate.
const double inc = kTwoPi * freqHz / sr;
phase += inc;
phase -= std::floor (phase * (1.0 / kTwoPi)) * kTwoPi;
float s = 0.0f;
if (shape == (int) SubShape::Triangle)
{
const float p = (float) (phase * (1.0 / kTwoPi));
s = 1.0f - 4.0f * std::abs (p - 0.5f);
}
else
{
s = (float) std::sin (phase);
}
out += s * level;
}
} // namespace serum
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Simple sub oscillator: sine or triangle, one/two octaves down or unison.
// ===========================================================================
class SubOscillator
{
public:
void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; }
void reset() { phase = 0.0; }
void noteOn (double freqHz, int octaveShift);
// Accumulate into out (mono; the caller pans/levels it).
void processAdd (double freqHz, int shape, float level, float& out) noexcept;
private:
double sr = 44100.0;
double phase = 0.0;
static constexpr double kTwoPi = 6.28318530717958647692;
};
} // namespace serum
+233
View File
@@ -0,0 +1,233 @@
#include "SynthVoice.h"
namespace serum
{
namespace
{
inline float clampF (float v, float lo, float hi) noexcept
{
return v < lo ? lo : (v > hi ? hi : v);
}
}
void SynthVoice::prepare (double sampleRate, int maxBlockSize)
{
oscA.prepare (sampleRate);
oscB.prepare (sampleRate);
sub.prepare (sampleRate);
noise.prepare (sampleRate);
filters.prepare (sampleRate, maxBlockSize);
for (auto& e : env)
e.prepare (sampleRate);
scratch.setSize (2, maxBlockSize, false, false, true);
reset();
}
void SynthVoice::reset()
{
oscA.reset(); oscB.reset();
sub.reset(); noise.reset();
filters.reset();
for (auto& e : env)
e.reset();
note = -1;
velocity = 0.0f;
baseFreq = 0.0;
active = released = false;
noteId = 0;
oscillatorsNeedNoteOn = false;
noteRandom = 0.5f;
scratch.clear();
}
void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce::uint32 noteSeed)
{
note = noteNumber;
velocity = velocity01;
baseFreq = freqHz;
active = true;
released = false;
seed = noteSeed;
noteId = noteSeed;
oscillatorsNeedNoteOn = true; // initialise fresh oscillator phases on first render
juce::Random rng (noteSeed);
noteRandom = rng.nextFloat();
sub.noteOn (freqHz, -1);
noise.noteOn (noteSeed);
for (auto& e : env)
e.noteOn();
}
void SynthVoice::noteOff()
{
if (! active || released)
return;
released = true;
for (auto& e : env)
e.noteOff();
}
void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept
{
if (! active || numSamples <= 0)
return;
// 1. Refresh envelope parameters (cheap; also lets UI edits affect held notes).
for (int i = 0; i < kNumEnvelopes; ++i)
env[(size_t) i].setParams (maps::toSeconds (ctx.envAttack[i]),
maps::toSeconds (ctx.envDecay[i]),
ctx.envSustain[i],
maps::toSeconds (ctx.envRelease[i]),
ctx.envCurve[i]);
// 2. Block-start envelope values (control-rate modulation sources).
float envStart[kNumEnvelopes];
for (int i = 0; i < kNumEnvelopes; ++i)
envStart[i] = env[(size_t) i].getValue();
// 3. Per-voice modulation source values.
float src[kNumModSources];
src[(int) ModSource::Lfo1] = ctx.lfoValues[0];
src[(int) ModSource::Lfo2] = ctx.lfoValues[1];
src[(int) ModSource::Lfo3] = ctx.lfoValues[2];
src[(int) ModSource::Lfo4] = ctx.lfoValues[3];
src[(int) ModSource::Env1] = envStart[0];
src[(int) ModSource::Env2] = envStart[1];
src[(int) ModSource::Env3] = envStart[2];
src[(int) ModSource::Env4] = envStart[3];
src[(int) ModSource::Velocity] = velocity;
src[(int) ModSource::Note] = clampF ((float) note / 127.0f, 0.0f, 1.0f);
src[(int) ModSource::ModWheel] = ctx.modWheel;
src[(int) ModSource::PitchBend]= ctx.pitchBend;
src[(int) ModSource::Macro1] = ctx.macroValues[0];
src[(int) ModSource::Macro2] = ctx.macroValues[1];
src[(int) ModSource::Macro3] = ctx.macroValues[2];
src[(int) ModSource::Macro4] = ctx.macroValues[3];
src[(int) ModSource::Random] = noteRandom;
// 4. Accumulate modulation offsets (block rate).
std::array<float, kNumModTargets> mod { { } };
if (ctx.matrix != nullptr)
{
for (const auto& c : ctx.matrix->connections)
{
float v = src[(int) c.source];
if (c.bipolar && ! isBipolarSource (c.source))
v = v * 2.0f - 1.0f;
mod[(int) c.target] += v * c.depth;
}
}
if (ctx.macros != nullptr)
{
for (int m = 0; m < kNumMacros; ++m)
for (const auto& a : ctx.macros->assignments[(size_t) m])
mod[(int) a.target] += ctx.macroValues[m] * a.depth;
}
// 5. Modulated oscillator parameters.
OscParams a = ctx.oscA;
OscParams b = ctx.oscB;
a.level = clampF (a.level + mod[(int) ModTarget::OscALevel], 0.0f, 1.0f);
a.pan = clampF (a.pan + mod[(int) ModTarget::OscAPan], -1.0f, 1.0f);
a.wtPos = clampF (a.wtPos + mod[(int) ModTarget::OscAWtPos], 0.0f, 1.0f);
a.unison = (int) std::llround (clampF ((float) a.unison + mod[(int) ModTarget::OscAUnison] * 16.0f, 1.0f, 16.0f));
a.detune = clampF (a.detune + mod[(int) ModTarget::OscADetune], 0.0f, 1.0f);
a.spread = clampF (a.spread + mod[(int) ModTarget::OscASpread], 0.0f, 1.0f);
a.warpAmt = clampF (a.warpAmt+ mod[(int) ModTarget::OscAWarpAmt], 0.0f, 1.0f);
b.level = clampF (b.level + mod[(int) ModTarget::OscBLevel], 0.0f, 1.0f);
b.pan = clampF (b.pan + mod[(int) ModTarget::OscBPan], -1.0f, 1.0f);
b.wtPos = clampF (b.wtPos + mod[(int) ModTarget::OscBWtPos], 0.0f, 1.0f);
b.unison = (int) std::llround (clampF ((float) b.unison + mod[(int) ModTarget::OscBUnison] * 16.0f, 1.0f, 16.0f));
b.detune = clampF (b.detune + mod[(int) ModTarget::OscBDetune], 0.0f, 1.0f);
b.spread = clampF (b.spread + mod[(int) ModTarget::OscBSpread], 0.0f, 1.0f);
b.warpAmt = clampF (b.warpAmt+ mod[(int) ModTarget::OscBWarpAmt], 0.0f, 1.0f);
// 6. Frequency (pitch bend + mod matrix pitch + per-osc coarse/fine).
const double semis = ctx.pitchBend * ctx.pitchBendRange + mod[(int) ModTarget::Pitch] * 24.0;
const double bent = baseFreq * std::pow (2.0, semis / 12.0);
const double freqA = bent * std::pow (2.0, (double) a.coarse / 12.0 + (double) a.fine / 1200.0);
const double freqB = bent * std::pow (2.0, (double) b.coarse / 12.0 + (double) b.fine / 1200.0);
// Initialise phases only for a fresh note; update held-note unison parameters
// without resetting existing phases when modulated at control rate.
if (oscillatorsNeedNoteOn)
{
oscA.noteOn (freqA, a, seed);
oscB.noteOn (freqB, b, seed + 1);
oscillatorsNeedNoteOn = false;
}
else
{
oscA.setParams (a);
oscB.setParams (b);
}
// 7. Modulated filter parameters.
FilterBankParams fb = ctx.filters;
fb.f1Cutoff = clampF (fb.f1Cutoff + mod[(int) ModTarget::Filter1Cutoff], 0.0f, 1.0f);
fb.f1Res = clampF (fb.f1Res + mod[(int) ModTarget::Filter1Res], 0.0f, 1.0f);
fb.f1Drive = clampF (fb.f1Drive + mod[(int) ModTarget::Filter1Drive], 0.0f, 1.0f);
fb.f2Cutoff = clampF (fb.f2Cutoff + mod[(int) ModTarget::Filter2Cutoff], 0.0f, 1.0f);
fb.f2Res = clampF (fb.f2Res + mod[(int) ModTarget::Filter2Res], 0.0f, 1.0f);
fb.f2Drive = clampF (fb.f2Drive + mod[(int) ModTarget::Filter2Drive], 0.0f, 1.0f);
fb.mix = clampF (fb.mix + mod[(int) ModTarget::FilterMix], 0.0f, 1.0f);
fb.out = clampF (fb.out + mod[(int) ModTarget::FilterOut], 0.0f, 1.5f);
// 8. Amp modulation + velocity.
const float ampMod = 1.0f + mod[(int) ModTarget::Amp];
const float velGain = velocity * velocity + 0.001f;
// 9. Generate oscillators/sub/noise into scratch.
float* sL = scratch.getWritePointer (0);
float* sR = scratch.getWritePointer (1);
const Wavetable& wtA = ctx.wavetables->getTable (a.wave);
const Wavetable& wtB = ctx.wavetables->getTable (b.wave);
const double subMult = (ctx.subOct == -2) ? 0.25 : (ctx.subOct == -1) ? 0.5 : 1.0;
const float subLevel = clampF (ctx.subLevel + mod[(int) ModTarget::SubLevel], 0.0f, 1.0f);
const float noiseLevel = clampF (ctx.noiseLevel + mod[(int) ModTarget::NoiseLevel], 0.0f, 1.0f);
for (int i = 0; i < numSamples; ++i)
{
float l = 0.0f, r = 0.0f;
oscA.processAdd (wtA, a, freqA, l, r);
oscB.processAdd (wtB, b, freqB, l, r);
float mono = 0.0f;
if (ctx.subOn)
sub.processAdd (bent * subMult, ctx.subShape, subLevel, mono);
if (ctx.noiseOn)
noise.processAdd (ctx.noiseType, noiseLevel, mono);
l += mono;
r += mono;
sL[i] = l;
sR[i] = r;
}
// 10. Filter bank (control rate).
filters.process (sL, sR, numSamples, fb, (float) baseFreq);
// 11. Amp envelope (per sample) and advance the remaining envelopes.
for (int i = 0; i < numSamples; ++i)
{
const float amp = env[0].process();
env[1].process();
env[2].process();
env[3].process();
const float gain = amp * ampMod * velGain;
outL[i] += sL[i] * gain;
outR[i] += sR[i] * gain;
}
// 12. Release completes when the amp envelope has fully decayed.
if (released && ! env[0].isActive())
active = false;
}
} // namespace serum
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
#include "Wavetable.h"
#include "Oscillator.h"
#include "SubOscillator.h"
#include "NoiseOscillator.h"
#include "FilterBank.h"
#include "Envelope.h"
#include "ModulationMatrix.h"
#include "MacroControls.h"
namespace serum
{
// ===========================================================================
// Everything a voice needs to render one block. Global (non per-voice) values
// are provided by the engine; the voice adds its own per-voice modulation.
// ===========================================================================
struct RenderContext
{
double sampleRate = 44100.0;
const WavetableLibrary* wavetables = nullptr;
float lfoValues[kNumLfos] { 0.0f, 0.0f, 0.0f, 0.0f };
float macroValues[kNumMacros] { 0.0f, 0.0f, 0.0f, 0.0f };
float modWheel = 0.0f;
float pitchBend = 0.0f;
float pitchBendRange = 2.0f;
const ModulationMatrix* matrix = nullptr;
const MacroControls* macros = nullptr;
OscParams oscA;
OscParams oscB;
bool subOn = false;
int subShape = 0;
int subOct = -1;
float subLevel = 0.0f;
bool noiseOn = false;
int noiseType = 0;
float noiseLevel = 0.0f;
FilterBankParams filters;
float envAttack[4] { 0.01f, 0.01f, 0.01f, 0.01f };
float envDecay[4] { 0.2f, 0.2f, 0.2f, 0.2f };
float envSustain[4]{ 0.7f, 0.7f, 0.7f, 0.7f };
float envRelease[4]{ 0.3f, 0.3f, 0.3f, 0.3f };
float envCurve[4] { 0.5f, 0.5f, 0.5f, 0.5f };
};
// ===========================================================================
// One synthesis voice: two oscillators + sub + noise -> filter bank -> amp
// envelope. Four envelopes provide per-voice modulation sources.
// ===========================================================================
class SynthVoice
{
public:
void prepare (double sampleRate, int maxBlockSize);
void reset();
void noteOn (int noteNumber, float velocity, double freqHz, juce::uint32 seed);
void noteOff();
void render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept;
bool isActive() const noexcept { return active; }
bool isReleased() const noexcept { return released; }
int getNote() const noexcept { return note; }
juce::uint64 getNoteId() const noexcept { return noteId; }
private:
Oscillator oscA, oscB;
SubOscillator sub;
NoiseOscillator noise;
FilterBank filters;
std::array<Envelope, 4> env;
int note = -1;
float velocity = 0.0f;
double baseFreq = 0.0;
bool active = false;
bool released = false;
juce::uint64 noteId = 0;
bool oscillatorsNeedNoteOn = false;
juce::uint32 seed = 0;
float noteRandom = 0.5f;
juce::AudioBuffer<float> scratch; // 2 channels
};
} // namespace serum
+40
View File
@@ -0,0 +1,40 @@
# tests module
## Owned files
- TestMain.cpp
## Rules
- Keep this a headless console harness that drives the real SerumAltAudioProcessor.
- Check rendered blocks for NaN and inf. Check non-silence over the full preset render, not each block, because note release can legitimately produce silent blocks.
- Print PASS or FAIL per preset to `std::cout`.
- Return a non-zero exit code when any check fails.
- Never show a JUCE alert window.
- Do not add GUI interaction beyond `juce::ScopedJuceInitialiser_GUI`.
## IF-THEN
- IF a check fails THEN increment the failure count and print the preset name.
- Build and run natively from the repository root with `./build_harness.sh --run`. Building the `SerumAltTest` target alone does not run checks.
- The harness-only configuration sets `SERUMALT_BUILD_PLUGIN=OFF` and `SERUMALT_BUILD_TESTS=ON`. Production scripts disable the harness.
- The harness links the real processor and editor sources but creates no plugin bundles. Keep that build separation when adding checks.
## Examples
```cpp
// BAD: a failing check does not fail the run
bool ok = ! hasNaN (buffer) && peak (buffer) > 0.001f; // result ignored
return 0;
```
```cpp
// GOOD: accumulate failures and exit non-zero
int failures = 0;
const bool ok = ! hasNaN (buffer) && peak (buffer) > 0.001f;
if (! ok) ++failures;
...
return failures == 0 ? 0 : 1;
```
This file overrides /AGENT.md where they conflict.
+135
View File
@@ -0,0 +1,135 @@
#include <JuceHeader.h>
#include <cmath>
#include <iostream>
#include "../PluginProcessor.h"
// ===========================================================================
// Headless QA harness: instantiates the processor, plays notes through every
// factory preset and reports NaN/inf and silence failures.
// ===========================================================================
namespace
{
bool hasNaN (const juce::AudioBuffer<float>& b)
{
for (int ch = 0; ch < b.getNumChannels(); ++ch)
{
const float* d = b.getReadPointer (ch);
for (int i = 0; i < b.getNumSamples(); ++i)
if (! std::isfinite (d[i]))
return true;
}
return false;
}
float peak (const juce::AudioBuffer<float>& b)
{
float p = 0.0f;
for (int ch = 0; ch < b.getNumChannels(); ++ch)
{
const float* d = b.getReadPointer (ch);
for (int i = 0; i < b.getNumSamples(); ++i)
p = std::max (p, std::abs (d[i]));
}
return p;
}
}
int main()
{
juce::ScopedJuceInitialiser_GUI initialiser;
serum::SerumAltAudioProcessor processor;
processor.prepareToPlay (44100.0, 512);
const int numPresets = processor.getNumPrograms();
int failures = 0;
const int notes[3] = { 48, 60, 67 };
// Default (init) preset.
{
juce::MidiBuffer midi;
for (int n : notes)
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
juce::AudioBuffer<float> buffer (2, 512);
float maxPeak = 0.0f;
bool nan = false;
for (int block = 0; block < 240; ++block)
{
buffer.clear();
processor.processBlock (buffer, midi);
midi.clear();
if (block == 180)
for (int n : notes)
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
nan = nan || hasNaN (buffer);
maxPeak = std::max (maxPeak, peak (buffer));
}
const bool ok = ! nan && maxPeak > 0.001f;
if (! ok) ++failures;
std::cout << (ok ? "PASS" : "FAIL") << " [Init] peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
}
// Every factory preset.
for (int preset = 0; preset < numPresets; ++preset)
{
processor.setCurrentProgram (preset);
juce::MidiBuffer midi;
for (int n : notes)
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
juce::AudioBuffer<float> buffer (2, 512);
float maxPeak = 0.0f;
bool nan = false;
for (int block = 0; block < 240; ++block)
{
buffer.clear();
processor.processBlock (buffer, midi);
midi.clear();
if (block == 180)
for (int n : notes)
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
nan = nan || hasNaN (buffer);
maxPeak = std::max (maxPeak, peak (buffer));
}
const bool ok = ! nan && maxPeak > 0.001f;
if (! ok) ++failures;
std::cout << (ok ? "PASS" : "FAIL") << " [" << processor.getProgramName (preset) << "]"
<< " peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
}
// RAVE toggle + parameter churn while playing (no crash / NaN).
{
processor.setCurrentProgram (0);
juce::MidiBuffer midi;
for (int n : notes)
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
processor.setRaveEnabled (true);
juce::AudioBuffer<float> buffer (2, 512);
bool nan = false;
for (int block = 0; block < 120; ++block)
{
buffer.clear();
processor.processBlock (buffer, midi);
midi.clear();
if (block == 60)
processor.setRaveEnabled (false);
nan = nan || hasNaN (buffer);
}
const bool ok = ! nan;
if (! ok) ++failures;
std::cout << (ok ? "PASS" : "FAIL") << " [RAVE toggle / param churn]" << (nan ? " <NaN!>" : "") << "\n";
}
std::cout << "\n" << (failures == 0 ? "ALL QA CHECKS PASSED" : "QA FAILURES DETECTED")
<< " (" << failures << " failures)\n";
return failures == 0 ? 0 : 1;
}
+348
View File
@@ -0,0 +1,348 @@
#include "Wavetable.h"
namespace serum
{
// ---------------------------------------------------------------------------
// Wavetable
// ---------------------------------------------------------------------------
void Wavetable::clear()
{
frames.clear();
name = {};
}
void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int numHarmonics)
{
name = n;
frames.assign (kFrames, std::vector<float> (kTableSize, 0.0f));
const float invN = 1.0f / (float) kTableSize;
constexpr double twoPi = 6.28318530717958647692;
for (int f = 0; f < kFrames; ++f)
{
auto& frame = frames[(size_t) f];
// Precompute per-harmonic rotation step (cos/sin of the angular increment).
std::vector<double> cosStep ((size_t) numHarmonics + 1, 0.0);
std::vector<double> sinStep ((size_t) numHarmonics + 1, 0.0);
std::vector<double> mag ((size_t) numHarmonics + 1, 0.0);
std::vector<double> phase ((size_t) numHarmonics + 1, 0.0);
double maxAmp = 1e-9;
for (int h = 1; h <= numHarmonics; ++h)
{
const float a = ampFn (f, h);
const double m = std::abs ((double) a);
mag[(size_t) h] = m;
phase[(size_t) h] = (a < 0.0f) ? twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base
const double ang = twoPi * (double) h * (double) invN;
cosStep[(size_t) h] = std::cos (ang);
sinStep[(size_t) h] = std::sin (ang);
maxAmp = std::max (maxAmp, m);
}
double peak = 1e-9;
for (int h = 1; h <= numHarmonics; ++h)
{
if (mag[(size_t) h] < 1e-9)
continue;
// Start the rotating phasor at the harmonic's phase offset.
double s = std::sin (phase[(size_t) h]);
double c = std::cos (phase[(size_t) h]);
const double cs = cosStep[(size_t) h];
const double ss = sinStep[(size_t) h];
const double m = mag[(size_t) h];
for (int i = 0; i < kTableSize; ++i)
{
frame[(size_t) i] += (float) (m * s);
const double s2 = s * cs + c * ss;
c = c * cs - s * ss;
s = s2;
}
}
// Normalise each frame to unity peak so morphing keeps a stable level.
for (int i = 0; i < kTableSize; ++i)
peak = std::max (peak, (double) std::abs (frame[(size_t) i]));
if (peak > 1e-6)
{
const float g = (float) (1.0 / peak);
for (int i = 0; i < kTableSize; ++i)
frame[(size_t) i] *= g;
}
}
}
float Wavetable::read (float framePos, float phase) const noexcept
{
if (frames.empty())
return 0.0f;
framePos = juce::jlimit (0.0f, (float) (kFrames - 1), framePos);
int f0 = (int) framePos;
float frac = framePos - (float) f0;
int f1 = juce::jmin (f0 + 1, kFrames - 1);
float p = phase * (float) kTableSize;
int i0 = (int) p;
if (i0 < 0) i0 = 0;
float t = p - (float) i0;
int i1 = (i0 + 1) & (kTableSize - 1);
i0 &= (kTableSize - 1);
const auto& a = frames[(size_t) f0];
const auto& b = frames[(size_t) f1];
const float s0 = lerp (a[(size_t) i0], a[(size_t) i1], t);
const float s1 = lerp (b[(size_t) i0], b[(size_t) i1], t);
return lerp (s0, s1, frac);
}
void Wavetable::copyFrame (int frameIndex, float* dest, int numSamples) const
{
if (frames.empty() || dest == nullptr)
return;
frameIndex = juce::jlimit (0, kFrames - 1, frameIndex);
const auto& frame = frames[(size_t) frameIndex];
const int n = juce::jmin (numSamples, kTableSize);
for (int i = 0; i < n; ++i)
dest[i] = frame[(size_t) i];
}
// ---------------------------------------------------------------------------
// Library
// ---------------------------------------------------------------------------
void WavetableLibrary::prebuild()
{
for (int i = 0; i < kNumWavetables; ++i)
getTable (i);
}
const Wavetable& WavetableLibrary::getTable (int index) const
{
index = juce::jlimit (0, kNumWavetables - 1, index);
if (!built[(size_t) index])
buildTable (index);
return tables[(size_t) index];
}
void WavetableLibrary::buildTable (int index) const
{
switch (index)
{
case 0: tables[(size_t) index] = makeBasic(); break;
case 1: tables[(size_t) index] = makeSawPwm(); break;
case 2: tables[(size_t) index] = makeSquareSync(); break;
case 3: tables[(size_t) index] = makeTriangleFold(); break;
case 4: tables[(size_t) index] = makeVowel(); break;
case 5: tables[(size_t) index] = makeOrgan(); break;
case 6: tables[(size_t) index] = makeWarmSaw(); break;
case 7: tables[(size_t) index] = makeDigital(); break;
case 8: tables[(size_t) index] = makeGlass(); break;
case 9: tables[(size_t) index] = makeBass(); break;
default: tables[(size_t) index] = makeBasic(); break;
}
built[(size_t) index] = true;
}
namespace
{
inline float normT (int frame) { return (float) frame / 255.0f; }
constexpr float kPi = 3.14159265358979323846f;
}
// sine -> saw morph
Wavetable WavetableLibrary::makeBasic()
{
Wavetable wt;
wt.buildHarmonic ("Basic Shapes", [] (int f, int h)
{
const float t = normT (f);
const float sine = (h == 1) ? 1.0f : 0.0f;
const float saw = 1.0f / (float) h;
return sine + (saw - sine) * t;
}, 48);
return wt;
}
// narrow pulse -> wide pulse
Wavetable WavetableLibrary::makeSawPwm()
{
Wavetable wt;
wt.buildHarmonic ("Saw PWM", [] (int f, int h)
{
const float t = normT (f);
const float duty = 0.08f + 0.42f * t; // 8% -> 50%
const float a = std::sin (kPi * (float) h * duty);
return (2.0f / ((float) h * kPi)) * a;
}, 48);
return wt;
}
// hard-sync style comb sweep
Wavetable WavetableLibrary::makeSquareSync()
{
Wavetable wt;
wt.buildHarmonic ("Square Sync", [] (int f, int h)
{
const float t = normT (f);
const float ratio = 1.0f + 3.0f * t;
const float comb = 0.5f + 0.5f * std::cos (2.0f * kPi * (float) h * ratio);
return (1.0f / std::pow ((float) h, 0.8f)) * comb;
}, 48);
return wt;
}
// triangle -> folded/saw-ish
Wavetable WavetableLibrary::makeTriangleFold()
{
Wavetable wt;
wt.buildHarmonic ("Triangle Fold", [] (int f, int h)
{
const float t = normT (f);
// Triangle: odd harmonics with alternating sign, 1/h^2.
float tri = 0.0f;
if ((h & 1) == 1)
{
const int k = (h - 1) / 2;
const float sign = ((k & 1) == 0) ? 1.0f : -1.0f;
tri = sign * 8.0f / (kPi * kPi * (float) (h * h));
}
const float saw = 1.0f / (float) h;
return tri + (saw - tri) * t;
}, 48);
return wt;
}
// formant morph a -> e -> i
Wavetable WavetableLibrary::makeVowel()
{
Wavetable wt;
wt.buildHarmonic ("Vowel", [] (int f, int h)
{
const float t = normT (f);
// Three formant sets (Hz) with f0 = 55 Hz.
const float f0 = 55.0f;
const float aa[3] = { 730.0f, 1090.0f, 2440.0f };
const float ee[3] = { 530.0f, 1840.0f, 2480.0f };
const float ii[3] = { 270.0f, 2290.0f, 3010.0f };
float from[3], to[3];
if (t < 0.5f)
{
const float u = t * 2.0f;
for (int k = 0; k < 3; ++k) { from[k] = aa[k]; to[k] = ee[k]; }
(void) u;
}
else
{
const float u = (t - 0.5f) * 2.0f;
for (int k = 0; k < 3; ++k) { from[k] = ee[k]; to[k] = ii[k]; }
(void) u;
}
float tt = (t < 0.5f) ? t * 2.0f : (t - 0.5f) * 2.0f;
float sum = 0.0f;
const float sigma = 1.8f; // formant bandwidth in harmonic units
for (int k = 0; k < 3; ++k)
{
const float fc = from[k] + (to[k] - from[k]) * tt;
const float center = fc / f0;
const float d = ((float) h - center) / sigma;
sum += std::exp (-0.5f * d * d) * (k == 0 ? 1.0f : 0.6f);
}
return sum;
}, 48);
return wt;
}
// drawbar organ morph
Wavetable WavetableLibrary::makeOrgan()
{
Wavetable wt;
wt.buildHarmonic ("Organ", [] (int f, int h)
{
const float t = normT (f);
// two drawbar registrations (16', 8', 5 1/3', 4', 2 2/3', 2', ...)
const float regA[8] = { 0.0f, 1.0f, 0.0f, 0.35f, 0.0f, 0.2f, 0.0f, 0.1f };
const float regB[8] = { 0.5f, 1.0f, 0.4f, 0.6f, 0.25f, 0.5f, 0.2f, 0.35f };
if (h > 8) return 0.0f;
const float a = regA[h - 1];
const float b = regB[h - 1];
return a + (b - a) * t;
}, 8);
return wt;
}
// warm lowpassed saw -> brighter
Wavetable WavetableLibrary::makeWarmSaw()
{
Wavetable wt;
wt.buildHarmonic ("Warm Saw", [] (int f, int h)
{
const float t = normT (f);
const float cutoff = 10.0f + 34.0f * t; // harmonic rolloff centre
const float rolloff = std::exp (-((float) h / cutoff) * ((float) h / cutoff));
const float body = 1.0f / std::pow ((float) h, 0.9f);
return body * (0.4f + 0.6f * rolloff);
}, 48);
return wt;
}
// additive, brighter and slightly combed
Wavetable WavetableLibrary::makeDigital()
{
Wavetable wt;
wt.buildHarmonic ("Digital", [] (int f, int h)
{
const float t = normT (f);
const float comb = 0.5f + 0.5f * std::cos ((float) h * 0.9f * (1.0f + t));
return (1.0f / std::pow ((float) h, 0.6f)) * (0.5f + 0.5f * comb);
}, 48);
return wt;
}
// inharmonic bell-like partial clusters
Wavetable WavetableLibrary::makeGlass()
{
Wavetable wt;
wt.buildHarmonic ("Glass", [] (int f, int h)
{
const float t = normT (f);
const float c1 = 1.0f + t * 2.5f;
const float c2 = 3.6f + t * 3.4f;
const float c3 = 6.2f + t * 4.0f;
const float sigma = 0.9f;
float sum = 0.0f;
const float centers[3] = { c1, c2, c3 };
const float gains[3] = { 1.0f, 0.7f, 0.4f };
for (int k = 0; k < 3; ++k)
{
const float d = ((float) h - centers[k]) / sigma;
sum += gains[k] * std::exp (-0.5f * d * d);
}
return sum;
}, 48);
return wt;
}
// sub-heavy -> fuller bass
Wavetable WavetableLibrary::makeBass()
{
Wavetable wt;
wt.buildHarmonic ("Bass", [] (int f, int h)
{
const float t = normT (f);
const float sub = (h == 1) ? 1.0f : ((h == 2) ? 0.4f : ((h == 3) ? 0.12f : 0.0f));
const float full = 1.0f / std::pow ((float) h, 1.1f);
return sub + (full - sub) * t;
}, 48);
return wt;
}
} // namespace serum
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// A single wavetable: 256 frames of 2048 samples each. Frames morph smoothly
// from a simple base shape to a complex one. Lookups interpolate bilinearly
// (across frames and within a frame). Tables are RAM-resident and generated
// lazily by the WavetableLibrary.
// ===========================================================================
class Wavetable
{
public:
static constexpr int kFrames = 256;
static constexpr int kTableSize = 2048;
Wavetable() = default;
juce::String name;
std::vector<std::vector<float>> frames; // [frame][sample]
bool isValid() const noexcept { return (int) frames.size() == kFrames; }
void clear();
// Build a table from a per-frame harmonic amplitude function.
// ampFn(frameIndex, harmonicNumber) -> amplitude (harmonic 1 = fundamental)
using HarmonicAmpFn = std::function<float (int, int)>;
void buildHarmonic (const juce::String& name, HarmonicAmpFn ampFn, int numHarmonics = 64);
// Read the table at a fractional frame position and a phase in [0,1).
float read (float framePos, float phase) const noexcept;
// Read with phase wrapped and clamped frame position.
float readSafe (float framePos, float phase) const noexcept
{
phase -= std::floor (phase);
return read (juce::jlimit (0.0f, (float) kFrames - 1.001f, framePos), phase);
}
// Copy raw samples of a frame into a destination buffer (for the GUI).
void copyFrame (int frameIndex, float* dest, int numSamples) const;
private:
static float lerp (float a, float b, float t) noexcept { return a + (b - a) * t; }
};
// ===========================================================================
// Lazily-built library of factory wavetables. Indices map to kWavetableNames.
// ===========================================================================
class WavetableLibrary
{
public:
const Wavetable& getTable (int index) const;
int size() const noexcept { return kNumWavetables; }
// Force generation of all tables (used at prepare time).
void prebuild();
private:
mutable std::array<Wavetable, kNumWavetables> tables;
mutable std::array<bool, kNumWavetables> built { { false } };
void buildTable (int index) const;
static Wavetable makeBasic();
static Wavetable makeSawPwm();
static Wavetable makeSquareSync();
static Wavetable makeTriangleFold();
static Wavetable makeVowel();
static Wavetable makeOrgan();
static Wavetable makeWarmSaw();
static Wavetable makeDigital();
static Wavetable makeGlass();
static Wavetable makeBass();
};
} // namespace serum
+48
View File
@@ -0,0 +1,48 @@
# engine module
## Related source files
This directory contains guidance; the implementation remains in `../Engine.h`
and `../Engine.cpp`. The preparation rules below are requirements for changes;
current buffer resizing and lazy wavetable issues are recorded in the root AGENT.md.
## Rules
- Own the voice pool, LFOs, wavetable library, FX rack, matrix and macros by value.
- Read every APVTS value through the null-guarding `v()` helper.
- Build the `RenderContext` once per block and pass it to voices as `const&`.
- Size `mixBuffer` once in `prepare()` and only `clear()` it per block.
- Prebuild wavetables in `prepare()` before the first render.
- Clamp the final output with the soft limiter.
- Never call `setSize` on any buffer inside `processBlock`.
## IF-THEN
- IF a per-block buffer is needed THEN allocate it in `prepare()` and clear it per block.
- IF a module needs the matrix, macros or wavetables THEN go through Engine accessors rather than reaching into another module.
## Examples
```cpp
// BAD: per-callback buffer reconfiguration on the audio thread
void processBlock (...)
{
mixBuffer.setSize (2, n, false, false, true);
...
}
```
```cpp
// GOOD: size once, clear per block
void prepare (double sr, int blockSize)
{
mixBuffer.setSize (2, blockSize, false, false, true);
}
void processBlock (...)
{
mixBuffer.clear();
...
}
```
This file overrides /AGENT.md where they conflict.
+34
View File
@@ -0,0 +1,34 @@
# modulation module
## Related source files
This directory contains guidance; implementations remain in the parent `Source/`
directory:
- `../ModulationMatrix.h`, `../ModulationMatrix.cpp`
- `../MacroControls.h`, `../MacroControls.cpp`
- `../RAVEButton.h`, `../RAVEButton.cpp`, which define `RaveController`
## Rules
- Use `bool` return codes for bounded inserts (`addConnection`, `addAssignment`).
- Require a cap on connection and assignment counts (`kMaxConnections`, `kMaxAssignments`).
- Serialise and restore state through `juce::ValueTree` so presets survive save and load.
- Never accept a `ModTarget::None` connection; reject it and return false.
- Use `ids::` for every APVTS parameter reference in RaveController.
- Keep the existing RAVEButton filenames unless a rename is explicitly in scope.
## GUI/audio state
The current public connection and assignment vectors are shared mutable state.
GUI writes can race with audio-thread iteration. A mutex taken only by the writer
does not protect an unlocked reader, and taking a blocking mutex in the audio
callback conflicts with the real-time requirements.
When fixing this path, publish a stable snapshot or use a bounded thread-safe
handoff. Keep allocation and snapshot reclamation off the audio thread. Validate
reader and writer lifetimes together, including preset and state restore.
If `addConnection` or `addAssignment` returns false, handle the failed insert.
This file refines the repository-root AGENT.md for the related sources.
+39
View File
@@ -0,0 +1,39 @@
# params module
## Related source files
This directory contains guidance; the implementation remains in `../Params.h`
and `../Params.cpp`.
## Rules
- Use the `ids::` namespace for every APVTS parameter ID string.
- Require one `ids::` entry per audio parameter, and one entry only.
- Use `enum class` for every enum. Give each a trailing `Count` enumerator.
- Use `inline constexpr` for parameter ID strings and `k`-prefixed integer constants.
- Keep all 0..1 to physical unit conversions in the `maps::` namespace.
- Keep the wavetable name table (`kWavetableNames`) in sync with `kNumWavetables`.
- Return an empty `juce::String` from enum-to-string switches for unknown values.
- Never re-declare a parameter ID string literal outside Params.h.
- Never add a physical unit mapping anywhere but `maps::`.
## IF-THEN
- IF you add an audio parameter THEN add its ID to `ids::` and, when it has physical units, add its mapping to `maps::` before any engine or GUI code reads it.
- IF a switch converts an enum to a string THEN include a default case that returns an empty string.
## Examples
```cpp
// BAD: re-declares the ID and drifts from Params.h
if (auto* p = apvts.getParameter ("f1Cutoff"))
return p->getValue();
```
```cpp
// GOOD: one source of truth
if (auto* p = apvts.getParameter (ids::f1Cutoff))
return p->getValue();
```
This file overrides /AGENT.md where they conflict.
+41
View File
@@ -0,0 +1,41 @@
# plugin module
## Related source files
This directory contains guidance; the implementation remains in
`../PluginProcessor.h` and `../PluginProcessor.cpp`.
## Rules
- Keep this the JUCE `AudioProcessor` boundary: APVTS, engine, RAVE, presets and state.
- Declare the full parameter layout in `createParameterLayout()`.
- Use `juce::ScopedNoDenormals` in `processBlock`.
- Guard `getRawParameterValue` and `getParameter` results before dereferencing them.
- Return silently on null or invalid state XML and invalid ValueTrees.
- Use raw `new` only for JUCE-owned objects (`createEditor`, `createPluginFilter`).
- Prefer const accessors over the public `parameters`, `engine` and `rave` fields for new code.
## IF-THEN
- IF state XML is null or invalid THEN return without touching the APVTS.
- IF a parameter is missing from a preset load THEN skip it with an `if (auto* param = ...)` guard.
## Examples
```cpp
// BAD: dereferences a possibly null XML result
auto xml = getXmlFromBinary (data, size);
auto state = juce::ValueTree::fromXml (*xml);
```
```cpp
// GOOD: null guard, silent return
std::unique_ptr<juce::XmlElement> xml (getXmlFromBinary (data, size));
if (xml == nullptr)
return;
juce::ValueTree state = juce::ValueTree::fromXml (*xml);
if (! state.isValid())
return;
```
This file overrides /AGENT.md where they conflict.

Some files were not shown because too many files have changed in this diff Show More