Compare commits

...
9 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
41 changed files with 1392 additions and 615 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.
+3
View File
@@ -16,3 +16,6 @@ compile_commands.json
*.user *.user
.DS_Store .DS_Store
.cache/ .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"
]
}
}
}
+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 -->
+238 -192
View File
@@ -1,22 +1,11 @@
#include "Engine.h" #include "Engine.h"
#include "RAVEButton.h"
namespace serum namespace serum
{ {
namespace namespace
{ {
inline float v (juce::AudioProcessorValueTreeState& apvts, const char* id)
{
if (auto* p = apvts.getRawParameterValue (id))
return p->load();
return 0.0f;
}
inline int vic (juce::AudioProcessorValueTreeState& apvts, const char* id, int maxValue)
{
return juce::jlimit (0, maxValue, (int) std::llround (v (apvts, id) * maxValue));
}
inline float limit (float x) noexcept inline float limit (float x) noexcept
{ {
const float ax = std::fabs (x); const float ax = std::fabs (x);
@@ -26,47 +15,36 @@ namespace
const float clipped = 0.8f + std::tanh (over) * 0.2f; const float clipped = 0.8f + std::tanh (over) * 0.2f;
return std::copysign (clipped, x); return std::copysign (clipped, x);
} }
const char* kEnvAttack[kNumEnvelopes] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
const char* kEnvDecay[kNumEnvelopes] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
const char* kEnvSustain[kNumEnvelopes]= { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
const char* kEnvRelease[kNumEnvelopes]= { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
const char* kEnvCurve[kNumEnvelopes] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
const char* kLfoRate[kNumLfos] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate };
const char* kLfoSync[kNumLfos] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync };
const char* kLfoBeat[kNumLfos] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat };
const char* kLfoShape[kNumLfos] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
const char* kLfoPhase[kNumLfos] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase };
const char* kLfoFade[kNumLfos] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade };
const char* kLfoDelay[kNumLfos] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay };
const char* kFxType[kNumFxSlots] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
const char* kFxMix[kNumFxSlots] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
const char* kFxP1[kNumFxSlots] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1,
ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 };
const char* kFxP2[kNumFxSlots] = { ids::fx1P2, ids::fx2P2, ids::fx3P2, ids::fx4P2,
ids::fx5P2, ids::fx6P2, ids::fx7P2, ids::fx8P2 };
const char* kFxP3[kNumFxSlots] = { ids::fx1P3, ids::fx2P3, ids::fx3P3, ids::fx4P3,
ids::fx5P3, ids::fx6P3, ids::fx7P3, ids::fx8P3 };
const char* kFxP4[kNumFxSlots] = { ids::fx1P4, ids::fx2P4, ids::fx3P4, ids::fx4P4,
ids::fx5P4, ids::fx6P4, ids::fx7P4, ids::fx8P4 };
} }
void Engine::prepare (double sampleRate, int maxBlockSize) 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; sr = sampleRate;
blockSize = maxBlockSize; 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) for (auto& voice : voices)
voice.prepare (sampleRate, maxBlockSize); voice.prepare (sampleRate, blockSize);
for (auto& lfo : lfos) for (auto& lfo : lfos)
lfo.prepare (sampleRate); lfo.prepare (sampleRate);
fx.prepare (sampleRate, maxBlockSize); fx.prepare (sampleRate, blockSize);
mixBuffer.setSize (2, maxBlockSize, false, false, true); mixBuffer.setSize (2, blockSize, false, false, true);
reset(); reset();
captureControls();
} }
void Engine::reset() void Engine::reset()
@@ -78,29 +56,57 @@ void Engine::reset()
fx.reset(); fx.reset();
pitchBend = 0.0f; pitchBend = 0.0f;
modWheel = 0.0f; modWheel = 0.0f;
activeVoiceCount.store (0, std::memory_order_relaxed);
mixBuffer.clear(); mixBuffer.clear();
} }
void Engine::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) void Engine::setLfoShapeData (int index, const std::vector<float>& data, int steps)
{ {
const juce::ScopedLock lock (controlLock);
index = juce::jlimit (0, kNumLfos - 1, index); index = juce::jlimit (0, kNumLfos - 1, index);
lfos[(size_t) index].setShapeData (data, steps); controlLfos[(size_t) index].setShapeData (data, steps);
} }
int Engine::getActiveVoiceCount() const float Engine::v (const char* id) const
{ {
int count = 0; const auto it = parameterValues.find (id);
for (const auto& v : voices) return it != parameterValues.end() ? it->second.value : 0.0f;
if (v.isActive()) }
++count;
return count; int Engine::vic (const char* id, int maxValue) const
{
return juce::jlimit (0, maxValue, (int) std::llround (v (id) * maxValue));
} }
SynthVoice* Engine::findFreeVoice() SynthVoice* Engine::findFreeVoice()
{ {
for (auto& v : voices) for (auto& voice : voices)
if (! v.isActive()) if (! voice.isActive())
return &v; return &voice;
return nullptr; return nullptr;
} }
@@ -110,20 +116,20 @@ SynthVoice* Engine::stealVoice()
SynthVoice* best = nullptr; SynthVoice* best = nullptr;
juce::uint64 bestId = std::numeric_limits<juce::uint64>::max(); juce::uint64 bestId = std::numeric_limits<juce::uint64>::max();
for (auto& v : voices) for (auto& voice : voices)
if (v.isActive() && v.isReleased() && v.getNoteId() < bestId) if (voice.isActive() && voice.isReleased() && voice.getNoteId() < bestId)
{ {
best = &v; best = &voice;
bestId = v.getNoteId(); bestId = voice.getNoteId();
} }
if (best != nullptr) if (best != nullptr)
return best; return best;
for (auto& v : voices) for (auto& voice : voices)
if (v.isActive() && v.getNoteId() < bestId) if (voice.isActive() && voice.getNoteId() < bestId)
{ {
best = &v; best = &voice;
bestId = v.getNoteId(); bestId = voice.getNoteId();
} }
return best != nullptr ? best : &voices[0]; return best != nullptr ? best : &voices[0];
} }
@@ -140,181 +146,221 @@ void Engine::noteOn (int noteNumber, float velocity01)
void Engine::noteOff (int noteNumber) void Engine::noteOff (int noteNumber)
{ {
for (auto& v : voices) for (auto& voice : voices)
if (v.isActive() && v.getNote() == noteNumber && ! v.isReleased()) if (voice.isActive() && voice.getNote() == noteNumber && ! voice.isReleased())
v.noteOff(); voice.noteOff();
} }
void Engine::allNotesOff() void Engine::allNotesOff()
{ {
for (auto& v : voices) for (auto& voice : voices)
if (v.isActive()) if (voice.isActive())
v.noteOff(); voice.noteOff();
} }
void Engine::readOscParams (juce::AudioProcessorValueTreeState& apvts, const char* prefix, OscParams& o) void Engine::readOscParams (const paramIds::Oscillator& ids, OscParams& o) const
{ {
const juce::String p = prefix; o.enabled = v (ids.on) > 0.5f;
auto g = [&] (const char* suffix) { return v (apvts, (p + suffix).toRawUTF8()); }; o.wave = vic (ids.wave, kNumWavetables - 1);
o.wtPos = v (ids.wtPos);
o.enabled = g ("On") > 0.5f; o.warp = vic (ids.warp, (int) WarpMode::Count - 1);
o.wave = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (g ("Wave") * (kNumWavetables - 1))); o.warpAmt = v (ids.warpAmt);
o.wtPos = g ("WtPos"); o.coarse = vic (ids.coarse, 48) - 24;
o.warp = (int) std::llround (g ("Warp") * 7.0f); o.fine = vic (ids.fine, 200) - 100;
o.warpAmt = g ("WarpAmt"); o.level = v (ids.level);
o.coarse = (int) std::llround (g ("Coarse") * 48.0f) - 24; o.pan = v (ids.pan) * 2.0f - 1.0f;
o.fine = (int) std::llround (g ("Fine") * 200.0f) - 100; o.unison = 1 + vic (ids.unison, kMaxUnison - 1);
o.level = g ("Level"); o.detune = v (ids.detune);
o.pan = g ("Pan") * 2.0f - 1.0f; o.spread = v (ids.spread);
o.unison = 1 + (int) std::llround (g ("Unison") * 15.0f); o.phase = v (ids.phase);
o.detune = g ("Detune"); o.randPhase = v (ids.randPhase);
o.spread = g ("Spread");
o.phase = g ("Phase");
o.randPhase = g ("RandPh");
} }
void Engine::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi, void Engine::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi,
juce::AudioProcessorValueTreeState& apvts, juce::AudioProcessorValueTreeState&, juce::AudioPlayHead* playhead)
juce::AudioPlayHead* playhead)
{ {
const int n = buffer.getNumSamples(); const int n = buffer.getNumSamples();
const int numCh = buffer.getNumChannels(); const int numCh = buffer.getNumChannels();
captureControls();
// Tempo. // Tempo.
if (playhead != nullptr) if (playhead != nullptr)
if (auto pos = playhead->getPosition()) if (auto pos = playhead->getPosition())
if (auto b = pos->getBpm()) if (auto tempo = pos->getBpm())
bpm = *b; if (std::isfinite (*tempo) && *tempo > 0.0)
bpm = *tempo;
// MIDI. // Advance LFOs and capture their values at control-rate render boundaries.
for (const auto meta : midi)
{
const auto m = meta.getMessage();
if (m.isNoteOn() && m.getVelocity() > 0)
noteOn (m.getNoteNumber(), m.getFloatVelocity());
else if (m.isNoteOff() || (m.isNoteOn() && m.getVelocity() == 0))
noteOff (m.getNoteNumber());
else if (m.isPitchWheel())
pitchBend = (m.getPitchWheelValue() - 8192) / 8192.0f;
else if (m.isController())
{
if (m.getControllerNumber() == 1)
modWheel = m.getControllerValue() / 127.0f;
else if (m.getControllerNumber() == 120 || m.getControllerNumber() == 123)
allNotesOff();
}
else if (m.isAllNotesOff() || m.isAllSoundOff())
allNotesOff();
}
// Prepare the voice mix buffer.
mixBuffer.setSize (2, n, false, false, true);
mixBuffer.clear();
float* mixL = mixBuffer.getWritePointer (0);
float* mixR = mixBuffer.getWritePointer (1);
// Advance LFOs and capture their values (control rate).
float lfoValues[kNumLfos];
for (int i = 0; i < kNumLfos; ++i) for (int i = 0; i < kNumLfos; ++i)
{ {
lfos[(size_t) i].setTempo (bpm); auto& lfo = lfos[(size_t) i];
lfos[(size_t) i].setParams (v (apvts, kLfoRate[i]), lfo.setTempo (bpm);
v (apvts, kLfoSync[i]) > 0.5f, lfo.setParams (v (paramIds::lfoRate[i]), v (paramIds::lfoSync[i]) > 0.5f,
v (apvts, kLfoBeat[i]), v (paramIds::lfoBeat[i]), vic (paramIds::lfoShape[i], (int) LfoShape::Count - 1),
vic (apvts, kLfoShape[i], 6), v (paramIds::lfoPhase[i]), v (paramIds::lfoFade[i]), v (paramIds::lfoDelay[i]));
v (apvts, kLfoPhase[i]),
v (apvts, kLfoFade[i]),
v (apvts, kLfoDelay[i]));
for (int s = 0; s < n; ++s)
lfos[(size_t) i].process();
lfoValues[i] = lfos[(size_t) i].getValue();
} }
const float macroValues[kNumMacros] = { v (apvts, ids::macro1), v (apvts, ids::macro2),
v (apvts, ids::macro3), v (apvts, ids::macro4) };
// Build the render context. // Build the render context.
RenderContext ctx; RenderContext ctx;
ctx.sampleRate = sr; ctx.sampleRate = sr;
ctx.wavetables = &wavetables; ctx.wavetables = &wavetables;
for (int i = 0; i < kNumLfos; ++i) ctx.lfoValues[i] = lfoValues[i]; ctx.matrix = &audioMatrix;
for (int i = 0; i < kNumMacros; ++i) ctx.macroValues[i] = macroValues[i]; ctx.macros = &audioMacros;
ctx.modWheel = modWheel; for (int i = 0; i < kNumMacros; ++i)
ctx.pitchBend = pitchBend; ctx.macroValues[i] = v (paramIds::macros[i]);
ctx.pitchBendRange = 2.0f;
ctx.matrix = &matrix;
ctx.macros = &macros;
readOscParams (apvts, "oscA", ctx.oscA); readOscParams (paramIds::oscillators[0], ctx.oscA);
readOscParams (apvts, "oscB", ctx.oscB); readOscParams (paramIds::oscillators[1], ctx.oscB);
ctx.subOn = v (apvts, ids::subOn) > 0.5f; ctx.subOn = v (ids::subOn) > 0.5f;
ctx.subShape = vic (apvts, ids::subShape, 1); ctx.subShape = vic (ids::subShape, (int) SubShape::Count - 1);
ctx.subOct = vic (apvts, ids::subOct, 2) - 2; ctx.subOct = vic (ids::subOct, 2) - 2;
ctx.subLevel = v (apvts, ids::subLevel); 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.noiseOn = v (apvts, ids::noiseOn) > 0.5f; ctx.filters.f1On = v (ids::f1On) > 0.5f;
ctx.noiseType = vic (apvts, ids::noiseType, 1); ctx.filters.f1Type = vic (ids::f1Type, (int) FilterModel::Count - 1);
ctx.noiseLevel = v (apvts, ids::noiseLevel); ctx.filters.f1Cutoff = v (ids::f1Cutoff);
ctx.filters.f1Res = v (ids::f1Res);
ctx.filters.f1On = v (apvts, ids::f1On) > 0.5f; ctx.filters.f1Drive = v (ids::f1Drive);
ctx.filters.f1Type = vic (apvts, ids::f1Type, 6); ctx.filters.f1Key = v (ids::f1Key);
ctx.filters.f1Cutoff = v (apvts, ids::f1Cutoff); ctx.filters.f1Slope = vic (ids::f1Slope, 2);
ctx.filters.f1Res = v (apvts, ids::f1Res); ctx.filters.f2On = v (ids::f2On) > 0.5f;
ctx.filters.f1Drive = v (apvts, ids::f1Drive); ctx.filters.f2Type = vic (ids::f2Type, (int) FilterModel::Count - 1);
ctx.filters.f1Key = v (apvts, ids::f1Key); ctx.filters.f2Cutoff = v (ids::f2Cutoff);
ctx.filters.f1Slope = vic (apvts, ids::f1Slope, 2); ctx.filters.f2Res = v (ids::f2Res);
ctx.filters.f2Drive = v (ids::f2Drive);
ctx.filters.f2On = v (apvts, ids::f2On) > 0.5f; ctx.filters.f2Key = v (ids::f2Key);
ctx.filters.f2Type = vic (apvts, ids::f2Type, 6); ctx.filters.f2Slope = vic (ids::f2Slope, 2);
ctx.filters.f2Cutoff = v (apvts, ids::f2Cutoff); ctx.filters.route = vic (ids::fRoute, (int) FilterRoute::Count - 1);
ctx.filters.f2Res = v (apvts, ids::f2Res); ctx.filters.mix = v (ids::fMix);
ctx.filters.f2Drive = v (apvts, ids::f2Drive); ctx.filters.out = v (ids::fOut) * 1.5f;
ctx.filters.f2Key = v (apvts, ids::f2Key);
ctx.filters.f2Slope = vic (apvts, ids::f2Slope, 2);
ctx.filters.route = vic (apvts, ids::fRoute, 2);
ctx.filters.mix = v (apvts, ids::fMix);
ctx.filters.out = v (apvts, ids::fOut) * 1.5f;
for (int i = 0; i < kNumEnvelopes; ++i) for (int i = 0; i < kNumEnvelopes; ++i)
{ {
ctx.envAttack[i] = v (apvts, kEnvAttack[i]); ctx.envAttack[i] = v (paramIds::envAttack[i]);
ctx.envDecay[i] = v (apvts, kEnvDecay[i]); ctx.envDecay[i] = v (paramIds::envDecay[i]);
ctx.envSustain[i] = v (apvts, kEnvSustain[i]); ctx.envSustain[i] = v (paramIds::envSustain[i]);
ctx.envRelease[i] = v (apvts, kEnvRelease[i]); ctx.envRelease[i] = v (paramIds::envRelease[i]);
ctx.envCurve[i] = v (apvts, kEnvCurve[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. // Render all active voices into the mix buffer.
for (auto& voice : voices) for (auto& voice : voices)
if (voice.isActive()) if (voice.isActive())
voice.render (mixL, mixR, n, ctx); voice.render (block.getWritePointer (0), block.getWritePointer (1), count, ctx);
// FX rack. auto modulatedSlots = slots;
FxSlotParams slots[kNumFxSlots];
for (int i = 0; i < kNumFxSlots; ++i) for (int i = 0; i < kNumFxSlots; ++i)
{ modulatedSlots[(size_t) i].mix = juce::jlimit (0.0f, 1.0f,
slots[i].type = juce::jlimit (0, (int) FxType::Count - 1, (int) std::llround (v (apvts, kFxType[i]) * ((int) FxType::Count - 1))); slots[(size_t) i].mix + globalMod[(size_t) ModTarget::Fx1Mix + (size_t) i]);
slots[i].mix = v (apvts, kFxMix[i]); fx.process (block, modulatedSlots.data(), kNumFxSlots);
slots[i].p[0] = v (apvts, kFxP1[i]);
slots[i].p[1] = v (apvts, kFxP2[i]);
slots[i].p[2] = v (apvts, kFxP3[i]);
slots[i].p[3] = v (apvts, kFxP4[i]);
}
fx.process (mixBuffer, slots, kNumFxSlots);
// Master + soft limiting. // Master + soft limiting.
const float master = v (apvts, ids::master); const float master = juce::jlimit (0.0f, 1.0f, v (ids::master) + globalMod[(size_t) ModTarget::Master]);
for (int ch = 0; ch < numCh; ++ch) for (int ch = 0; ch < numCh; ++ch)
{ {
float* dest = buffer.getWritePointer (ch); float* dest = buffer.getWritePointer (ch, offset);
const float* src = mixBuffer.getReadPointer (ch < 2 ? ch : 0); const float* src = block.getReadPointer (ch < 2 ? ch : 0);
for (int i = 0; i < n; ++i) for (int i = 0; i < count; ++i)
dest[i] = limit (src[i] * master); 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 } // namespace serum
+25 -10
View File
@@ -1,6 +1,8 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include <string_view>
#include <unordered_map>
#include "Params.h" #include "Params.h"
#include "Wavetable.h" #include "Wavetable.h"
#include "SynthVoice.h" #include "SynthVoice.h"
@@ -19,7 +21,8 @@ namespace serum
class Engine class Engine
{ {
public: public:
void prepare (double sampleRate, int blockSize); Engine();
void prepare (double sampleRate, int blockSize, juce::AudioProcessorValueTreeState& apvts);
void reset(); void reset();
void processBlock (juce::AudioBuffer<float>& buffer, void processBlock (juce::AudioBuffer<float>& buffer,
@@ -32,22 +35,31 @@ public:
void noteOff (int noteNumber); void noteOff (int noteNumber);
void allNotesOff(); void allNotesOff();
// Modulation / DSP accessors (read-only for the GUI). // Control-state accessors: hold getControlLock() while reading or editing.
const juce::CriticalSection& getControlLock() const { return controlLock; }
ModulationMatrix& getMatrix() { return matrix; } ModulationMatrix& getMatrix() { return matrix; }
MacroControls& getMacros() { return macros; } MacroControls& getMacros() { return macros; }
WavetableLibrary& getWavetables() { return wavetables; } const std::array<LFO, kNumLfos>& getLfos() const { return controlLfos; }
const std::array<LFO, kNumLfos>& getLfos() const { return lfos; } const WavetableLibrary& getWavetables() const { return wavetables; }
void setLfoShapeData (int index, const std::vector<float>& data, int steps); void setLfoShapeData (int index, const std::vector<float>& data, int steps);
int getActiveVoiceCount() const; int getActiveVoiceCount() const { return activeVoiceCount.load (std::memory_order_relaxed); }
private: private:
std::array<SynthVoice, kNumVoices> voices; std::array<SynthVoice, kNumVoices> voices;
std::array<LFO, kNumLfos> lfos; std::array<LFO, kNumLfos> lfos, controlLfos;
WavetableLibrary wavetables; WavetableLibrary wavetables;
FXProcessor fx; FXProcessor fx;
ModulationMatrix matrix; ModulationMatrix matrix, audioMatrix;
MacroControls macros; 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; double sr = 44100.0;
int blockSize = 512; int blockSize = 512;
@@ -55,13 +67,16 @@ private:
float pitchBend = 0.0f; float pitchBend = 0.0f;
float modWheel = 0.0f; float modWheel = 0.0f;
juce::uint64 noteCounter = 0; juce::uint64 noteCounter = 0;
std::atomic<int> activeVoiceCount { 0 };
juce::AudioBuffer<float> mixBuffer; juce::AudioBuffer<float> mixBuffer;
SynthVoice* findFreeVoice(); SynthVoice* findFreeVoice();
SynthVoice* stealVoice(); SynthVoice* stealVoice();
void captureControls();
void readOscParams (juce::AudioProcessorValueTreeState& apvts, const char* prefix, OscParams& out); 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 } // namespace serum
+14 -11
View File
@@ -14,24 +14,27 @@ namespace serum
FXProcessor::FXProcessor() FXProcessor::FXProcessor()
{ {
units[0] = std::make_unique<HyperUnit>(); for (auto& slotUnits : units)
units[1] = std::make_unique<ChorusUnit>(); {
units[2] = std::make_unique<FlangerUnit>(); slotUnits[0] = std::make_unique<HyperUnit>();
units[3] = std::make_unique<PhaserUnit>(); slotUnits[1] = std::make_unique<ChorusUnit>();
units[4] = std::make_unique<DistortionUnit>(); slotUnits[2] = std::make_unique<FlangerUnit>();
units[5] = std::make_unique<EQUnit>(); slotUnits[3] = std::make_unique<PhaserUnit>();
units[6] = std::make_unique<CompressorUnit>(); slotUnits[4] = std::make_unique<DistortionUnit>();
units[7] = std::make_unique<DelayUnit>(); slotUnits[5] = std::make_unique<EQUnit>();
units[8] = std::make_unique<ReverbUnit>(); slotUnits[6] = std::make_unique<CompressorUnit>();
slotUnits[7] = std::make_unique<DelayUnit>();
slotUnits[8] = std::make_unique<ReverbUnit>();
}
} }
FXProcessor::~FXProcessor() = default; FXProcessor::~FXProcessor() = default;
void FXProcessor::prepare (double sampleRate, int maxBlockSize) void FXProcessor::prepare (double sampleRate, int maxBlockSize)
{ {
for (auto& u : units) for (auto& slotUnits : units)
for (auto& u : slotUnits)
u->prepare (sampleRate, maxBlockSize); u->prepare (sampleRate, maxBlockSize);
dry.setSize (2, maxBlockSize, false, false, true);
wet.setSize (2, maxBlockSize, false, false, true); wet.setSize (2, maxBlockSize, false, false, true);
reset(); reset();
} }
+5 -3
View File
@@ -31,7 +31,7 @@ struct FxSlotParams
// =========================================================================== // ===========================================================================
// Reorderable effects rack. Slots are processed in list order; reordering is // Reorderable effects rack. Slots are processed in list order; reordering is
// simply swapping FxSlotParams entries. Nine effect unit implementations are // simply swapping FxSlotParams entries. Nine effect unit implementations are
// owned here and shared across slots. // preconstructed per slot with independent DSP history.
// =========================================================================== // ===========================================================================
class FXProcessor class FXProcessor
{ {
@@ -47,8 +47,10 @@ public:
static juce::String fxTypeName (int type); static juce::String fxTypeName (int type);
private: private:
std::array<std::unique_ptr<FXUnit>, 9> units; static constexpr int kNumEffectTypes = (int) FxType::Count - 1;
juce::AudioBuffer<float> dry, wet; std::array<std::array<std::unique_ptr<FXUnit>, kNumEffectTypes>, kNumFxSlots> units;
std::array<int, kNumFxSlots> activeTypes {};
juce::AudioBuffer<float> wet;
}; };
} // namespace serum } // namespace serum
+76 -29
View File
@@ -5,7 +5,7 @@ namespace serum
namespace namespace
{ {
constexpr double kPi = 3.14159265358979323846; constexpr double kPi = juce::MathConstants<double>::pi;
inline float clampF (float v, float lo, float hi) noexcept inline float clampF (float v, float lo, float hi) noexcept
{ {
@@ -30,6 +30,9 @@ void Filter::prepare (double sampleRate, int maxBlockSize)
void Filter::reset() void Filter::reset()
{ {
lastCutoffHz = lastRes = -1.0f;
lastType = -1;
ladderG = 0.0;
ic1eq = ic2eq = 0.0; ic1eq = ic2eq = 0.0;
lastG = lastK = 0.0; lastG = lastK = 0.0;
a1 = a2 = a3 = 0.0; a1 = a2 = a3 = 0.0;
@@ -40,6 +43,57 @@ void Filter::reset()
combDamp = 0.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 void Filter::updateSvf (double g, double k) noexcept
{ {
if (g == lastG && k == lastK) if (g == lastG && k == lastK)
@@ -143,22 +197,16 @@ double Filter::comb (double in, double freqHz, double res, double drive) noexcep
return (double) y; return (double) y;
} }
double Filter::formant (double in, double morph, double res) noexcept double Filter::formant (double in) noexcept
{ {
// morph (0..1) sweeps the three bandpass centres to produce vowel-like spectra.
const double m = clampD (morph, 0.0, 1.0);
const double base[3] = { 400.0, 1200.0, 2600.0 };
const double k = clampD (2.0 * (1.0 - res), 0.05, 2.0);
double out = 0.0; double out = 0.0;
const double gains[3] = { 1.0, 0.8, 0.5 }; const double gains[3] = { 1.0, 0.8, 0.5 };
for (int i = 0; i < 3; ++i) for (int i = 0; i < 3; ++i)
{ {
const double fc = base[i] * (0.7 + 1.6 * m) * (i == 2 ? 0.9 : 1.0); const auto& c = formantCoefficients[(size_t) i];
const double g = std::tan (kPi * clampD (fc, 30.0, sr * 0.45) / sr); const double a1 = c[0];
const double a1 = 1.0 / (1.0 + g * (g + k)); const double a2 = c[1];
const double a2 = g * a1; const double a3 = c[2];
const double a3 = g * a2;
const double v3 = in - formantState[(size_t) i][1]; const double v3 = in - formantState[(size_t) i][1];
const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3; const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3;
const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * v3; const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * v3;
@@ -169,11 +217,9 @@ double Filter::formant (double in, double morph, double res) noexcept
return clampD (out * 0.5, -8.0, 8.0); return clampD (out * 0.5, -8.0, 8.0);
} }
double Filter::screamer (double in, double cutoffHz, double res, double drive) noexcept double Filter::screamer (double in, double drive) noexcept
{ {
const double g = std::tan (kPi * clampD (cutoffHz, 30.0, sr * 0.45) / sr); const double band = svfBand (in, lastG, lastK);
const double k = clampD (2.0 * (1.0 - res), 0.05, 2.0);
const double band = svfBand (in, g, k);
const double driven = std::tanh (band * (1.0 + drive * 12.0)); const double driven = std::tanh (band * (1.0 + drive * 12.0));
return driven * (1.0 - drive * 0.4); return driven * (1.0 - drive * 0.4);
} }
@@ -183,11 +229,8 @@ float Filter::processSample (float in, float cutoffHz, float res, float drive, i
res = clampF (res, 0.0f, 0.98f); res = clampF (res, 0.0f, 0.98f);
drive = clampF (drive, 0.0f, 1.0f); drive = clampF (drive, 0.0f, 1.0f);
// Ladder stages are cascaded one-poles, which need an exponential coefficient updateCoefficients (cutoffHz, res, type);
// (always in (0,1]) for unconditional stability. The TPT SVF (formant/screamer) const double g = ladderG;
// computes its own tan()-based g internally.
const double fc = clampD (cutoffHz, 20.0, sr * 0.45);
const double g = 1.0 - std::exp (-2.0 * kPi * fc / sr);
double out = (double) in; double out = (double) in;
switch ((FilterModel) type) switch ((FilterModel) type)
@@ -221,10 +264,10 @@ float Filter::processSample (float in, float cutoffHz, float res, float drive, i
out = comb (in, cutoffHz, res, drive); out = comb (in, cutoffHz, res, drive);
break; break;
case FilterModel::Formant: case FilterModel::Formant:
out = formant (in, maps::hzToCutoff (cutoffHz), res); out = formant (in);
break; break;
case FilterModel::Screamer: case FilterModel::Screamer:
out = screamer (in, cutoffHz, res, drive); out = screamer (in, drive);
break; break;
default: default:
break; break;
@@ -233,18 +276,22 @@ float Filter::processSample (float in, float cutoffHz, float res, float drive, i
return (float) clampD (out, -8.0, 8.0); 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, void Filter::process (float* samples, int numSamples, float cutoffNorm, float res,
float drive, float keytrack, float noteHz, int type, int slope) noexcept float drive, float keytrack, float noteHz, int type, int slope) noexcept
{ {
if (samples == nullptr || numSamples <= 0) if (samples == nullptr || numSamples <= 0)
return; return;
// Keytrack shifts the cutoff with note pitch. const float cutoffHz = getCutoffHz (cutoffNorm, keytrack, noteHz);
const float noteNumber = (noteHz > 0.0f) ? (69.0f + 12.0f * std::log2f (noteHz / 440.0f)) : 60.0f;
const float baseHz = maps::cutoffToHz (cutoffNorm);
const float keyFactor = std::pow (2.0f, keytrack * (noteNumber - 60.0f) / 12.0f);
const float cutoffHz = clampF (baseHz * keyFactor, 20.0f, 18000.0f);
for (int i = 0; i < numSamples; ++i) for (int i = 0; i < numSamples; ++i)
samples[i] = processSample (samples[i], cutoffHz, res, drive, type, slope); samples[i] = processSample (samples[i], cutoffHz, res, drive, type, slope);
} }
+8 -2
View File
@@ -23,9 +23,13 @@ public:
// Single-sample version (used by the comb/naive paths where convenient). // 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; 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: private:
double sr = 44100.0; 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). // TPT SVF state (also reused by formant/screamer).
double ic1eq = 0.0, ic2eq = 0.0; double ic1eq = 0.0, ic2eq = 0.0;
@@ -42,15 +46,17 @@ private:
// Formant: three parallel bandpass SVFs (state pairs). // Formant: three parallel bandpass SVFs (state pairs).
std::array<std::array<double, 2>, 3> formantState { { { { 0.0, 0.0 } }, { { 0.0, 0.0 } }, { { 0.0, 0.0 } } } }; std::array<std::array<double, 2>, 3> formantState { { { { 0.0, 0.0 } }, { { 0.0, 0.0 } }, { { 0.0, 0.0 } } } };
std::array<std::array<double, 3>, 3> formantCoefficients {};
void updateCoefficients (float cutoffHz, float res, int type) noexcept;
void updateSvf (double g, double k) noexcept; void updateSvf (double g, double k) noexcept;
double svfLow (double in, double g, double k) noexcept; double svfLow (double in, double g, double k) noexcept;
double svfBand (double in, double g, double k) noexcept; double svfBand (double in, double g, double k) noexcept;
double svfHigh (double in, double g, double k) noexcept; double svfHigh (double in, double g, double k) noexcept;
double ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept; double ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept;
double comb (double in, double freqHz, double res, double drive) noexcept; double comb (double in, double freqHz, double res, double drive) noexcept;
double formant (double in, double morph, double res) noexcept; double formant (double in) noexcept;
double screamer (double in, double cutoffHz, double res, double drive) noexcept; double screamer (double in, double drive) noexcept;
}; };
} // namespace serum } // namespace serum
+6 -4
View File
@@ -39,6 +39,8 @@ void FilterBank::process (float* l, float* r, int numSamples, const FilterBankPa
else if (p.route == (int) FilterRoute::Parallel) else if (p.route == (int) FilterRoute::Parallel)
{ {
// Run both filters on copies and crossfade. // 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; float f1l = 0.0f, f1r = 0.0f, f2l = 0.0f, f2r = 0.0f;
for (int i = 0; i < numSamples; ++i) for (int i = 0; i < numSamples; ++i)
{ {
@@ -46,13 +48,13 @@ void FilterBank::process (float* l, float* r, int numSamples, const FilterBankPa
f2l = l[i]; f2r = r[i]; f2l = l[i]; f2r = r[i];
if (p.f1On) if (p.f1On)
{ {
f1l = f1L.processSample (f1l, maps::cutoffToHz (p.f1Cutoff), p.f1Res, p.f1Drive, p.f1Type, p.f1Slope); f1l = f1L.processSample (f1l, cutoff1, p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
f1r = f1R.processSample (f1r, maps::cutoffToHz (p.f1Cutoff), p.f1Res, p.f1Drive, p.f1Type, p.f1Slope); f1r = f1R.processSample (f1r, cutoff1, p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
} }
if (p.f2On) if (p.f2On)
{ {
f2l = f2L.processSample (f2l, maps::cutoffToHz (p.f2Cutoff), p.f2Res, p.f2Drive, p.f2Type, p.f2Slope); f2l = f2L.processSample (f2l, cutoff2, p.f2Res, p.f2Drive, p.f2Type, p.f2Slope);
f2r = f2R.processSample (f2r, maps::cutoffToHz (p.f2Cutoff), 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; const float m = p.mix;
l[i] = f1l * (1.0f - m) + f2l * m; l[i] = f1l * (1.0f - m) + f2l * m;
+2 -2
View File
@@ -12,8 +12,8 @@ namespace serum
class Display : public juce::Component class Display : public juce::Component
{ {
public: public:
void setTitle (const juce::String& t) { title = t; repaint(); } void setTitle (const juce::String& t) { if (title != t) { title = t; repaint(); } }
void setValue (const juce::String& v) { value = v; repaint(); } void setValue (const juce::String& v) { if (value != v) { value = v; repaint(); } }
void paint (juce::Graphics& g) override; void paint (juce::Graphics& g) override;
+7 -5
View File
@@ -5,7 +5,10 @@ namespace serum
void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c) void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c)
{ {
if (attack == a && decay == d && sustain == s && release == r && curve == c)
return;
attack = a; decay = d; sustain = s; release = r; curve = c; attack = a; decay = d; sustain = s; release = r; curve = c;
repaint();
} }
void EnvelopeDisplay::paint (juce::Graphics& g) void EnvelopeDisplay::paint (juce::Graphics& g)
@@ -19,7 +22,7 @@ void EnvelopeDisplay::paint (juce::Graphics& g)
const float atkShape = 0.3f + curve * 2.7f; const float atkShape = 0.3f + curve * 2.7f;
const float decShape = 3.0f - curve * 2.7f; const float decShape = 3.0f - curve * 2.7f;
// Normalise durations for display (attack 0..1, decay 0..0.6, release 0..0.6). // Normalise durations for display, allowing a short sustain plateau.
const float aSec = maps::toSeconds (attack); const float aSec = maps::toSeconds (attack);
const float dSec = maps::toSeconds (decay); const float dSec = maps::toSeconds (decay);
const float rSec = maps::toSeconds (release); const float rSec = maps::toSeconds (release);
@@ -36,7 +39,6 @@ void EnvelopeDisplay::paint (juce::Graphics& g)
juce::Path path; juce::Path path;
path.startNewSubPath (left, bottom); path.startNewSubPath (left, bottom);
path.lineTo (left, top);
// Attack (curve-shaped). // Attack (curve-shaped).
const int steps = 48; const int steps = 48;
@@ -44,7 +46,7 @@ void EnvelopeDisplay::paint (juce::Graphics& g)
for (int i = 0; i <= steps; ++i) for (int i = 0; i <= steps; ++i)
{ {
const float p = (float) i / steps; const float p = (float) i / steps;
const float y = top + (bottom - top) * std::pow (p, atkShape); const float y = bottom - (bottom - top) * std::pow (p, atkShape);
path.lineTo (left + p * (peakX - left), y); path.lineTo (left + p * (peakX - left), y);
} }
@@ -53,7 +55,7 @@ void EnvelopeDisplay::paint (juce::Graphics& g)
for (int i = 0; i <= steps; ++i) for (int i = 0; i <= steps; ++i)
{ {
const float p = (float) i / steps; const float p = (float) i / steps;
const float y = sustainY + (bottom - sustainY) * std::pow (1.0f - p, decShape); const float y = sustainY + (top - sustainY) * std::pow (1.0f - p, decShape);
path.lineTo (peakX + p * (decX - peakX), y); path.lineTo (peakX + p * (decX - peakX), y);
} }
path.lineTo (decX, sustainY); path.lineTo (decX, sustainY);
@@ -64,7 +66,7 @@ void EnvelopeDisplay::paint (juce::Graphics& g)
for (int i = 0; i <= steps; ++i) for (int i = 0; i <= steps; ++i)
{ {
const float p = (float) i / steps; const float p = (float) i / steps;
const float y = sustainY + (bottom - sustainY) * std::pow (1.0f - p, decShape); const float y = bottom - (bottom - sustainY) * std::pow (1.0f - p, decShape);
path.lineTo (relStartX + p * (right - relStartX), y); path.lineTo (relStartX + p * (right - relStartX), y);
} }
+3
View File
@@ -37,7 +37,10 @@ float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int typ
void FilterDisplay::setParams (int t, float c, float r, float d, int s) void FilterDisplay::setParams (int t, float c, float r, float d, int s)
{ {
if (type == t && cutoff == c && res == r && drive == d && slope == s)
return;
type = t; cutoff = c; res = r; drive = d; slope = s; type = t; cutoff = c; res = r; drive = d; slope = s;
repaint();
} }
void FilterDisplay::paint (juce::Graphics& g) void FilterDisplay::paint (juce::Graphics& g)
+5 -3
View File
@@ -8,13 +8,15 @@ namespace serum
{ {
// =========================================================================== // ===========================================================================
// Filter frequency-response view (magnitude vs log frequency). // Approximate filter frequency-response view (magnitude vs log frequency).
// =========================================================================== // ===========================================================================
class FilterDisplay : public juce::Component class FilterDisplay : public juce::Component,
public juce::SettableTooltipClient
{ {
public: 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 setParams (int type, float cutoff, float res, float drive, int slope);
void setEnabled (bool e) { enabled = e; } void setEnabled (bool e) { if (enabled != e) { enabled = e; repaint(); } }
void paint (juce::Graphics& g) override; void paint (juce::Graphics& g) override;
+9 -3
View File
@@ -5,9 +5,15 @@ namespace serum
void LFODisplay::setShapeData (const std::vector<float>& data, int s) void LFODisplay::setShapeData (const std::vector<float>& data, int s)
{ {
steps = juce::jlimit (2, 64, s); const int newSteps = juce::jlimit (2, 64, s);
shapeData = data; const size_t dataSize = std::min (data.size(), size_t (64));
if ((int) shapeData.size() < 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); shapeData.resize (64, 0.0f);
repaint(); repaint();
} }
+1 -1
View File
@@ -13,7 +13,7 @@ namespace serum
class LFODisplay : public juce::Component class LFODisplay : public juce::Component
{ {
public: public:
void setShape (int s) { shape = s; repaint(); } void setShape (int s) { if (shape != s) { shape = s; repaint(); } }
void setShapeData (const std::vector<float>& data, int steps); 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 setOnShapeEdited (std::function<void (const std::vector<float>&, int)> cb) { onEdited = std::move (cb); }
+5 -5
View File
@@ -10,12 +10,12 @@ ToggleButton::ToggleButton (const juce::String& lbl) : label (lbl)
void ToggleButton::attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId) void ToggleButton::attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId)
{ {
param = apvts.getParameter (paramId); attachment.reset();
if (param != nullptr) if (auto* param = apvts.getParameter (paramId))
{ {
state = param->getValue() > 0.5f;
attachment = std::make_unique<juce::ParameterAttachment> (*param, attachment = std::make_unique<juce::ParameterAttachment> (*param,
[this] (float newValue) { setToggleState (newValue > 0.5f); }); [this] (float newValue) { setToggleState (newValue > 0.5f); });
attachment->sendInitialUpdate();
} }
} }
@@ -60,9 +60,9 @@ void ToggleButton::mouseDown (const juce::MouseEvent&)
{ {
onClick (newState); onClick (newState);
} }
else if (param != nullptr) else if (attachment != nullptr)
{ {
param->setValueNotifyingHost (newState ? 1.0f : 0.0f); attachment->setValueAsCompleteGesture (newState ? 1.0f : 0.0f);
} }
setToggleState (newState); setToggleState (newState);
-1
View File
@@ -35,7 +35,6 @@ public:
private: private:
bool state = false; bool state = false;
juce::String label; juce::String label;
juce::RangedAudioParameter* param = nullptr;
std::unique_ptr<juce::ParameterAttachment> attachment; std::unique_ptr<juce::ParameterAttachment> attachment;
std::function<void (bool)> onClick; std::function<void (bool)> onClick;
juce::Colour onColour = theme::accent; juce::Colour onColour = theme::accent;
+4 -4
View File
@@ -13,10 +13,10 @@ namespace serum
class WaveformDisplay : public juce::Component class WaveformDisplay : public juce::Component
{ {
public: public:
void setWavetables (const WavetableLibrary* lib) { wtLib = lib; } void setWavetables (const WavetableLibrary* lib) { if (wtLib != lib) { wtLib = lib; repaint(); } }
void setWaveIndex (int index) { wave = index; } void setWaveIndex (int index) { if (wave != index) { wave = index; repaint(); } }
void setFramePosition (float pos) { wtPos = pos; } void setFramePosition (float pos) { if (wtPos != pos) { wtPos = pos; repaint(); } }
void setEnabled (bool e) { enabled = e; } void setEnabled (bool e) { if (enabled != e) { enabled = e; repaint(); } }
void paint (juce::Graphics& g) override; void paint (juce::Graphics& g) override;
+33 -20
View File
@@ -10,15 +10,17 @@ void LFO::reset()
delayCounter = 0.0; delayCounter = 0.0;
fadeCounter = 0.0; fadeCounter = 0.0;
fadeVal = 1.0f; fadeVal = 1.0f;
holdValue = 0.0f; holdValue = rng.nextFloat() * 2.0f - 1.0f;
prevPhase = 0.0;
prevDelayParam = -1.0f; prevDelayParam = -1.0f;
prevFadeParam = -1.0f; prevFadeParam = -1.0f;
shapeBuffer.assign ((size_t) kShapePoints, 0.0f); if (shapeBuffer.empty())
{
shapeBuffer.resize ((size_t) kShapePoints);
// default step sequence // default step sequence
for (int i = 0; i < kShapePoints; ++i) for (int i = 0; i < kShapePoints; ++i)
shapeBuffer[(size_t) i] = (i % 2 == 0) ? 1.0f : -1.0f; shapeBuffer[(size_t) i] = (i % 2 == 0) ? 1.0f : -1.0f;
} }
}
void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph, void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
float fade, float delay) float fade, float delay)
@@ -26,10 +28,10 @@ void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
sync = s; sync = s;
beat = b; beat = b;
shape = juce::jlimit (0, (int) LfoShape::Count - 1, shp); shape = juce::jlimit (0, (int) LfoShape::Count - 1, shp);
phase = juce::jlimit (0.0f, 1.0f, ph); phaseOffset = juce::jlimit (0.0f, 1.0f, ph);
if (sync) if (sync)
rateHz = maps::beatToMultiplier (beat) * (tempo / 60.0); rateHz = (tempo / 60.0) / maps::beatToMultiplier (beat);
else else
rateHz = maps::rateToHz (rateNorm); rateHz = maps::rateToHz (rateNorm);
@@ -46,6 +48,7 @@ void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
prevDelayParam = delay; prevDelayParam = delay;
prevFadeParam = fade; prevFadeParam = fade;
} }
value = delayCounter > 0.0 ? 0.0f : shapeValue() * fadeVal;
} }
void LFO::setShapeData (const std::vector<float>& data, int steps) void LFO::setShapeData (const std::vector<float>& data, int steps)
@@ -54,17 +57,18 @@ void LFO::setShapeData (const std::vector<float>& data, int steps)
return; return;
shapeSteps = juce::jlimit (2, kShapePoints, steps); shapeSteps = juce::jlimit (2, kShapePoints, steps);
shapeBuffer.assign (data.begin(), data.end()); for (size_t i = 0; i < shapeBuffer.size(); ++i)
shapeBuffer.resize ((size_t) kShapePoints, 0.0f); shapeBuffer[i] = i < data.size() && std::isfinite (data[i])
? juce::jlimit (-1.0f, 1.0f, data[i]) : 0.0f;
} }
float LFO::shapeValue() noexcept float LFO::shapeValue() noexcept
{ {
const float p = (float) phase; const float p = getPhase();
switch ((LfoShape) shape) switch ((LfoShape) shape)
{ {
case LfoShape::Sine: case LfoShape::Sine:
return std::sin (p * 6.28318530717958647692f); return std::sin (p * juce::MathConstants<float>::twoPi);
case LfoShape::Triangle: case LfoShape::Triangle:
return 1.0f - 4.0f * std::abs (p - 0.5f); return 1.0f - 4.0f * std::abs (p - 0.5f);
case LfoShape::Saw: case LfoShape::Saw:
@@ -72,11 +76,7 @@ float LFO::shapeValue() noexcept
case LfoShape::Square: case LfoShape::Square:
return (p < 0.5f) ? 1.0f : -1.0f; return (p < 0.5f) ? 1.0f : -1.0f;
case LfoShape::SampleHold: case LfoShape::SampleHold:
{
if (phase < prevPhase)
holdValue = rng.nextFloat() * 2.0f - 1.0f;
return holdValue; return holdValue;
}
case LfoShape::StepSeq: case LfoShape::StepSeq:
{ {
const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps)); const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps));
@@ -97,27 +97,40 @@ float LFO::shapeValue() noexcept
float LFO::process() noexcept float LFO::process() noexcept
{ {
return advance (1);
}
float LFO::advance (int numSamples) noexcept
{
if (numSamples <= 0)
return value;
// Start delay. // Start delay.
if (delayCounter > 0.0) if (delayCounter > 0.0)
{ {
delayCounter -= 1.0; const int skipped = (int) juce::jmin ((double) numSamples, std::ceil (delayCounter));
value = 0.0f; delayCounter = juce::jmax (0.0, delayCounter - skipped);
return 0.0f; numSamples -= skipped;
if (numSamples == 0)
return value = 0.0f;
} }
// Fade-in ramp. // Fade-in ramp.
if (fadeVal < 1.0f) if (fadeVal < 1.0f)
{ {
fadeCounter += 1.0; fadeCounter += numSamples;
if (fadeSeconds > 0.0) if (fadeSeconds > 0.0)
fadeVal = (float) juce::jlimit (0.0, 1.0, fadeCounter / (fadeSeconds * sr)); fadeVal = (float) juce::jlimit (0.0, 1.0, fadeCounter / (fadeSeconds * sr));
else else
fadeVal = 1.0f; fadeVal = 1.0f;
} }
prevPhase = phase; phase += rateHz * numSamples / sr;
phase += rateHz / sr; const double cycles = std::floor (phase);
phase -= 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; value = shapeValue() * fadeVal;
return value; return value;
+4 -2
View File
@@ -15,6 +15,7 @@ class LFO
public: public:
static constexpr int kShapePoints = 64; static constexpr int kShapePoints = 64;
LFO() { reset(); }
void prepare (double sampleRate) { sr = sampleRate; reset(); } void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset(); void reset();
@@ -24,7 +25,8 @@ public:
void setShapeData (const std::vector<float>& data, int steps); void setShapeData (const std::vector<float>& data, int steps);
float process() noexcept; // advance and return current value float process() noexcept; // advance and return current value
float getPhase() const noexcept { return (float) phase; } float advance (int numSamples) noexcept;
float getPhase() const noexcept { return (float) (phase + phaseOffset - std::floor (phase + phaseOffset)); }
float getValue() const noexcept { return value; } float getValue() const noexcept { return value; }
const std::vector<float>& getShapeData() const noexcept { return shapeBuffer; } const std::vector<float>& getShapeData() const noexcept { return shapeBuffer; }
@@ -35,6 +37,7 @@ private:
double tempo = 120.0; double tempo = 120.0;
double phase = 0.0; double phase = 0.0;
double phaseOffset = 0.0;
double rateHz = 1.0; double rateHz = 1.0;
float value = 0.0f; float value = 0.0f;
@@ -55,7 +58,6 @@ private:
juce::Random rng; juce::Random rng;
float holdValue = 0.0f; float holdValue = 0.0f;
double prevPhase = 0.0;
float shapeValue() noexcept; float shapeValue() noexcept;
}; };
+26 -11
View File
@@ -5,10 +5,11 @@ namespace serum
bool MacroControls::addAssignment (int macro, ModTarget target, float depth) bool MacroControls::addAssignment (int macro, ModTarget target, float depth)
{ {
macro = juce::jlimit (0, kNumMacros - 1, macro); if (macro < 0 || macro >= kNumMacros
if (target == ModTarget::None || (int) assignments[(size_t) macro].size() >= kMaxAssignments) || (int) target < 0 || (int) target >= kNumModTargets
|| ! std::isfinite (depth) || (int) assignments[(size_t) macro].size() >= kMaxAssignments)
return false; return false;
assignments[(size_t) macro].push_back ({ target, depth }); assignments[(size_t) macro].push_back ({ target, juce::jlimit (-1.0f, 1.0f, depth) });
return true; return true;
} }
@@ -40,12 +41,21 @@ juce::String MacroControls::macroName (int index)
juce::ValueTree MacroControls::toValueTree() const 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"); juce::ValueTree tree ("MACROS");
for (int m = 0; m < kNumMacros; ++m) for (int m = 0; m < kNumMacros; ++m)
{ {
juce::ValueTree mac ("MACRO"); juce::ValueTree mac ("MACRO");
mac.setProperty ("index", m, nullptr); mac.setProperty ("index", m, nullptr);
for (const auto& a : assignments[(size_t) m]) for (const auto& a : validated.assignments[(size_t) m])
{ {
juce::ValueTree asg ("ASSIGN"); juce::ValueTree asg ("ASSIGN");
asg.setProperty ("target", modTargetToString (a.target), nullptr); asg.setProperty ("target", modTargetToString (a.target), nullptr);
@@ -60,23 +70,28 @@ juce::ValueTree MacroControls::toValueTree() const
void MacroControls::fromValueTree (const juce::ValueTree& tree) void MacroControls::fromValueTree (const juce::ValueTree& tree)
{ {
clear(); clear();
if (! tree.isValid()) if (! tree.hasType ("MACROS"))
return; return;
for (const auto& mac : tree) for (const auto& mac : tree)
{ {
if (! mac.hasType ("MACRO")) if (! mac.hasType ("MACRO"))
continue; continue;
const int index = juce::jlimit (0, kNumMacros - 1, (int) mac.getProperty ("index", 0)); 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) for (const auto& asg : mac)
{ {
if ((int) assignments[(size_t) index].size() >= kMaxAssignments)
break;
if (! asg.hasType ("ASSIGN")) if (! asg.hasType ("ASSIGN"))
continue; continue;
MacroAssignment a; if (! addAssignment (index,
a.target = modTargetFromString (asg.getProperty ("target").toString()); modTargetFromString (asg.getProperty ("target").toString()),
a.depth = (float) asg.getProperty ("depth", 0.0); (float) asg.getProperty ("depth", 0.0)))
if (a.target != ModTarget::None) continue;
assignments[(size_t) index].push_back (a);
} }
} }
} }
+25 -11
View File
@@ -5,9 +5,11 @@ namespace serum
bool ModulationMatrix::addConnection (ModSource source, ModTarget target, float depth, bool bipolar) bool ModulationMatrix::addConnection (ModSource source, ModTarget target, float depth, bool bipolar)
{ {
if (target == ModTarget::None || (int) connections.size() >= kMaxConnections) if ((int) source < 0 || (int) source >= kNumModSources
|| (int) target < 0 || (int) target >= kNumModTargets
|| ! std::isfinite (depth) || (int) connections.size() >= kMaxConnections)
return false; return false;
connections.push_back ({ source, target, depth, bipolar }); connections.push_back ({ source, target, juce::jlimit (-1.0f, 1.0f, depth), bipolar });
return true; return true;
} }
@@ -26,8 +28,16 @@ void ModulationMatrix::removeAllWithTarget (ModTarget target)
juce::ValueTree ModulationMatrix::toValueTree() const juce::ValueTree ModulationMatrix::toValueTree() const
{ {
juce::ValueTree tree ("MODMATRIX"); ModulationMatrix validated;
for (const auto& c : connections) 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"); juce::ValueTree con ("CONNECTION");
con.setProperty ("source", modSourceToString (c.source), nullptr); con.setProperty ("source", modSourceToString (c.source), nullptr);
@@ -42,20 +52,24 @@ juce::ValueTree ModulationMatrix::toValueTree() const
void ModulationMatrix::fromValueTree (const juce::ValueTree& tree) void ModulationMatrix::fromValueTree (const juce::ValueTree& tree)
{ {
connections.clear(); connections.clear();
if (! tree.isValid()) if (! tree.hasType ("MODMATRIX"))
return; return;
for (const auto& con : tree) for (const auto& con : tree)
{ {
if ((int) connections.size() >= kMaxConnections)
break;
if (! con.hasType ("CONNECTION")) if (! con.hasType ("CONNECTION"))
continue; continue;
ModConnection c; const auto sourceName = con.getProperty ("source").toString();
c.source = modSourceFromString (con.getProperty ("source").toString()); const auto source = modSourceFromString (sourceName);
c.target = modTargetFromString (con.getProperty ("target").toString()); if (sourceName.isEmpty() || modSourceToString (source) != sourceName)
c.depth = (float) con.getProperty ("depth", 0.0); continue;
c.bipolar = (bool) con.getProperty ("bipolar", false); if (! addConnection (source,
if (c.target != ModTarget::None && (int) connections.size() < kMaxConnections) modTargetFromString (con.getProperty ("target").toString()),
connections.push_back (c); (float) con.getProperty ("depth", 0.0),
(bool) con.getProperty ("bipolar", false)))
continue;
} }
} }
+46 -22
View File
@@ -6,49 +6,78 @@ namespace serum
void Oscillator::reset() void Oscillator::reset()
{ {
for (auto& v : voices) for (auto& v : voices)
{ v = SubVoice {};
v.phase = 0.0;
v.detuneRatio = 1.0;
v.pan = 0.0f;
v.level = 1.0f;
}
activeUnison = 1; activeUnison = 1;
initializedUnison = 0;
paramsValid = false;
} }
void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed) void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed)
{ {
jassert (freqHz > 0.0); jassert (freqHz > 0.0);
rng.setSeed (seed); 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 int uni = juce::jlimit (1, kMaxUnison, p.unison);
activeUnison = uni; 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; const double basePhase = (double) p.phase * kTwoPi;
for (int v = initializedUnison; v < uni; ++v)
for (int v = 0; v < uni; ++v)
{ {
// Even phase spacing prevents cancellation across unison voices. // Even phase spacing distributes the initial unison phases.
double offset = (uni > 1) ? ((double) v / (double) uni) * kTwoPi : 0.0; double offset = (uni > 1) ? ((double) v / (double) uni) * kTwoPi : 0.0;
double random = rng.nextFloat() * (double) p.randPhase * kTwoPi; double random = rng.nextFloat() * (double) p.randPhase * kTwoPi;
voices[(size_t) v].phase = basePhase + offset + random; 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. // Detune: linear spread in cents, 0..50 cents at full depth.
double detuneCents = 0.0; double detuneCents = 0.0;
if (uni > 1) if (uni > 1)
detuneCents = (double) p.detune * 50.0 * ((double) (v - (uni - 1) / 2.0) / (double) ((uni - 1) / 2.0)); detuneCents = (double) p.detune * 50.0 * ((double) (v - (uni - 1) / 2.0) / (double) ((uni - 1) / 2.0));
voices[(size_t) v].detuneRatio = std::pow (2.0, detuneCents / 1200.0); sv.detuneRatio = std::pow (2.0, detuneCents / 1200.0);
}
if (panChanged)
{
// Stereo spread. // Stereo spread.
float panPos = (uni > 1) ? ((float) v / (float) (uni - 1) - 0.5f) * 2.0f * p.spread : 0.0f; float panPos = (uni > 1) ? ((float) v / (float) (uni - 1) - 0.5f) * 2.0f * p.spread : 0.0f;
voices[(size_t) v].pan = panPos; 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. // Gain scaling with a centre emphasis for odd unison counts.
float lvl = 1.0f / std::sqrt ((float) uni); float lvl = unisonLevel;
if ((uni & 1) && v == uni / 2) if ((uni & 1) && v == uni / 2)
lvl *= 1.3f; lvl *= 1.3f;
voices[(size_t) v].level = lvl; sv.level = lvl;
} }
} }
activeUnison = uni;
cachedParams = p;
paramsValid = true;
}
float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept
{ {
@@ -96,7 +125,7 @@ void Oscillator::processAdd (const Wavetable& wt, const OscParams& p, double fre
if (! p.enabled || p.level <= 0.0f || freqHz <= 0.0) if (! p.enabled || p.level <= 0.0f || freqHz <= 0.0)
return; return;
const int uni = juce::jlimit (1, kMaxUnison, p.unison); const int uni = activeUnison;
const float framePos = p.wtPos * 255.0f; const float framePos = p.wtPos * 255.0f;
const double phaseInc = kTwoPi * freqHz / sr; const double phaseInc = kTwoPi * freqHz / sr;
@@ -116,14 +145,9 @@ void Oscillator::processAdd (const Wavetable& wt, const OscParams& p, double fre
float sample = wt.readSafe (framePos, warpPhase (phase01, p)); float sample = wt.readSafe (framePos, warpPhase (phase01, p));
sample = warpSample (sample, p); sample = warpSample (sample, p);
// Constant-power pan.
const float panAngle = (sv.pan + 1.0f) * 0.5f * 1.5707963267948966f;
const float panL = std::cos (panAngle);
const float panR = std::sin (panAngle);
const float gain = sv.level * p.level; const float gain = sv.level * p.level;
accL += sample * gain * panL; accL += sample * gain * sv.panL;
accR += sample * gain * panR; accR += sample * gain * sv.panR;
} }
outL += accL; outL += accL;
+8 -3
View File
@@ -41,8 +41,9 @@ public:
void prepare (double sampleRate) { sr = sampleRate; reset(); } void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset(); void reset();
// (Re)configure unison sub-voices: phase offsets, detune, pan and gain. // Initialise a fresh note's unison sub-voices: phase offsets, detune, pan and gain.
void noteOn (double freqHz, const OscParams& p, juce::uint32 seed); void noteOn (double freqHz, const OscParams& p, juce::uint32 seed);
void setParams (const OscParams& p) noexcept;
// Accumulate this oscillator's contribution into outL/outR. // Accumulate this oscillator's contribution into outL/outR.
void processAdd (const Wavetable& wt, const OscParams& p, double freqHz, void processAdd (const Wavetable& wt, const OscParams& p, double freqHz,
@@ -55,16 +56,20 @@ private:
{ {
double phase = 0.0; double phase = 0.0;
double detuneRatio = 1.0; double detuneRatio = 1.0;
float pan = 0.0f; float panL = 0.70710678f;
float panR = 0.70710678f;
float level = 1.0f; float level = 1.0f;
}; };
std::array<SubVoice, kMaxUnison> voices; std::array<SubVoice, kMaxUnison> voices;
int activeUnison = 1; int activeUnison = 1;
int initializedUnison = 0;
bool paramsValid = false;
OscParams cachedParams;
double sr = 44100.0; double sr = 44100.0;
juce::Random rng; juce::Random rng;
static constexpr double kTwoPi = 6.28318530717958647692; static constexpr double kTwoPi = juce::MathConstants<double>::twoPi;
float warpPhase (float phase, const OscParams& p) const noexcept; float warpPhase (float phase, const OscParams& p) const noexcept;
float warpSample (float sample, const OscParams& p) const noexcept; float warpSample (float sample, const OscParams& p) const noexcept;
+1 -1
View File
@@ -50,7 +50,7 @@ ModSource modSourceFromString (const juce::String& s)
for (int i = 0; i < kNumModSources; ++i) for (int i = 0; i < kNumModSources; ++i)
if (s == modSourceToString ((ModSource) i)) if (s == modSourceToString ((ModSource) i))
return (ModSource) i; return (ModSource) i;
return ModSource::Lfo1; return ModSource::Count;
} }
juce::String modTargetName (ModTarget t) juce::String modTargetName (ModTarget t)
+46 -20
View File
@@ -226,6 +226,50 @@ inline constexpr int kNumFxSlots = 8;
inline constexpr int kMaxUnison = 16; inline constexpr int kMaxUnison = 16;
inline constexpr int kNumVoices = 32; 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 // Modulation sources / destinations
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -261,14 +305,7 @@ inline constexpr int kNumModSources = (int) ModSource::Count;
// Is a given source per-voice (i.e. needs a value for each voice)? // Is a given source per-voice (i.e. needs a value for each voice)?
inline bool isPerVoiceSource (ModSource s) inline bool isPerVoiceSource (ModSource s)
{ {
switch (s) return (s >= ModSource::Env1 && s <= ModSource::Note) || s == ModSource::Random;
{
case ModSource::Env1: case ModSource::Env2:
case ModSource::Env3: case ModSource::Env4:
case ModSource::Velocity: case ModSource::Note:
case ModSource::Random: return true;
default: return false;
}
} }
// Does a source already span -1..1 (bipolar range)? // Does a source already span -1..1 (bipolar range)?
@@ -280,18 +317,7 @@ inline bool isBipolarSource (ModSource s)
// Is a given target a per-voice parameter? // Is a given target a per-voice parameter?
inline bool isPerVoiceTarget (ModTarget t) inline bool isPerVoiceTarget (ModTarget t)
{ {
switch (t) return t != ModTarget::Master && ! (t >= ModTarget::Fx1Mix && t <= ModTarget::Fx8Mix);
{
case ModTarget::Master:
case ModTarget::FilterMix: case ModTarget::FilterOut:
case ModTarget::Fx1Mix: case ModTarget::Fx2Mix:
case ModTarget::Fx3Mix: case ModTarget::Fx4Mix:
case ModTarget::Fx5Mix: case ModTarget::Fx6Mix:
case ModTarget::Fx7Mix: case ModTarget::Fx8Mix:
return false;
default:
return true;
}
} }
juce::String modSourceName (ModSource s); juce::String modSourceName (ModSource s);
+137 -51
View File
@@ -94,18 +94,25 @@ namespace
knobs[(size_t) i]->setBounds (x + i * (w + gap), y, w, h); knobs[(size_t) i]->setBounds (x + i * (w + gap), y, w, h);
} }
const char* kFxType[8] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type, constexpr auto& kFxType = paramIds::fxType;
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type }; constexpr auto& kFxMix = paramIds::fxMix;
const char* kFxMix[8] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix, constexpr auto& kFxP1 = paramIds::fxP1;
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix }; constexpr auto& kFxP2 = paramIds::fxP2;
const char* kFxP1[8] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1, constexpr auto& kFxP3 = paramIds::fxP3;
ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 }; constexpr auto& kFxP4 = paramIds::fxP4;
const char* kFxP2[8] = { ids::fx1P2, ids::fx2P2, ids::fx3P2, ids::fx4P2,
ids::fx5P2, ids::fx6P2, ids::fx7P2, ids::fx8P2 }; constexpr const char* kFxParamNames[(int) FxType::Count][4] = {
const char* kFxP3[8] = { ids::fx1P3, ids::fx2P3, ids::fx3P3, ids::fx4P3, { "P1", "P2", "P3", "P4" },
ids::fx5P3, ids::fx6P3, ids::fx7P3, ids::fx8P3 }; { "Intensity", "Low Amount", "High Amount", "Output" },
const char* kFxP4[8] = { ids::fx1P4, ids::fx2P4, ids::fx3P4, ids::fx4P4, { "Rate", "Depth", "Width", "Unused" },
ids::fx5P4, ids::fx6P4, ids::fx7P4, ids::fx8P4 }; { "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" }
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -131,7 +138,6 @@ PluginEditor::PluginEditor (SerumAltAudioProcessor& p)
buildMacroTab(); buildMacroTab();
setTab (0); setTab (0);
applyUiScale();
startTimerHz (30); startTimerHz (30);
} }
@@ -163,9 +169,11 @@ Knob* PluginEditor::makeKnob (juce::Component* parent, const juce::String& name,
const juce::String& paramId, std::function<juce::String (float)> fmt, const juce::String& paramId, std::function<juce::String (float)> fmt,
const juce::String& tooltip) const juce::String& tooltip)
{ {
auto* k = new Knob (name, std::move (fmt)); auto control = std::make_unique<Knob> (name, std::move (fmt));
auto* k = control.get();
ownedControls.push_back (std::move (control));
parent->addAndMakeVisible (k); parent->addAndMakeVisible (k);
if (paramId.isNotEmpty()) if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr)
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> ( sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
processor.parameters, paramId, *k)); processor.parameters, paramId, *k));
k->setTooltip (tooltip.isNotEmpty() ? tooltip : name); k->setTooltip (tooltip.isNotEmpty() ? tooltip : name);
@@ -175,11 +183,13 @@ Knob* PluginEditor::makeKnob (juce::Component* parent, const juce::String& name,
juce::ComboBox* PluginEditor::makeCombo (juce::Component* parent, const juce::String& paramId, juce::ComboBox* PluginEditor::makeCombo (juce::Component* parent, const juce::String& paramId,
const juce::StringArray& items, const juce::String& tooltip) const juce::StringArray& items, const juce::String& tooltip)
{ {
auto* c = new juce::ComboBox(); auto control = std::make_unique<juce::ComboBox>();
auto* c = control.get();
ownedControls.push_back (std::move (control));
c->addItemList (items, 1); c->addItemList (items, 1);
c->setSelectedItemIndex (0, juce::dontSendNotification); c->setSelectedItemIndex (0, juce::dontSendNotification);
parent->addAndMakeVisible (c); parent->addAndMakeVisible (c);
if (paramId.isNotEmpty()) if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr)
comboAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::ComboBoxAttachment> ( comboAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::ComboBoxAttachment> (
processor.parameters, paramId, *c)); processor.parameters, paramId, *c));
if (tooltip.isNotEmpty()) if (tooltip.isNotEmpty())
@@ -190,7 +200,9 @@ juce::ComboBox* PluginEditor::makeCombo (juce::Component* parent, const juce::St
ToggleButton* PluginEditor::makeToggle (juce::Component* parent, const juce::String& label, ToggleButton* PluginEditor::makeToggle (juce::Component* parent, const juce::String& label,
const juce::String& paramId, const juce::String& tooltip) const juce::String& paramId, const juce::String& tooltip)
{ {
auto* t = new ToggleButton (label); auto control = std::make_unique<ToggleButton> (label);
auto* t = control.get();
ownedControls.push_back (std::move (control));
parent->addAndMakeVisible (t); parent->addAndMakeVisible (t);
if (paramId.isNotEmpty()) if (paramId.isNotEmpty())
t->attach (processor.parameters, paramId); t->attach (processor.parameters, paramId);
@@ -430,11 +442,11 @@ void PluginEditor::buildModTab()
envDisplays[(size_t) i].setBounds (layout::padding, layout::titleHeight, envDisplays[(size_t) i].setBounds (layout::padding, layout::titleHeight,
colW - 2 * layout::padding, layout::envDisplayHeight); colW - 2 * layout::padding, layout::envDisplayHeight);
const char* idsA[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A }; const auto& idsA = paramIds::envAttack;
const char* idsD[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D }; const auto& idsD = paramIds::envDecay;
const char* idsS[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S }; const auto& idsS = paramIds::envSustain;
const char* idsR[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R }; const auto& idsR = paramIds::envRelease;
const char* idsC[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve }; const auto& idsC = paramIds::envCurve;
std::vector<Knob*> knobs = { std::vector<Knob*> knobs = {
makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time (0.5 ms - 12 s)"), makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time (0.5 ms - 12 s)"),
@@ -455,13 +467,13 @@ void PluginEditor::buildModTab()
lfoPanels[(size_t) i].setBounds (gridX (i, colW, colGap), lfoTop, colW, lfoH); lfoPanels[(size_t) i].setBounds (gridX (i, colW, colGap), lfoTop, colW, lfoH);
modView.addAndMakeVisible (lfoPanels[(size_t) i]); modView.addAndMakeVisible (lfoPanels[(size_t) i]);
const char* rate[4] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate }; const auto& rate = paramIds::lfoRate;
const char* sync[4] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync }; const auto& sync = paramIds::lfoSync;
const char* beat[4] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat }; const auto& beat = paramIds::lfoBeat;
const char* shape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape }; const auto& shape = paramIds::lfoShape;
const char* phase[4] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase }; const auto& phase = paramIds::lfoPhase;
const char* fade[4] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade }; const auto& fade = paramIds::lfoFade;
const char* delay[4] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay }; const auto& delay = paramIds::lfoDelay;
makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "LFO shape")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight); makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "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); makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Tempo-sync LFO")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
@@ -533,9 +545,12 @@ void PluginEditor::buildModTab()
const int tid = modTargetCombo.getSelectedItemIndex(); const int tid = modTargetCombo.getSelectedItemIndex();
if (tid >= 0 && tid < (int) targetEnums.size()) if (tid >= 0 && tid < (int) targetEnums.size())
{ {
{
const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid], processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
(float) modDepthKnob.getValue(), (float) modDepthKnob.getValue(),
modBipolarToggle.getToggleState()); modBipolarToggle.getToggleState());
}
updateModList(); updateModList();
} }
}; };
@@ -546,7 +561,12 @@ void PluginEditor::buildModTab()
modRemoveButton.setTooltip ("Remove last modulation connection"); modRemoveButton.setTooltip ("Remove last modulation connection");
modRemoveButton.onClick = [this] modRemoveButton.onClick = [this]
{ {
processor.engine.getMatrix().removeConnection (processor.engine.getMatrix().size() - 1); {
const juce::ScopedLock lock (processor.engine.getControlLock());
auto& matrix = processor.engine.getMatrix();
if (matrix.size() > 0)
matrix.removeConnection (matrix.size() - 1);
}
updateModList(); updateModList();
}; };
matrixPanel.addAndMakeVisible (modRemoveButton); matrixPanel.addAndMakeVisible (modRemoveButton);
@@ -556,7 +576,10 @@ void PluginEditor::buildModTab()
modClearButton.setTooltip ("Clear all modulation connections"); modClearButton.setTooltip ("Clear all modulation connections");
modClearButton.onClick = [this] modClearButton.onClick = [this]
{ {
{
const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMatrix().clear(); processor.engine.getMatrix().clear();
}
updateModList(); updateModList();
}; };
matrixPanel.addAndMakeVisible (modClearButton); matrixPanel.addAndMakeVisible (modClearButton);
@@ -599,11 +622,15 @@ void PluginEditor::buildFxTab()
fxTypeCombos[(size_t) i]->setBounds (layout::padding, comboRowY(), 160, layout::comboHeight); fxTypeCombos[(size_t) i]->setBounds (layout::padding, comboRowY(), 160, layout::comboHeight);
const int upX = layout::padding + 160 + layout::gap; const int upX = layout::padding + 160 + layout::gap;
fxUp[(size_t) i] = new juce::TextButton ("\xe2\x86\x91"); 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]->setBounds (upX, comboRowY(), layout::arrowWidth, layout::comboHeight);
fxUp[(size_t) i]->setTooltip ("Move effect up"); fxUp[(size_t) i]->setTooltip ("Move effect up");
fxPanels[(size_t) i].addAndMakeVisible (fxUp[(size_t) i]); fxPanels[(size_t) i].addAndMakeVisible (fxUp[(size_t) i]);
fxDown[(size_t) i] = new juce::TextButton ("\xe2\x86\x93"); 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]->setBounds (upX + layout::arrowWidth + layout::gap, comboRowY(), layout::arrowWidth, layout::comboHeight);
fxDown[(size_t) i]->setTooltip ("Move effect down"); fxDown[(size_t) i]->setTooltip ("Move effect down");
fxPanels[(size_t) i].addAndMakeVisible (fxDown[(size_t) i]); fxPanels[(size_t) i].addAndMakeVisible (fxDown[(size_t) i]);
@@ -637,8 +664,8 @@ void PluginEditor::buildMacroTab()
macroPanels[(size_t) i].setBounds (gridX (i, colW, colGap), layout::margin, colW, macroH); macroPanels[(size_t) i].setBounds (gridX (i, colW, colGap), layout::margin, colW, macroH);
macroView.addAndMakeVisible (macroPanels[(size_t) i]); macroView.addAndMakeVisible (macroPanels[(size_t) i]);
const char* ids[4] = { ids::macro1, ids::macro2, ids::macro3, ids::macro4 }; const auto& macroIds = paramIds::macros;
macroKnobs[(size_t) i] = makeKnob (&macroPanels[(size_t) i], MacroControls::macroName (i), ids[i], formatPercent, macroKnobs[(size_t) i] = makeKnob (&macroPanels[(size_t) i], MacroControls::macroName (i), macroIds[i], formatPercent,
MacroControls::macroName (i) + " macro (0-100%)"); MacroControls::macroName (i) + " macro (0-100%)");
macroKnobs[(size_t) i]->setBounds ((colW - layout::macroKnobSize) / 2, macroKnobs[(size_t) i]->setBounds ((colW - layout::macroKnobSize) / 2,
layout::titleHeight + layout::padding, layout::titleHeight + layout::padding,
@@ -684,8 +711,11 @@ void PluginEditor::buildMacroTab()
const int tid = macroAssignTarget.getSelectedItemIndex(); const int tid = macroAssignTarget.getSelectedItemIndex();
if (tid >= 0 && tid < (int) targetEnums.size()) if (tid >= 0 && tid < (int) targetEnums.size())
{ {
{
const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid], processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
(float) macroDepthKnob.getValue()); (float) macroDepthKnob.getValue());
}
updateMacroList(); updateMacroList();
} }
}; };
@@ -696,7 +726,10 @@ void PluginEditor::buildMacroTab()
macroClearButton.setTooltip ("Clear all macro assignments"); macroClearButton.setTooltip ("Clear all macro assignments");
macroClearButton.onClick = [this] macroClearButton.onClick = [this]
{ {
{
const juce::ScopedLock lock (processor.engine.getControlLock());
processor.engine.getMacros().clear(); processor.engine.getMacros().clear();
}
updateMacroList(); updateMacroList();
}; };
macroAssignPanel.addAndMakeVisible (macroClearButton); macroAssignPanel.addAndMakeVisible (macroClearButton);
@@ -715,10 +748,15 @@ void PluginEditor::buildMacroTab()
void PluginEditor::swapFxSlots (int a, int b) 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 swapParam = [this] (const char* pa, const char* pb)
{ {
auto* p1 = processor.parameters.getParameter (pa); auto* p1 = processor.parameters.getParameter (pa);
auto* p2 = processor.parameters.getParameter (pb); auto* p2 = processor.parameters.getParameter (pb);
if (p1 == nullptr || p2 == nullptr)
return;
const float v1 = p1->getValue(); const float v1 = p1->getValue();
const float v2 = p2->getValue(); const float v2 = p2->getValue();
p1->setValueNotifyingHost (v2); p1->setValueNotifyingHost (v2);
@@ -748,6 +786,7 @@ void PluginEditor::setTab (int index)
tabMod.setToggleState (currentTab == 2, juce::dontSendNotification); tabMod.setToggleState (currentTab == 2, juce::dontSendNotification);
tabFx.setToggleState (currentTab == 3, juce::dontSendNotification); tabFx.setToggleState (currentTab == 3, juce::dontSendNotification);
tabMacro.setToggleState (currentTab == 4, juce::dontSendNotification); tabMacro.setToggleState (currentTab == 4, juce::dontSendNotification);
timerCallback();
} }
void PluginEditor::cycleTab (int delta) void PluginEditor::cycleTab (int delta)
@@ -791,11 +830,18 @@ void PluginEditor::applyUiScale()
void PluginEditor::updateVisuals() void PluginEditor::updateVisuals()
{ {
const auto& apvts = processor.parameters; const auto& apvts = processor.parameters;
auto gv = [&] (const char* id) { return apvts.getRawParameterValue (id)->load(); }; auto gv = [&] (const char* id)
{
if (auto* value = apvts.getRawParameterValue (id))
return value->load();
return 0.0f;
};
masterDisplay.setValue (formatPercent (gv (ids::master))); masterDisplay.setValue (formatPercent (gv (ids::master)));
// Waveforms. // Waveforms.
if (currentTab == 0)
{
const int waveA = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (gv (ids::oscAWave) * (kNumWavetables - 1))); 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))); const int waveB = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (gv (ids::oscBWave) * (kNumWavetables - 1)));
oscAWave.setWaveIndex (waveA); oscAWave.setWaveIndex (waveA);
@@ -804,62 +850,98 @@ void PluginEditor::updateVisuals()
oscBWave.setWaveIndex (waveB); oscBWave.setWaveIndex (waveB);
oscBWave.setFramePosition (gv (ids::oscBWtPos)); oscBWave.setFramePosition (gv (ids::oscBWtPos));
oscBWave.setEnabled (gv (ids::oscBOn) > 0.5f); oscBWave.setEnabled (gv (ids::oscBOn) > 0.5f);
oscAWave.repaint(); }
oscBWave.repaint();
// Filters. // Filters.
if (currentTab == 1)
{
filter1Display.setParams ((int) std::llround (gv (ids::f1Type) * 6.0f), gv (ids::f1Cutoff), gv (ids::f1Res), 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)); gv (ids::f1Drive), (int) std::llround (gv (ids::f1Slope) * 2.0f));
filter1Display.setEnabled (gv (ids::f1On) > 0.5f); filter1Display.setEnabled (gv (ids::f1On) > 0.5f);
filter2Display.setParams ((int) std::llround (gv (ids::f2Type) * 6.0f), gv (ids::f2Cutoff), gv (ids::f2Res), 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)); gv (ids::f2Drive), (int) std::llround (gv (ids::f2Slope) * 2.0f));
filter2Display.setEnabled (gv (ids::f2On) > 0.5f); filter2Display.setEnabled (gv (ids::f2On) > 0.5f);
filter1Display.repaint(); }
filter2Display.repaint();
// Envelopes. // Envelopes.
const char* a[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A }; if (currentTab == 2)
const char* d[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
const char* s[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
const char* r[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
const char* c[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
for (int i = 0; i < kNumEnvelopes; ++i)
{ {
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])); envDisplays[(size_t) i].setParams (gv (a[i]), gv (d[i]), gv (s[i]), gv (r[i]), gv (c[i]));
envDisplays[(size_t) i].repaint();
} }
// LFOs. // LFOs.
const char* lshape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape }; if (currentTab == 2)
{
const auto& lshape = paramIds::lfoShape;
const juce::ScopedLock lock (processor.engine.getControlLock());
for (int i = 0; i < kNumLfos; ++i) 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].setShape ((int) std::llround (gv (lshape[i]) * 6.0f));
lfoDisplays[(size_t) i].setShapeData (processor.engine.getLfos()[(size_t) i].getShapeData(), lfoDisplays[(size_t) i].setShapeData (lfo.getShapeData(), lfo.getShapeSteps());
processor.engine.getLfos()[(size_t) i].getShapeSteps()); }
lfoDisplays[(size_t) i].repaint(); }
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. // RAVE state.
raveButton.setToggleState (processor.isRaveEnabled()); raveButton.setToggleState (processor.isRaveEnabled());
const int program = processor.getCurrentProgram();
if (presetCombo.getSelectedItemIndex() != program)
presetCombo.setSelectedItemIndex (program, juce::dontSendNotification);
// Scale change. // Scale change.
if (processor.getUiScaleIndex() != currentScaleIndex) if (processor.getUiScaleIndex() != currentScaleIndex)
applyUiScale(); applyUiScale();
if (scaleCombo.getSelectedItemIndex() != currentScaleIndex)
scaleCombo.setSelectedItemIndex (currentScaleIndex, juce::dontSendNotification);
} }
void PluginEditor::updateModList() void PluginEditor::updateModList()
{ {
juce::String text; juce::String text;
{
const juce::ScopedLock lock (processor.engine.getControlLock());
const auto& cons = processor.engine.getMatrix().connections; const auto& cons = processor.engine.getMatrix().connections;
for (const auto& c : cons) for (const auto& c : cons)
text += modSourceName (c.source) + " -> " + modTargetName (c.target) text += modSourceName (c.source) + " -> " + modTargetName (c.target)
+ " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n"; + " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n";
}
if (modList.getText() != text)
modList.setText (text, false); modList.setText (text, false);
} }
void PluginEditor::updateMacroList() void PluginEditor::updateMacroList()
{ {
juce::String text; juce::String text;
{
const juce::ScopedLock lock (processor.engine.getControlLock());
for (int m = 0; m < kNumMacros; ++m) for (int m = 0; m < kNumMacros; ++m)
{ {
text += MacroControls::macroName (m) + ":\n"; text += MacroControls::macroName (m) + ":\n";
@@ -869,13 +951,17 @@ void PluginEditor::updateMacroList()
for (const auto& a : assigns) for (const auto& a : assigns)
text += " " + modTargetName (a.target) + " [" + juce::String (a.depth, 2) + "]\n"; text += " " + modTargetName (a.target) + " [" + juce::String (a.depth, 2) + "]\n";
} }
}
if (macroList.getText() != text)
macroList.setText (text, false); macroList.setText (text, false);
} }
void PluginEditor::timerCallback() void PluginEditor::timerCallback()
{ {
updateVisuals(); updateVisuals();
if (currentTab == 2)
updateModList(); updateModList();
else if (currentTab == 4)
updateMacroList(); updateMacroList();
} }
+2
View File
@@ -99,6 +99,8 @@ private:
juce::TextButton macroAssignButton, macroClearButton; juce::TextButton macroAssignButton, macroClearButton;
juce::TextEditor macroList; juce::TextEditor macroList;
std::vector<std::unique_ptr<juce::Component>> ownedControls;
// --- attachments --- // --- attachments ---
std::vector<std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment>> sliderAttachments; std::vector<std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment>> sliderAttachments;
std::vector<std::unique_ptr<juce::AudioProcessorValueTreeState::ComboBoxAttachment>> comboAttachments; std::vector<std::unique_ptr<juce::AudioProcessorValueTreeState::ComboBoxAttachment>> comboAttachments;
+106 -34
View File
@@ -176,7 +176,7 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void SerumAltAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) void SerumAltAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{ {
engine.prepare (sampleRate, samplesPerBlock); engine.prepare (sampleRate, samplesPerBlock, parameters);
} }
void SerumAltAudioProcessor::releaseResources() void SerumAltAudioProcessor::releaseResources()
@@ -205,14 +205,13 @@ int SerumAltAudioProcessor::getNumPrograms()
int SerumAltAudioProcessor::getCurrentProgram() int SerumAltAudioProcessor::getCurrentProgram()
{ {
return currentProgram; return currentProgram.load();
} }
void SerumAltAudioProcessor::setCurrentProgram (int index) void SerumAltAudioProcessor::setCurrentProgram (int index)
{ {
index = juce::jlimit (0, getNumPrograms() - 1, index); if (getNumPrograms() > 0)
loadFactoryPreset (index); loadFactoryPreset (juce::jlimit (0, getNumPrograms() - 1, index));
currentProgram = index;
} }
const juce::String SerumAltAudioProcessor::getProgramName (int index) const juce::String SerumAltAudioProcessor::getProgramName (int index)
@@ -238,24 +237,36 @@ void SerumAltAudioProcessor::loadFactoryPreset (int index)
if (index < 0 || index >= (int) presets.size()) if (index < 0 || index >= (int) presets.size())
return; return;
const juce::ScopedLock lock (engine.getControlLock());
const FactoryPreset& preset = presets[(size_t) index]; const FactoryPreset& preset = presets[(size_t) index];
const auto* uiScaleParam = parameters.getParameter (ids::uiScale);
// RAVE should start off for a freshly loaded preset. for (auto* param : getParameters())
rave.resetSnapshot(); if (param != nullptr && param != uiScaleParam)
if (auto* raveParam = parameters.getParameter (ids::rave)) param->setValueNotifyingHost (param->getDefaultValue());
raveParam->setValueNotifyingHost (0.0f);
for (const auto& kv : preset.params) for (const auto& kv : preset.params)
if (auto* param = parameters.getParameter (kv.first)) if (auto* param = parameters.getParameter (kv.first))
param->setValueNotifyingHost (kv.second); if (param != uiScaleParam && std::isfinite (kv.second))
param->setValueNotifyingHost (juce::jlimit (0.0f, 1.0f, kv.second));
engine.getMatrix().clear(); // 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) for (const auto& mod : preset.mods)
engine.getMatrix().addConnection (mod.source, mod.target, mod.depth, mod.bipolar); if (! matrix.addConnection (mod.source, mod.target, mod.depth, mod.bipolar))
continue;
engine.getMacros().clear(); auto& macros = engine.getMacros();
macros.clear();
for (const auto& ma : preset.macroAssigns) for (const auto& ma : preset.macroAssigns)
engine.getMacros().addAssignment (ma.macro, ma.target, ma.depth); if (! macros.addAssignment (ma.macro, ma.target, ma.depth))
continue;
restoreLfoShapesFromState ({});
currentProgram.store (index);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -263,25 +274,36 @@ void SerumAltAudioProcessor::loadFactoryPreset (int index)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void SerumAltAudioProcessor::setRaveEnabled (bool enabled) void SerumAltAudioProcessor::setRaveEnabled (bool enabled)
{ {
rave.setEnabled (enabled, parameters); const juce::ScopedLock lock (engine.getControlLock());
if (auto* raveParam = parameters.getParameter (ids::rave)) if (auto* raveParam = parameters.getParameter (ids::rave))
{
raveParam->beginChangeGesture();
raveParam->setValueNotifyingHost (enabled ? 1.0f : 0.0f); raveParam->setValueNotifyingHost (enabled ? 1.0f : 0.0f);
raveParam->endChangeGesture();
}
} }
bool SerumAltAudioProcessor::isRaveEnabled() const bool SerumAltAudioProcessor::isRaveEnabled() const
{ {
return rave.isEnabled(); if (auto* raveParam = parameters.getRawParameterValue (ids::rave))
return raveParam->load() > 0.5f;
return false;
} }
int SerumAltAudioProcessor::getUiScaleIndex() const int SerumAltAudioProcessor::getUiScaleIndex() const
{ {
if (auto* p = parameters.getRawParameterValue (ids::uiScale)) if (auto* p = parameters.getRawParameterValue (ids::uiScale))
return juce::jlimit (0, 4, (int) std::llround (p->load() * 4.0f)); {
const float value = p->load();
if (std::isfinite (value))
return (int) std::llround (juce::jlimit (0.0f, 1.0f, value) * 4.0f);
}
return 1; return 1;
} }
void SerumAltAudioProcessor::setUiScaleIndex (int index) void SerumAltAudioProcessor::setUiScaleIndex (int index)
{ {
const juce::ScopedLock lock (engine.getControlLock());
index = juce::jlimit (0, 4, index); index = juce::jlimit (0, 4, index);
if (auto* p = parameters.getParameter (ids::uiScale)) if (auto* p = parameters.getParameter (ids::uiScale))
p->setValueNotifyingHost ((float) index / 4.0f); p->setValueNotifyingHost ((float) index / 4.0f);
@@ -298,7 +320,15 @@ float SerumAltAudioProcessor::getUiScale() const
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void SerumAltAudioProcessor::getStateInformation (juce::MemoryBlock& destData) void SerumAltAudioProcessor::getStateInformation (juce::MemoryBlock& destData)
{ {
const juce::ScopedLock lock (engine.getControlLock());
auto state = parameters.copyState(); 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.getMatrix().toValueTree(), nullptr);
state.appendChild (engine.getMacros().toValueTree(), nullptr); state.appendChild (engine.getMacros().toValueTree(), nullptr);
saveLfoShapesToState (state); saveLfoShapesToState (state);
@@ -310,39 +340,47 @@ void SerumAltAudioProcessor::getStateInformation (juce::MemoryBlock& destData)
void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBytes) void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBytes)
{ {
if (data == nullptr || sizeInBytes <= 0)
return;
std::unique_ptr<juce::XmlElement> xml (getXmlFromBinary (data, sizeInBytes)); std::unique_ptr<juce::XmlElement> xml (getXmlFromBinary (data, sizeInBytes));
if (xml == nullptr) if (xml == nullptr || ! xml->hasTagName ("SerumAlt"))
return; return;
juce::ValueTree state = juce::ValueTree::fromXml (*xml); juce::ValueTree state = juce::ValueTree::fromXml (*xml);
if (! state.isValid()) if (! state.hasType ("SerumAlt"))
return; return;
const juce::ScopedLock lock (engine.getControlLock());
// A persisted RAVE toggle is rendered non-destructively, so retain it.
parameters.replaceState (state); parameters.replaceState (state);
engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX")); engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX"));
engine.getMacros().fromValueTree (state.getChildWithName ("MACROS")); engine.getMacros().fromValueTree (state.getChildWithName ("MACROS"));
restoreLfoShapesFromState (state); restoreLfoShapesFromState (state);
// A persisted RAVE toggle has no live snapshot, so start it off. const double savedProgram = (double) state.getProperty ("currentProgram", 0);
rave.resetSnapshot(); currentProgram.store (std::isfinite (savedProgram)
if (auto* raveParam = parameters.getParameter (ids::rave)) ? (int) juce::jlimit (0.0, (double) juce::jmax (0, getNumPrograms() - 1), savedProgram) : 0);
raveParam->setValueNotifyingHost (0.0f);
} }
void SerumAltAudioProcessor::saveLfoShapesToState (juce::ValueTree& state) const void SerumAltAudioProcessor::saveLfoShapesToState (juce::ValueTree& state) const
{ {
const juce::ScopedLock lock (engine.getControlLock());
juce::ValueTree tree ("LFOSHAPES"); juce::ValueTree tree ("LFOSHAPES");
for (int i = 0; i < kNumLfos; ++i) for (int i = 0; i < kNumLfos; ++i)
{ {
const auto& data = engine.getLfos()[(size_t) i].getShapeData(); const auto& source = engine.getLfos()[(size_t) i];
const auto& data = source.getShapeData();
juce::ValueTree lfo ("LFO"); juce::ValueTree lfo ("LFO");
lfo.setProperty ("index", i, nullptr); lfo.setProperty ("index", i, nullptr);
lfo.setProperty ("steps", engine.getLfos()[(size_t) i].getShapeSteps(), nullptr); lfo.setProperty ("steps", juce::jlimit (2, LFO::kShapePoints, source.getShapeSteps()), nullptr);
juce::Array<juce::var> arr; juce::Array<juce::var> arr;
for (float vv : data) for (int point = 0; point < juce::jmin (LFO::kShapePoints, (int) data.size()); ++point)
arr.add (vv); {
lfo.setProperty ("data", juce::var (arr), nullptr); 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); tree.appendChild (lfo, nullptr);
} }
state.appendChild (tree, nullptr); state.appendChild (tree, nullptr);
@@ -350,23 +388,57 @@ void SerumAltAudioProcessor::saveLfoShapesToState (juce::ValueTree& state) const
void SerumAltAudioProcessor::restoreLfoShapesFromState (const juce::ValueTree& state) 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"); const juce::ValueTree tree = state.getChildWithName ("LFOSHAPES");
if (! tree.isValid()) if (! tree.isValid())
return; return;
std::array<bool, kNumLfos> restored {};
for (const auto& lfo : tree) for (const auto& lfo : tree)
{ {
if (! lfo.hasType ("LFO")) if (! lfo.hasType ("LFO"))
continue; continue;
const int index = juce::jlimit (0, kNumLfos - 1, (int) lfo.getProperty ("index", 0)); const double savedIndex = (double) lfo.getProperty ("index", -1);
const int steps = (int) lfo.getProperty ("steps", 16); 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; std::vector<float> data;
if (auto* arr = lfo.getProperty ("data").getArray()) data.reserve ((size_t) numPoints);
for (const auto& vv : *arr) for (int point = 0; point < numPoints; ++point)
data.push_back ((float) vv); {
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); engine.setLfoShapeData (index, data, steps);
restored[(size_t) index] = true;
} }
} }
+4 -5
View File
@@ -1,16 +1,16 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include <atomic>
#include "Params.h" #include "Params.h"
#include "Engine.h" #include "Engine.h"
#include "RAVEButton.h"
namespace serum namespace serum
{ {
// =========================================================================== // ===========================================================================
// SerumAlt — a wavetable synthesiser (VST3 / AU). Hosts the APVTS, the audio // SerumAlt — a wavetable synthesiser (VST3 / AU). Hosts the APVTS, the audio
// engine, preset management and the RAVE controller. // engine, preset management and the RAVE toggle.
// =========================================================================== // ===========================================================================
class SerumAltAudioProcessor : public juce::AudioProcessor class SerumAltAudioProcessor : public juce::AudioProcessor
{ {
@@ -52,15 +52,14 @@ public:
void loadFactoryPreset (int index); void loadFactoryPreset (int index);
int getNumFactoryPresets() const; int getNumFactoryPresets() const;
// Public DSP state (read/write from the GUI thread). // Public control state (hold the engine control lock for matrix/macros/LFOs).
juce::AudioProcessorValueTreeState parameters; juce::AudioProcessorValueTreeState parameters;
Engine engine; Engine engine;
RaveController rave;
static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout();
private: private:
int currentProgram = 0; std::atomic<int> currentProgram { 0 };
void restoreLfoShapesFromState (const juce::ValueTree& state); void restoreLfoShapesFromState (const juce::ValueTree& state);
void saveLfoShapesToState (juce::ValueTree& state) const; void saveLfoShapesToState (juce::ValueTree& state) const;
+22 -75
View File
@@ -1,90 +1,37 @@
#include "RAVEButton.h" #include "RAVEButton.h"
#include "SynthVoice.h"
#include "FXProcessor.h"
namespace serum namespace serum
{ {
void RaveController::resetSnapshot() void RaveController::apply (RenderContext& context, FxSlotParams* slots, int numSlots) noexcept
{ {
snapshot.clear(); // Boost the static rendering parameters.
enabled = false; 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;
void RaveController::snapshotParam (const juce::String& id, juce::AudioProcessorValueTreeState& apvts) // Apply drive boosts.
{ context.filters.f1Drive = 0.6f;
if (auto* p = apvts.getParameter (id)) context.filters.f2Drive = 0.6f;
snapshot.emplace_back (id, p->getValue());
}
void RaveController::setParam (const juce::String& id, float value, juce::AudioProcessorValueTreeState& apvts) // Boost FX-specific params (Hyper intensity / Reverb mix).
{ if (slots == nullptr)
if (auto* p = apvts.getParameter (id))
p->setValueNotifyingHost (juce::jlimit (0.0f, 1.0f, value));
}
void RaveController::setEnabled (bool shouldEnable, juce::AudioProcessorValueTreeState& apvts)
{
if (shouldEnable == enabled)
return; return;
for (int i = 0; i < juce::jlimit (0, kNumFxSlots, numSlots); ++i)
if (shouldEnable)
{ {
snapshot.clear(); auto& slot = slots[i];
if (slot.type == (int) FxType::Hyper)
// Snapshot the static "boost" parameters. slot.p[0] = 1.0f; // OTT intensity 100%
const char* staticParams[] = else if (slot.type == (int) FxType::Reverb)
{ {
ids::oscAUnison, ids::oscBUnison, const float mix = std::isfinite (slot.mix) ? slot.mix : 0.0f;
ids::oscASpread, ids::oscBSpread, slot.mix = juce::jlimit (0.0f, 1.0f, mix + 0.4f); // reverb send +6dB-ish
ids::oscADetune, ids::oscBDetune,
ids::f1Drive, ids::f2Drive
};
for (auto id : staticParams)
snapshotParam (id, apvts);
// Snapshot + boost FX-specific params (Hyper intensity / Reverb mix).
const char* fxMixIds[kNumFxSlots] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
const char* fxP1Ids[kNumFxSlots] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1,
ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 };
const char* fxTypeIds[kNumFxSlots] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
for (int i = 0; i < kNumFxSlots; ++i)
{
const auto* typeParam = apvts.getParameter (fxTypeIds[i]);
const int type = typeParam ? (int) (typeParam->getValue() * (int) FxType::Count) : 0;
if (type == (int) FxType::Hyper)
{
snapshotParam (fxP1Ids[i], apvts);
setParam (fxP1Ids[i], 1.0f, apvts); // OTT intensity 100%
} }
else if (type == (int) FxType::Reverb)
{
snapshotParam (fxMixIds[i], apvts);
const float current = apvts.getParameter (fxMixIds[i])->getValue();
setParam (fxMixIds[i], current + 0.4f, apvts); // reverb send +6dB-ish
}
}
// Apply boosts.
setParam (ids::oscAUnison, 8.0f / 16.0f, apvts);
setParam (ids::oscBUnison, 8.0f / 16.0f, apvts);
setParam (ids::oscASpread, 1.0f, apvts);
setParam (ids::oscBSpread, 1.0f, apvts);
setParam (ids::oscADetune, 1.0f, apvts);
setParam (ids::oscBDetune, 0.7f, apvts);
setParam (ids::f1Drive, 0.6f, apvts);
setParam (ids::f2Drive, 0.6f, apvts);
enabled = true;
}
else
{
for (const auto& entry : snapshot)
setParam (entry.first, entry.second, apvts);
snapshot.clear();
enabled = false;
} }
} }
+7 -14
View File
@@ -1,29 +1,22 @@
#pragma once #pragma once
#include <JuceHeader.h> #include <JuceHeader.h>
#include "Params.h"
namespace serum namespace serum
{ {
struct RenderContext;
struct FxSlotParams;
// =========================================================================== // ===========================================================================
// RAVE — one-shot "make it huge" control. Toggling on snapshots the current // RAVE — non-destructive "make it huge" control. While enabled, rendering
// values of the affected parameters and pushes unison, width, drive, OTT and // boosts unison, width, drive, OTT and reverb in the current block's snapshots;
// reverb to their boosted settings; toggling off restores the snapshot. // the underlying parameters remain unchanged when toggling on or off.
// =========================================================================== // ===========================================================================
class RaveController class RaveController
{ {
public: public:
void setEnabled (bool enabled, juce::AudioProcessorValueTreeState& apvts); static void apply (RenderContext& context, FxSlotParams* slots, int numSlots) noexcept;
bool isEnabled() const noexcept { return enabled; }
void resetSnapshot();
private:
bool enabled = false;
std::vector<std::pair<juce::String, float>> snapshot;
void snapshotParam (const juce::String& id, juce::AudioProcessorValueTreeState& apvts);
void setParam (const juce::String& id, float value, juce::AudioProcessorValueTreeState& apvts);
}; };
} // namespace serum } // namespace serum
+21 -8
View File
@@ -35,7 +35,8 @@ void SynthVoice::reset()
velocity = 0.0f; velocity = 0.0f;
baseFreq = 0.0; baseFreq = 0.0;
active = released = false; active = released = false;
lastUnisonA = lastUnisonB = 1; noteId = 0;
oscillatorsNeedNoteOn = false;
noteRandom = 0.5f; noteRandom = 0.5f;
scratch.clear(); scratch.clear();
} }
@@ -48,7 +49,8 @@ void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce::
active = true; active = true;
released = false; released = false;
seed = noteSeed; seed = noteSeed;
lastUnisonA = lastUnisonB = 0; // force unison reconfigure on first render noteId = noteSeed;
oscillatorsNeedNoteOn = true; // initialise fresh oscillator phases on first render
juce::Random rng (noteSeed); juce::Random rng (noteSeed);
noteRandom = rng.nextFloat(); noteRandom = rng.nextFloat();
@@ -151,10 +153,19 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC
const double freqA = bent * std::pow (2.0, (double) a.coarse / 12.0 + (double) a.fine / 1200.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); const double freqB = bent * std::pow (2.0, (double) b.coarse / 12.0 + (double) b.fine / 1200.0);
// Reconfigure unison only when the integer count changes (avoids phase reset // Initialise phases only for a fresh note; update held-note unison parameters
// on every block when unison is LFO-modulated at control rate). // without resetting existing phases when modulated at control rate.
if (a.unison != lastUnisonA) { oscA.noteOn (freqA, a, seed); lastUnisonA = a.unison; } if (oscillatorsNeedNoteOn)
if (b.unison != lastUnisonB) { oscB.noteOn (freqB, b, seed + 1); lastUnisonB = b.unison; } {
oscA.noteOn (freqA, a, seed);
oscB.noteOn (freqB, b, seed + 1);
oscillatorsNeedNoteOn = false;
}
else
{
oscA.setParams (a);
oscB.setParams (b);
}
// 7. Modulated filter parameters. // 7. Modulated filter parameters.
FilterBankParams fb = ctx.filters; FilterBankParams fb = ctx.filters;
@@ -177,6 +188,8 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC
const Wavetable& wtA = ctx.wavetables->getTable (a.wave); const Wavetable& wtA = ctx.wavetables->getTable (a.wave);
const Wavetable& wtB = ctx.wavetables->getTable (b.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 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) for (int i = 0; i < numSamples; ++i)
{ {
@@ -186,9 +199,9 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC
float mono = 0.0f; float mono = 0.0f;
if (ctx.subOn) if (ctx.subOn)
sub.processAdd (bent * subMult, ctx.subShape, ctx.subLevel, mono); sub.processAdd (bent * subMult, ctx.subShape, subLevel, mono);
if (ctx.noiseOn) if (ctx.noiseOn)
noise.processAdd (ctx.noiseType, ctx.noiseLevel, mono); noise.processAdd (ctx.noiseType, noiseLevel, mono);
l += mono; l += mono;
r += mono; r += mono;
+1 -1
View File
@@ -87,7 +87,7 @@ private:
bool released = false; bool released = false;
juce::uint64 noteId = 0; juce::uint64 noteId = 0;
int lastUnisonA = 1, lastUnisonB = 1; bool oscillatorsNeedNoteOn = false;
juce::uint32 seed = 0; juce::uint32 seed = 0;
float noteRandom = 0.5f; float noteRandom = 0.5f;
+12
View File
@@ -0,0 +1,12 @@
{
"mcp": {
"graft": {
"type": "local",
"command": [
"graft",
"mcp"
],
"enabled": true
}
}
}