diff --git a/.claude/helpers/graft-hooks.cjs b/.claude/helpers/graft-hooks.cjs index 4a04dc1..aa307ff 100755 --- a/.claude/helpers/graft-hooks.cjs +++ b/.claude/helpers/graft-hooks.cjs @@ -64,4 +64,4 @@ function entry(name) { return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent } -import(pathToFileURL(entry("hooks.js")).href).then((m) => m.main(process.argv[2])).catch(() => { /* graft unavailable — no-op */ }); +import(pathToFileURL(entry("hooks.js")).href).then((m) => m.main(process.argv[2])).catch(() => { /* graft unavailable, no-op */ }); diff --git a/.claude/helpers/graft-statusline.cjs b/.claude/helpers/graft-statusline.cjs index b596658..ee32aea 100755 --- a/.claude/helpers/graft-statusline.cjs +++ b/.claude/helpers/graft-statusline.cjs @@ -64,4 +64,4 @@ function entry(name) { return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent } -import(pathToFileURL(entry("statusline.js")).href).then((m) => m.main()).catch(() => { /* graft unavailable — no-op */ }); +import(pathToFileURL(entry("statusline.js")).href).then((m) => m.main()).catch(() => { /* graft unavailable, no-op */ }); diff --git a/.claude/skills/graft/SKILL.md b/.claude/skills/graft/SKILL.md index 4302ec8..4599ec9 100644 --- a/.claude/skills/graft/SKILL.md +++ b/.claude/skills/graft/SKILL.md @@ -14,34 +14,35 @@ graph of who-calls-what. Querying a node costs a few hundred tokens; rebuilding that understanding by reading source costs thousands, and misses the edges. Every command below is `$0`, needs no API key, and returns in under a second. -There are six of them. **Pick the one that fits the task, run it, act on the -answer; don't chain tools hoping for more. Most tasks need one call.** +There are six of them. Pick the one that fits the task, run it, and act on the +answer. Do not chain tools hoping for more. Most tasks need one call. ## The tools ### 1 · `graft ask "" --source`: locate + understand (the default) Ranked retrieval over the graph, routed automatically between prose nodes and the wiring graph, returning the top hits with exact `file:line`. -- `--source` inlines the code at each hit, the ≤8-line **crux** of each +- `--source` inlines the code at each hit, the ≤8-line crux of each definition, so the result IS the code you need, no follow-up file read. Add `--full` only when the crux is too small to act on. - `--in ` narrows to a subtree before ranking; `-n N` caps results (default 8). -- **Use it when** the question is conceptual or locational: "how does auth - work", "where is rate-limiting handled", "what assembles the request pipeline". +- Use it when the question is conceptual or locational, for example "how does + auth work", "where is rate-limiting handled", "what assembles the request + pipeline". - One ask usually answers. A genuinely multi-part question needs one ask per distinct sub-aspect, never the same question reworded. Few or weak hits mean switch tool (grep / skeleton / callers), don't re-ask. ### 2 · `graft grep ""`: exhaustive find -Regex (or `--fixed` for a literal) over every indexed file, hits **grouped by -enclosing symbol** and ranked by coupling; it also reports files it couldn't read. -- **Use it when** you need every occurrence: all call sites, all uses of a - constant, all providers. `ask` is ranked top-N and *will* miss instances; - grep won't. One grep replaces a spray of asks. -- Search a **short symbol name or literal**, not a full guessed signature: an +Regex (or `--fixed` for a literal) over every indexed file, hits grouped by +enclosing symbol and ranked by coupling; it also reports files it could not read. +- Use it when you need every occurrence: all call sites, all uses of a + constant, all providers. `ask` is ranked top-N and can miss instances; + grep does not. One grep replaces a spray of asks. +- Search a short symbol name or literal, not a full guessed signature: an over-specific regex (`func (s *Server) GenerateHandler`) returns nothing even - when the code is indexed. If a grep misses, **loosen it** (drop the receiver - and signature, keep the bare name) and retry `graft grep` — do NOT switch to + when the code is indexed. If a grep misses, loosen it (drop the receiver + and signature, keep the bare name) and retry `graft grep`. Do not switch to raw `grep -rn`, which is slower and unranked. - `-i` case-insensitive; `--in ` scopes to a subtree. Raw `grep -rn` is only for files graft genuinely doesn't index (docs, configs, brand-new files). @@ -49,38 +50,38 @@ enclosing symbol** and ranked by coupling; it also reports files it couldn't rea ### 3 · `graft skeleton `: a file's API at a glance Signatures-only view of one file (every function / method / type with its span) in ~200 tokens, ~10x cheaper than reading the file. -- **Use it when** you need "what's in this file / what can I call here" before - editing or wiring into it. One skeleton is the whole answer for a file; don't - re-skeleton the same file, and don't skeleton every file `map` already named. +- Use it when you need "what's in this file / what can I call here" before + editing or wiring into it. One skeleton is the whole answer for a file; do not + re-skeleton the same file, and do not skeleton every file `map` already named. ### 4 · `graft callers `: the exact edges Precomputed call/reference edges, not a text search. Symbol can be bare (`Foo`), qualified (`Class.method`), or package-qualified (`pkg.Fn`). -- default `--direction in`: **who calls/references** this; run before you +- default `--direction in`: who calls or references this; run before you rename, delete, or change its signature. -- `--direction out`: **what this symbol itself calls/depends on** (the old `callees`). -- `--depth N`: walk transitively N hops for the **full blast radius** (the old +- `--direction out`: what this symbol itself calls or depends on (the old `callees`). +- `--depth N`: walk transitively N hops for the full blast radius (the old `impact`); `--depth 2` is the usual "what breaks if I touch this". -- `--depth all`: the **entire connected closure** — every source reachable - through the edges. Reach for this before a **refactor, rename, or any - multi-file change**: it surfaces the sibling and downstream files (platform +- `--depth all`: the entire connected closure, meaning every source reachable + through the edges. Reach for this before a refactor, rename, or any + multi-file change: it surfaces the sibling and downstream files (platform variants, a module you must split out) that a single-file edit would miss. ### 5 · `graft map`: orientation for an unfamiliar repo or area A token-budgeted tour: directory clusters, per-directory hubs, and global hotspots, straight from the wiring graph. -- **Use it when** you land in a repo cold or are asked for "the architecture". - `map` alone is the answer: read the hub cards it names; do NOT then skeleton +- Use it when you land in a repo cold or are asked for "the architecture". + `map` alone is the answer: read the hub cards it names; do not then skeleton or ask your way through every subsystem it lists. `--max-dirs N` widens it. ### 6 · Lifecycle: `graft build` / `graft check` Every tool above refreshes the graph itself before answering, so what those tools -return always describes the code as it is right now — including edits you just made -and have not committed. You do **not** need to run `build` after editing. +return always describes the code as it is right now, including edits you just made +and have not committed. You do not need to run `build` after editing. -One caveat, if you `grep` the markdown under `graft/` directly: those cards are a +One caveat: if you `grep` the markdown under `graft/` directly, those cards are a projection, rebuilt at the end of the turn rather than on each query, so after an edit -they can lag. The tools above never do — prefer them, and treat a card's spans as +they can lag. The tools above never do. Prefer them, and treat a card's spans as stale if you have edited that file this turn. `build` is for the LLM layer (`--deep` adds a concept map; skip unless asked); @@ -93,9 +94,9 @@ stale if you have edited that file this turn. | Onboarding / "explain this codebase" | `graft map`, then read the named hub cards | 1 | | Understanding a flow ("how does X work") | `graft ask "" --source` | 1 | | Finding where a change belongs | `graft ask "where is " --source` | 1 | -| Editing a symbol you can already name | `graft grep ""`, edit at the `file:line` (skip `ask` — you know where it is) | 1 | +| Editing a symbol you can already name | `graft grep ""`, edit at the `file:line` (skip `ask`, you know where it is) | 1 | | Renaming / deleting / changing a signature | `graft callers --depth 2` first | 1 | -| Refactor / multi-file change (before editing) | `graft callers --depth all` — map every connected file, don't stop at the first | 1 | +| Refactor / multi-file change (before editing) | `graft callers --depth all` to map every connected file, do not stop at the first | 1 | | "What does this depend on?" | `graft callers --direction out` | 1 | | Finding every occurrence of a pattern | `graft grep ""` | 1 | | "What's the API of this file?" | `graft skeleton ` | 1 | @@ -113,25 +114,26 @@ you already know where you're working, narrow with `graft ask "" --in "` for the exact `file:line`, then edit. Reserve - `graft ask` for when you don't yet know where the code lives — an `ask` - round-trip is wasted on a target you can already name. + `graft ask` for when you do not yet know where the code lives, because an + `ask` round-trip is wasted on a target you can already name. - Trust the answer and act. Reach for a second tool only when the first genuinely fell short: weak hits, a truncated span, or a need to be exhaustive. - If graft names a path that isn't on disk, its index is ahead of your checkout - (a branch switch or unpulled move). Don't read the missing file — `graft grep` - the symbol to find where it lives now, or run `graft build` to refresh. + (a branch switch or unpulled move). Do not read the missing file. Instead + `graft grep` the symbol to find where it lives now, or run `graft build` to + refresh. ## Report what graft saved, every turn -Each retrieval tool **opens** its output with a `[graft] tokens saved ≈ N` line: +Each retrieval tool opens its output with a `[graft] tokens saved ≈ N` line: the estimated tokens that call saved versus reading the files it covers whole. Whenever you used any graft tool in a turn, close your reply with a one-line -tally summing those numbers across every graft call you made, e.g. -`🌱 graft saved ~12,400 tokens this turn (3 calls)`. A call with no such line +tally summing those numbers across every graft call you made, for example +`graft saved ~12,400 tokens this turn (3 calls)`. A call with no such line (tiny files, where the pointers cost as much as the source) saved nothing, so skip it. This is the per-turn figure; the statusline carries the running session total. -**Never pipe a graft command through `head`, `tail`, or `sed -n`.** Every tool +Never pipe a graft command through `head`, `tail`, or `sed -n`. Every tool is already capped and states what it dropped; clipping it costs you hits you asked for, and it silently drops the savings line the statusline's running total is parsed from. diff --git a/AGENT.md b/AGENT.md deleted file mode 100644 index ce89950..0000000 --- a/AGENT.md +++ /dev/null @@ -1,83 +0,0 @@ -# SerumAlt agent guidance - -## Project - -SerumAlt is a JUCE 7 wavetable synthesiser using C++17. Project metadata is in -`CMakeLists.txt`. Production formats are VST3 and Standalone on Linux, VST3/AU/Standalone -on macOS, and VST3 on Windows. First-party classes use `namespace serum`; executable -entry points and JUCE's `createPluginFilter` are outside that namespace. - -## Build and verification - -Read README.md's Building section before changing scripts, dependencies, or CMake. -It is the maintained source for prerequisites, options and artifact paths. - -- Linux production: `./build_linux.sh`. -- macOS production, on a Mac: `./build_macos.sh`. -- Windows x64 VST3, cross-compiled on Linux: `./build_windows.sh`. -- Native QA build and execution: `./build_harness.sh --run`. -- Shell syntax: `for script in build_*.sh; do bash -n "$script"; done`. - -Production scripts explicitly enable `SERUMALT_BUILD_PLUGIN` and disable -`SERUMALT_BUILD_TESTS`. The harness script does the reverse in a separate directory. -Building `SerumAltTest` alone does not execute it; `--run` invokes CTest. -When changing CMake, verify production has no `SerumAltTest` target and the -harness-only build has no `SerumAlt`, VST3, AU, or Standalone target. - -Keep existing build trees and local changes intact. Use a new `BUILD_DIR` for -clean-build verification. Use a fresh Windows `OUTPUT_DIR` for distribution checks, -since staging does not remove extra files. Never edit generated CMakeCache.txt files. -The checked-in MinGW toolchain is the single source of cross-compiler configuration. - -## Current source layout - -`CMakeLists.txt` owns the explicit `SERUMALT_SOURCES` list and shared target setup. -`cmake/Plugin.cmake` owns production formats and MinGW link flags; -`cmake/Harness.cmake` owns the console harness and CTest registration. -The harness compiles the same processor/editor sources independently of the plugin. - -Most C++ files still live directly under `Source/`. Existing implementation -subdirectories are `EffectUnits/`, `GUI/`, `Presets/`, `Resources/`, and `Tests/`. -`FXProcessor`, `PluginEditor`, `Resources`, and `RAVEButton` remain at the source root. -`RAVEButton.{h,cpp}` defines `RaveController`. - -The lowercase `Source/engine`, `modulation`, `params`, `plugin`, and `synth` -directories currently hold guidance, not relocated implementations. Consult their -AGENT.md files when editing the related root-level sources. Consult the matching -AGENT.md when editing an existing implementation subdirectory. -`AUDIT_REPORT.md` is a historical audit and proposed refactor, not a pending -instruction to move files. Update the explicit source list and includes only when -an actual source move is part of the requested task. - -## Style and ownership - -Follow the surrounding file. Existing C++ generally uses four-space indentation, -`#pragma once`, PascalCase types, camelCase members without prefixes, `k`-prefixed -constants, scoped enums, and `noexcept` on cheap getters and DSP paths. Use `Count` -only for enums that need a size or iteration sentinel. Headers include JuceHeader; -implementation files normally include their own header first. - -Prefer value members or `std::unique_ptr` for owned objects and attachments. -`addAndMakeVisible` does not transfer ownership or delete children. Raw allocations -returned by `createEditor` and `createPluginFilter` transfer ownership to JUCE callers. -Guard parameter lookups and invalid state data. Use `jassert` sparingly for programmer -errors and return `bool` from bounded inserts to report failure. - -## Real-time and concurrency requirements - -For new or changed audio paths, allocate buffers during preparation and avoid heap -allocation, blocking locks and I/O in `processBlock` or per-sample DSP. Account for -variable host block sizes without allocating in the callback. Keep GUI updates on -the JUCE message thread. Exchange GUI/audio state through a safe snapshot or bounded -handoff; a timer or a writer-only mutex does not make concurrent access safe. - -These are requirements, not claims that the current code already satisfies them. -The historical audit records lazy wavetable allocation, per-block buffer resizing, -and unsynchronised modulation/LFO edits. When changing those paths, inspect the -current implementation and verify the fix separately. The QA smoke test does not -prove real-time safety or absence of data races. - -## Scope of guidance - -Module guidance refines this file for its related sources. Preserve the root -real-time and ownership requirements if an example conflicts with them. diff --git a/AGENTS.md b/AGENTS.md index d8af4a5..320092a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,3 +39,95 @@ re-read whole files. After big code changes, refresh the graph with `graft build` (deterministic, no API key, $0). + +# SerumAlt agent guidance + +## Project + +SerumAlt is a JUCE 7 wavetable synthesiser using C++17. Project metadata is in +`CMakeLists.txt`. Production formats are VST3 and Standalone on Linux, VST3/AU/Standalone +on macOS, and VST3 on Windows. First-party classes use `namespace serum`; executable +entry points and JUCE's `createPluginFilter` are outside that namespace. + +## Development scope + +This is a fun proof of concept. Prefer small working changes, clear names, and +successful compilation over release engineering or broad rewrites. Use the existing +smoke harness for DSP changes; add test infrastructure only when requested. The user +will judge sound and interaction by using the synth. Keep ownership and audio-thread +safety fixes practical, without adding a generic framework. + +## Build and verification + +Read README.md's Building section before changing scripts, dependencies, or CMake. +It is the maintained source for prerequisites, options and artifact paths. + +- Linux production: `./build_linux.sh`. +- macOS production, on a Mac: `./build_macos.sh`. +- Windows x64 VST3, cross-compiled on Linux: `./build_windows.sh`. +- Native QA build and execution: `./build_harness.sh --run`. +- Shell syntax: `for script in build_*.sh; do bash -n "$script"; done`. + +Production scripts explicitly enable `SERUMALT_BUILD_PLUGIN` and disable +`SERUMALT_BUILD_TESTS`. The harness script does the reverse in a separate directory. +Building `SerumAltTest` alone does not execute it; `--run` invokes CTest. +When changing CMake, verify production has no `SerumAltTest` target and the +harness-only build has no `SerumAlt`, VST3, AU, or Standalone target. + +Keep existing build trees and local changes intact. Use a new `BUILD_DIR` for +clean-build verification. Use a fresh Windows `OUTPUT_DIR` for distribution checks, +since staging does not remove extra files. Never edit generated CMakeCache.txt files. +The checked-in MinGW toolchain is the single source of cross-compiler configuration. + +## Current source layout + +`CMakeLists.txt` owns the explicit `SERUMALT_SOURCES` list and shared target setup. +`cmake/Plugin.cmake` owns production formats and MinGW link flags; +`cmake/Harness.cmake` owns the console harness and CTest registration. +The harness compiles the same processor/editor sources independently of the plugin. + +Most C++ files still live directly under `Source/`. Existing implementation +subdirectories are `EffectUnits/`, `GUI/`, `Presets/`, `Resources/`, and `Tests/`. +`FXProcessor`, `PluginEditor`, `Resources`, and `RAVEButton` remain at the source root. +`RAVEButton.{h,cpp}` defines `RaveController`. + +The lowercase `Source/engine`, `modulation`, `params`, `plugin`, and `synth` +directories currently hold guidance, not relocated implementations. Consult their +`AGENTS.md` files when editing the related root-level sources. Consult the matching +`AGENTS.md` when editing an existing implementation subdirectory. +`AUDIT_REPORT.md` is a historical audit and proposed refactor, not a pending +instruction to move files. Update the explicit source list and includes only when +an actual source move is part of the requested task. + +## Style and ownership + +Follow the surrounding file. Existing C++ generally uses four-space indentation, +`#pragma once`, PascalCase types, camelCase members without prefixes, `k`-prefixed +constants, scoped enums, and `noexcept` on cheap getters and DSP paths. Use `Count` +only for enums that need a size or iteration sentinel. Headers include JuceHeader; +implementation files normally include their own header first. + +Prefer value members or `std::unique_ptr` for owned objects and attachments. +`addAndMakeVisible` does not transfer ownership or delete children. Raw allocations +returned by `createEditor` and `createPluginFilter` transfer ownership to JUCE callers. +Guard parameter lookups and invalid state data. Use `jassert` sparingly for programmer +errors and return `bool` from bounded inserts to report failure. + +## Real-time and concurrency requirements + +For new or changed audio paths, allocate buffers during preparation and avoid heap +allocation, blocking locks and I/O in `processBlock` or per-sample DSP. Account for +variable host block sizes without allocating in the callback. Keep GUI updates on +the JUCE message thread. Exchange GUI/audio state through a safe snapshot or bounded +handoff; a timer or a writer-only mutex does not make concurrent access safe. + +The engine prebuilds wavetables and renders through bounded buffer views. GUI and +state edits hold `Engine::getControlLock()`; audio uses a try-lock to capture into +reserved storage and keeps its last snapshot when busy. Keep those two copies +separate. RAVE changes render settings, not the underlying knob values. The +historical audit describes the older implementation, not current behavior. + +## Scope of guidance + +Module guidance refines this file for its related sources. Preserve the root +real-time and ownership requirements if an example conflicts with them. diff --git a/AUDIT_REPORT.md b/AUDIT_REPORT.md index 251f34e..dc91f63 100644 --- a/AUDIT_REPORT.md +++ b/AUDIT_REPORT.md @@ -120,9 +120,9 @@ The Windows cross-build does not build or run tests: `build_windows.sh:50` passe There is no clean step in `build_windows.sh`. The script configures into `build_windows/` and builds without removing or cleaning it first. A clean rebuild is not scripted; it would require manually removing `build_windows/` (or invoking Ninja's clean target, which the -script does not do). A combined "clean build + run tests" command does not exist: the -Windows script disables tests, and the README test instructions assume an already-built -native `build/` tree. +script does not do). A combined "clean build + run tests" command does not exist. +The Windows script disables tests, and the README test instructions assume an +already-built native `build/` tree. --- @@ -138,7 +138,7 @@ generic synth architecture. - `Params.h`/`Params.cpp` depend only on `` (`Params.h:3`). It declares the `ids`, `maps` namespaces, all enums, `ModSource`/`ModTarget`, and the wavetable name - table. It is the shared foundation: nearly every other file includes it. + table. It is the shared foundation. Nearly every other file includes it. - `Wavetable`, `Oscillator`, `SubOscillator`, `NoiseOscillator`, `Envelope`, `LFO`, `Filter`, `FilterBank`, `SynthVoice` depend only on `Params`, `JuceHeader`, and each other (`Oscillator.h:4-5`, `FilterBank.h:4-5`, `SynthVoice.h:4-12`). @@ -244,7 +244,7 @@ generic synth architecture. The code intends to allocate only at prepare time, but it does not fully achieve this: -- Prepare-time allocation is the norm: delay lines and buffers are sized in `prepare()` +- Prepare-time allocation is the norm. Delay lines and buffers are sized in `prepare()` (`Filter.cpp:24`, `Chorus.cpp:9-11`, `Reverb.cpp:17-33`, `Engine.cpp:68`). - Violation 1: `Engine::processBlock` calls `mixBuffer.setSize (2, n, false, false, true)` on every block at `Engine.cpp:211`. `AudioBuffer::setSize` is a no-op when the size is @@ -283,7 +283,7 @@ mix of asserts, null guards, and return codes. itself. The only reporting is the test harness printing to `std::cout` and returning an exit code (`TestMain.cpp:73,103,129,132-134`). -Net style: programmer errors use `jassert` (very sparingly), runtime/state errors use null +Net style: programmer errors use `jassert` (sparingly), runtime/state errors use null guards or default values, and bounded-insertion failures use `bool` return codes that callers currently ignore. @@ -317,9 +317,9 @@ thread: thread reads the same `shapeBuffer` in `LFO::shapeValue` (`LFO.cpp:82-91`). None of these shared collections are locked or double-buffered. This is a real data race -today: resizing `connections` while the audio thread iterates it is undefined behaviour. +today. Resizing `connections` while the audio thread iterates it is undefined behaviour. -The processor header acknowledges GUI-thread access but does not address the race: the +The processor header acknowledges GUI-thread access but does not address the race. The comment "Public DSP state (read/write from the GUI thread)" at `PluginProcessor.h:55` only describes intent. @@ -372,7 +372,7 @@ describes intent. ## 7. Anti-pattern catalog -Each entry lists the location, the problem, and the standard that a later AGENT.md phase +Each entry lists the location, the problem, and the standard that a later AGENTS.md phase should encode. 1. Parameter ID tables duplicated three times. diff --git a/CMakeLists.txt b/CMakeLists.txt index 862124c..700ec95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,7 @@ set(SERUMALT_SOURCES Source/EffectUnits/Delay.cpp Source/EffectUnits/Reverb.cpp Source/GUI/Knob.cpp + Source/GUI/KnobLookAndFeel.cpp Source/GUI/Slider.cpp Source/GUI/ToggleButton.cpp Source/GUI/Display.cpp diff --git a/JANITOR_TASKS.md b/JANITOR_TASKS.md new file mode 100644 index 0000000..19347a9 --- /dev/null +++ b/JANITOR_TASKS.md @@ -0,0 +1,699 @@ +# Prepared janitor tasks + +Prepared from the current working tree on branch `main`, using the `graft` context +graph and a read-only verification pass. All 14 tasks were then dispatched to +`janitors-coder` agents on the user's confirmation and completed. See the +Execution status section at the end of this file for what each agent changed and +the one remaining limitation. + +## How to read this file + +Each task is a handoff for one `janitors-coder` agent. The shape follows the +janitor skill: observed problem, desired behavior, file locations, acceptance +check, and conflicts. + +Task types are `Defect fix` when current behavior is wrong for the user, and +`Cleanup` when the change should preserve behavior. Fixes that change what the +user sees or hears are marked `Risk`. + +Baseline at preparation time. A clean harness build and CTest pass: +`BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` reaches `100% tests +passed`. The build also reports first-party compiler warnings (task JT-05). + +## Ownership before dispatch + +The worktree is dirty. These files have uncommitted edits and should be +committed or frozen before any task touches them: + +```text +Source/FXProcessor.cpp Source/PluginEditor.cpp +Source/SynthVoice.cpp Source/SynthVoice.h +README.md AGENTS.md +AUDIT_REPORT.md opencode.json +Source/AGENT.md -> AGENTS.md (staged rename) +Source/*/AGENT.md -> AGENTS.md (staged renames) +``` + +The `Source/FXProcessor.cpp` edit repairs code that does not compile at `HEAD` +(`HEAD` declares a two-dimensional `units` array in `FXProcessor.h` but indexes +it as one-dimensional in `FXProcessor.cpp`). Commit it first. Do not let two +agents edit the same file at once. + +## Task index + +| Id | Title | Type | Priority | Primary files | +| --- | --- | --- | --- | --- | +| JT-01 | Remove the unused look-and-feel | Cleanup | P1 | `Source/PluginEditor.h`, `Source/GUI/` | +| JT-02 | Make the LFO preview use the real LFO shape | Defect fix | P1 | `Source/LFO.{h,cpp}`, `Source/GUI/LFODisplay.{h,cpp}` | +| JT-03 | Give macros one set of names | Defect fix | P1 | `Source/MacroControls.*`, `Source/PluginProcessor.cpp`, `Source/PluginEditor.cpp` | +| JT-04 | Report rejected mod and macro inserts | Defect fix | P1 | `Source/PluginEditor.cpp` | +| JT-05 | Drive first-party compiler warnings to zero | Cleanup | P2 | several `Source/` files | +| JT-06 | Use `FXProcessor::fxTypeName` for the FX type list | Cleanup | P2 | `Source/PluginEditor.cpp` | +| JT-07 | Remove unused members and dead tracking | Cleanup | P2 | `Source/PluginEditor.h`, `Source/Filter.*`, `Source/Engine.*` | +| JT-08 | Remove dead parameters and discarded computations | Cleanup | P2 | `Source/SubOscillator.*`, `Source/Oscillator.*`, `Source/Wavetable.cpp`, `Source/Filter.*`, `Source/FilterBank.*` | +| JT-09 | De-duplicate the test render loop | Cleanup | P2 | `Source/Tests/TestMain.cpp` | +| JT-10 | Unify pi and two-pi constants | Cleanup | P2 | `Source/EffectUnits/*`, `Source/Wavetable.cpp`, `Source/SubOscillator.h`, `Source/GUI/LFODisplay.cpp`, `Source/EffectUnits/Biquad.h` | +| JT-11 | Share the envelope curve and mark the filter preview | Cleanup | P2 | `Source/Envelope.*`, `Source/GUI/EnvelopeDisplay.cpp`, `Source/GUI/FilterDisplay.cpp` | +| JT-12 | Reuse `paramIds::` in `PluginProcessor` | Cleanup | P3 | `Source/PluginProcessor.cpp` | +| JT-13 | Guard the mono-buffer and capacity assumptions | Hardening | P3 | `Source/FXProcessor.cpp`, `Source/SynthVoice.cpp` | +| JT-14 | Fix stale guidance and remaining prose slop | Cleanup | P3 | `AGENTS.md` files, `.claude/`, `build_windows.sh`, `AUDIT_REPORT.md` | + +--- + +## JT-01 Remove the unused look-and-feel + +Type. Cleanup. +Priority. P1. +Risk. None visible. The class was never installed, so deleting it changes nothing +on screen. This outcome was chosen by the user on 2026-05-10: keep JUCE defaults. + +Observed problem. `Source/PluginEditor.h:39` declares `SerumLookAndFeel laf`, and +no source file installs it. A repo-wide search finds no `setLookAndFeel` or +`setDefaultLookAndFeel` call, and `git log -S setLookAndFeel` shows none ever +existed. Every control draws with JUCE's default look-and-feel, and the custom +`drawRotarySlider` and `drawLinearSlider` in `Source/GUI/SerumLookAndFeel.h` have +never run. The class and its member are dead, and `Source/GUI/AGENTS.md:19` still +requires controls to be drawn through it. `Source/GUI/Knob.h:11` repeats the claim. + +Desired behavior. JUCE's default look-and-feel stays in use, the dead class and +member are gone, and the guidance matches the code. + +Work. Delete `Source/GUI/SerumLookAndFeel.h`. Remove the include at +`Source/PluginEditor.h:5` and the `laf` member at `Source/PluginEditor.h:39`. +Rewrite the comment at `Source/GUI/Knob.h:11` so it no longer says the knob is +styled by `SerumLookAndFeel`. In `Source/GUI/AGENTS.md`, drop `SerumLookAndFeel.h` +from Owned files and remove the "Draw all controls through `SerumLookAndFeel`" +rule. The header is header-only and absent from `SERUMALT_SOURCES`, so CMake needs +no change. + +Files in scope. `Source/PluginEditor.h`, `Source/GUI/SerumLookAndFeel.h` +(delete), `Source/GUI/Knob.h`, `Source/GUI/AGENTS.md`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. +`grep -rn "SerumLookAndFeel" Source/` returns nothing. The GUI is unchanged, +because the class was never installed. + +--- + +## JT-02 Make the LFO preview use the real LFO shape + +Type. Defect fix. +Priority. P1. +Risk. Changes the LFO display. The audio path should not change. + +Observed problem. `Source/GUI/LFODisplay.cpp:23-52` re-implements every LFO +shape. The audio path implements the same shapes in `Source/LFO.cpp:65-96`. Two +problems follow. + +1. Sample and hold disagrees. The display computes a fixed multiplicative hash + at `Source/GUI/LFODisplay.cpp:31-32` + (`((step * 2654435761u) & 0xffffu) / 32768.0f - 1.0f`), while the audio + returns `holdValue`, which comes from `juce::Random` + (`Source/LFO.cpp:13,79,131-133`). The preview shows a sequence the synth + never plays. +2. The remaining shapes can drift. Triangle, saw, square, step and freehand are + copied at `LFODisplay.cpp:26-28,34-52` and `LFO.cpp:73,75,77,80-92`. + +The display also hardcodes `64` at `Source/GUI/LFODisplay.cpp:8,9,10,17`, while +`LFO.h:16` already exposes `static constexpr int kShapePoints = 64;`. + +Desired behavior. One function decides the value of a shape, and both the audio +render and the display call it. The preview then cannot disagree with the sound. + +Work. Extract the shape switch from `LFO::shapeValue` (`Source/LFO.cpp:65-96`) +into a shared, pure function that takes phase, shape, step count and the shape +buffer, for example `float lfoShapeValue (float phase, LfoShape shape, int steps, +const float* data, int dataSize)`. Call it from both `LFO::shapeValue` and +`LFODisplay::valueAt`. Replace the hardcoded `64` in `LFODisplay` with +`LFO::kShapePoints`. For sample and hold, the preview should not invent values; +either have the display read the live hold value through `Engine::getLfos()` +(`Source/Engine.h:42`), or draw the S&H shape as a clearly labelled schematic and +stop pretending it is the played sequence. + +Files in scope. `Source/LFO.h`, `Source/LFO.cpp`, `Source/GUI/LFODisplay.h`, +`Source/GUI/LFODisplay.cpp`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. A +focused check confirms the display and `LFO::shapeValue` return the same value for +the same phase and shape, including sample and hold. State which S&H approach you +took. + +--- + +## JT-03 Give macros one set of names + +Type. Defect fix. +Priority. P1. +Risk. The macro parameter display names change in the host. Parameter IDs do not +change, so saved projects still load. + +Observed problem. The same four macros have two names. `Source/MacroControls.cpp:30-40` +returns "Energy", "Width", "Drive" and "Atmosphere". The APVTS parameters are +named "Macro 1" through "Macro 4" at `Source/PluginProcessor.cpp:168-171`, and +the assignment combo repeats "Macro 1" through "Macro 4" at +`Source/PluginEditor.cpp:686`. The macro panels and the macro list use +`MacroControls::macroName` (`Source/PluginEditor.cpp:668-669,948`). A user sees +"Energy" on the panel and "Macro 1" in the host automation lane and in the assign +combo. + +Desired behavior. One source of truth for the four macro names. + +Work. Make `MacroControls::macroName` the source of truth. Use it to build the +APVTS display names in `PluginProcessor.cpp` and the `macroAssignIndex` items in +`PluginEditor.cpp`. Leave the parameter IDs (`ids::macro1` and friends) and the +normalized values unchanged. + +Files in scope. `Source/MacroControls.h`, `Source/MacroControls.cpp`, +`Source/PluginProcessor.cpp`, `Source/PluginEditor.cpp`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. The +host automation names, the assign combo and the panel labels show the same four +strings. `grep -rn '"Macro 1"' Source/` returns only +`Source/Params.cpp:22`, which is the stable `ModSource` serialisation token used by +saved state and must not change. + +--- + +## JT-04 Report rejected mod and macro inserts + +Type. Defect fix. +Priority. P1. +Risk. Small UI change, no audio change. + +Observed problem. `ModulationMatrix::addConnection` and +`MacroControls::addAssignment` return `bool` and reject inserts past +`kMaxConnections` or `kMaxAssignments` (`Source/ModulationMatrix.cpp:6-12`, +`Source/MacroControls.cpp:6-13`). The editor ignores both results at +`Source/PluginEditor.cpp:550` and `Source/PluginEditor.cpp:716`, so a click that +fails looks identical to a click that worked. The list does not change and no +message appears. + +Desired behavior. A rejected insert is visible to the user. + +Work. Check the returned value at both call sites. The smallest useful fix is to +disable or grey the Add buttons when the relevant count reaches its cap, and to +keep the list text and selection unchanged on rejection. A status message is also +acceptable. Do not change the caps. + +Files in scope. `Source/PluginEditor.cpp`. Read the caps from +`ModulationMatrix::kMaxConnections` and `MacroControls::kMaxAssignments`; do not +hardcode the numbers. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. With +the connection or assignment count at its cap, the Add control is visibly +unavailable and clicking it changes nothing. + +--- + +## JT-05 Drive first-party compiler warnings to zero + +Type. Cleanup. +Priority. P2. + +Observed problem. The harness build compiles with `-Wall -Wextra` plus JUCE's +recommended warning flags and reports more than fifty warning lines from files +under `Source/`. Representative sites, all current: + +- `Source/SynthVoice.cpp:131,138` sign conversion from `int` to `size_t`. +- `Source/GUI/LFODisplay.cpp:11,12,16,32` sign conversion and a float equality. +- `Source/PluginEditor.cpp:88` `fmtSlope` is defined and never used. +- `Source/Oscillator.cpp:15` unused parameter `freqHz`. +- `Source/GUI/Slider.cpp:6` a local named `formatter` shadows a member. +- `Source/GUI/Knob.cpp:77` a local named `cursor` shadows a member. +- `Source/Params.cpp:8,33,58,97`, `Source/Oscillator.cpp:85,108`, + `Source/LFO.cpp:68`, `Source/Filter.cpp:57`, `Source/FXProcessor.cpp:53`, + `Source/GUI/FilterDisplay.cpp:13`, `Source/GUI/LFODisplay.cpp:23` + `-Wswitch-enum` cases. +- `-Wfloat-equal` comparisons in `Source/GUI/EnvelopeDisplay.cpp:8`, + `Source/GUI/FilterDisplay.cpp:40`, `Source/GUI/WaveformDisplay.h:18`, + `Source/Filter.cpp:99`, `Source/LFO.cpp:43`, `Source/Oscillator.cpp:28,29`, + `Source/MacroControls.cpp:82`, `Source/PluginProcessor.cpp:409`. + +Desired behavior. The harness build reports no warnings from `Source/`. Warnings +from `third_party/JUCE` are out of scope. + +Work. List the warnings with: + +```bash +BUILD_DIR=/tmp/serumalt-check ./build_harness.sh 2>&1 | grep 'warning:' | grep -v third_party +``` + +Fix the unused function, the unused parameter and the two shadowed locals by +deletion or rename. Fix the sign-conversion sites with explicit `(size_t)` or +`(juce::uint32)` casts, matching the surrounding style. For `-Wswitch-enum`, name +every enumerator explicitly. For `-Wfloat-equal`, check the meaning of each +comparison first. Some are deliberate cache keys that compare a stored parameter +against the current one. If the exact comparison is intended, keep the behavior +and make the intent explicit with a small named helper or a narrow, commented +suppression. Do not silently weaken a change-detection check. + +Files in scope. The `Source/` files named above and any other first-party file +that the command reports. + +Acceptance. The command above prints nothing from `Source/`. +`BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. + +--- + +## JT-06 Use `FXProcessor::fxTypeName` for the FX type list + +Type. Cleanup. +Priority. P2. + +Observed problem. `FXProcessor::fxTypeName` is declared at +`Source/FXProcessor.h:47` and defined at `Source/FXProcessor.cpp:51-67` with no +callers. The same ten labels are written again as a literal `juce::StringArray` +at `Source/PluginEditor.cpp:604-605`. The two lists must stay in the same order as +the `FxType` enum, and nothing enforces that. + +Desired behavior. The FX type combo is built from the one label function. + +Work. In `PluginEditor::buildFxTab`, replace the literal array with a loop over +`(int) FxType::Count` that adds `FXProcessor::fxTypeName (t)`. Confirm the combo +indices still line up with `FxType` values, since the combo index is used +directly as the parameter value (`Source/PluginEditor.cpp:246` area and the FX +type attachment). + +Files in scope. `Source/PluginEditor.cpp`, `Source/FXProcessor.h`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. The +combo shows Off through Reverb in enum order and selecting a type still sets the +matching slot type. `FXProcessor::fxTypeName` now has a caller. + +Conflict. `Source/FXProcessor.cpp` and `Source/PluginEditor.cpp` both have +uncommitted edits. Commit or freeze them first. + +--- + +## JT-07 Remove unused members and dead tracking + +Type. Cleanup. +Priority. P2. + +Observed problem. Four unused declarations remain: + +1. `Source/PluginEditor.h:66` declares + `std::vector oscAComponents, oscBComponents;`. Neither name + appears anywhere else in `Source/`. +2. `Filter::svfLow` and `Filter::svfHigh` are declared at + `Source/Filter.h:53,55` and defined at `Source/Filter.cpp:108,130`. Each name + occurs twice, at the declaration and the definition. The sibling `svfBand` is + used at `Source/Filter.cpp:222`. +3. `Engine::getActiveVoiceCount` (`Source/Engine.h:46`) has no callers, so + `activeVoiceCount` (`Source/Engine.h:70`) is written at + `Source/Engine.cpp:59` and `Source/Engine.cpp:363` and never read. The per-block + tally at `Source/Engine.cpp:360-362` exists only to feed it. +4. `PluginEditor.cpp:88` `fmtSlope` is unused, which the compiler also reports + (task JT-05 overlaps here; one agent should own both). + +Desired behavior. No unused storage and no work that feeds nothing. + +Work. Delete the two `PluginEditor` vectors. Delete `svfLow` and `svfHigh` from +the header and the implementation. Delete the getter, the atomic field, the +`reset()` store and the per-block tally, unless you can point to an intended +caller and wire it up instead. Delete `fmtSlope` unless a knob needs it; the +slope readout currently uses a different formatter. + +Files in scope. `Source/PluginEditor.h`, `Source/Filter.h`, `Source/Filter.cpp`, +`Source/Engine.h`, `Source/Engine.cpp`. Optionally `Source/PluginEditor.cpp` for +`fmtSlope`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes and +`grep -rn "oscAComponents\|oscBComponents\|svfLow\|svfHigh\|getActiveVoiceCount\|activeVoiceCount" Source/` +returns nothing. + +--- + +## JT-08 Remove dead parameters and discarded computations + +Type. Cleanup. +Priority. P2. + +Observed problem. Four sites compute or accept values that nothing uses: + +1. `Source/SubOscillator.cpp:9-11` computes `mult` from `octaveShift`, then + discards it and `freqHz` with `(void)` casts. Only `phase = 0.0;` has an + effect. The octave shift is already baked into `freqHz` by the voice + (`Source/SynthVoice.cpp:200,212`). The only caller passes `-1` for the shift + (`Source/SynthVoice.cpp:58`). +2. `Source/Oscillator.cpp:15` accepts `freqHz` only to feed + `jassert (freqHz > 0.0)`. In a release build the assert compiles out and the + parameter is unused. +3. `Source/Wavetable.cpp:238` and `:244` compute `const float u = ...` and + discard it with `(void) u` at `:240` and `:246`, while `:248` immediately + recomputes the same expression as `tt`, which is the value actually used. +4. `Source/Filter::prepare` accepts `maxBlockSize` and discards it at + `Source/Filter.cpp:28`. `FilterBank::prepare` forwards it to four `Filter` + objects (`Source/FilterBank.cpp:8-11`), so the parameter is dead end to end. + +Desired behavior. Signatures and locals describe what the code uses. + +Work. Reduce `SubOscillator::noteOn` to the work it does, which is resetting +phase, and update the one caller. Drop `freqHz` from `Oscillator::noteOn` and +update the two callers at `Source/SynthVoice.cpp:170-171`. Delete the two `u` +locals and both `(void) u` casts in `Wavetable.cpp`, keeping `tt`. Drop +`maxBlockSize` from `Filter::prepare`, `FilterBank::prepare` and their headers, +and update the `FilterBank` call at `Source/SynthVoice.cpp:20`. The `FXUnit` +interface keeps its `prepare (double, int)` shape because all units share it, and +the units that ignore the block size already leave the parameter unnamed, which is +the existing convention. See `Source/EffectUnits/AGENTS.md` and +`Source/synth/AGENTS.md`. + +Files in scope. `Source/SubOscillator.h`, `Source/SubOscillator.cpp`, +`Source/Oscillator.h`, `Source/Oscillator.cpp`, `Source/Wavetable.cpp`, +`Source/Filter.h`, `Source/Filter.cpp`, `Source/FilterBank.h`, +`Source/FilterBank.cpp`, `Source/SynthVoice.cpp`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. +`grep -rn "(void)" Source/SubOscillator.cpp Source/Wavetable.cpp Source/Filter.cpp` +returns nothing. The sub oscillator still tracks the voice frequency, because +that frequency is applied in `SynthVoice::render`. + +Conflict. `Source/SynthVoice.cpp` has uncommitted edits. + +--- + +## JT-09 De-duplicate the test render loop + +Type. Cleanup. +Priority. P2. + +Observed problem. `Source/Tests/TestMain.cpp` repeats the same render loop twice. +The init-preset block at lines 49-74 and the per-preset block at lines 76-105 do +the same work: build three note-ons, render 240 blocks of 512 samples, release at +block 180, accumulate a NaN flag and a peak, compare against `0.001f`, print, and +count failures. The RAVE block at lines 107-130 is a smaller variant. + +Desired behavior. One helper runs a preset and returns whether it passed. + +Work. Extract a function, for example +`bool runPreset (serum::SerumAltAudioProcessor&, const juce::String& label, double maxPeakFloor)`, +and call it for the init case and for each factory preset. Keep the output format +and the exit code behavior. Keep the rule from `Source/Tests/AGENTS.md`: check +non-silence across the whole render, not per block, because a release can produce +a legitimately silent block. + +Files in scope. `Source/Tests/TestMain.cpp`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes and +still prints one PASS line per preset. + +--- + +## JT-10 Unify pi and two-pi constants + +Type. Cleanup. +Priority. P2. + +Observed problem. `Source/synth/AGENTS.md` and +`Source/EffectUnits/AGENTS.md` require `juce::MathConstants`. Raw literals and +local constants remain: + +- `Source/SubOscillator.h:26` `kTwoPi = 6.28318530717958647692`. +- `Source/Wavetable.cpp:21` `twoPi = 6.28318530717958647692`. +- `Source/Wavetable.cpp:156` `kPi = 3.14159265358979323846f`. +- `Source/EffectUnits/Chorus.cpp:40,41` two-pi literals, `:46` a pi literal. +- `Source/EffectUnits/Flanger.cpp:38,39` two-pi literals. +- `Source/EffectUnits/Phaser.cpp:30,31` two-pi literals. +- `Source/GUI/LFODisplay.cpp:25` `6.2831853f`. +- `Source/EffectUnits/Biquad.h:31,45,60,75,90` write + `2.0 * juce::MathConstants::pi`, which should be `twoPi`. + +Already converted and correct: `Source/Oscillator.h:72`, `Source/Filter.cpp:8`, +`Source/LFO.cpp:71`, `Source/Oscillator.cpp:63` (half pi, leave it). + +Desired behavior. One spelling for each constant, taken from +`juce::MathConstants`. + +Work. Replace each literal with `juce::MathConstants::twoPi` or +`juce::MathConstants::twoPi` as the surrounding precision requires, and +`pi` where only pi is meant. Remove the now-unused local constants. Keep the +per-sample DSP value types unchanged, so an `f`-suffixed literal becomes +`juce::MathConstants::twoPi` and a `double` literal becomes the `double` +form. + +Files in scope. `Source/SubOscillator.h`, `Source/SubOscillator.cpp`, +`Source/Wavetable.cpp`, `Source/EffectUnits/Chorus.cpp`, +`Source/EffectUnits/Flanger.cpp`, `Source/EffectUnits/Phaser.cpp`, +`Source/EffectUnits/Biquad.h`, `Source/GUI/LFODisplay.cpp`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. +`grep -rn "6.28318530717958647692\|3.14159265358979323846\|6.2831853f" Source/` +returns nothing. The harness reports no NaN and preset peaks match the values +from the unmodified build. + +--- + +## JT-11 Share the envelope curve and mark the filter preview + +Type. Cleanup. +Priority. P2. + +Observed problem. Two smaller duplications remain between DSP and display. + +1. Envelope curve. `Source/Envelope.cpp:44-45` computes + `attackShape = 0.3 + curve * 2.7` and `decayShape = 3.0 - curve * 2.7`. + `Source/GUI/EnvelopeDisplay.cpp:22-23` repeats both formulas with `f` suffixes. +2. Filter preview. `Source/GUI/FilterDisplay.cpp:6-36` re-implements a magnitude + response that does not match the DSP in `Source/Filter.cpp:87-88`. The display + uses `q = 0.6f + res * 14.0f`, while the DSP uses + `k = clamp (2.0 * (1.0 - res), 0.05, 2.0)`. The header calls the view + approximate (`Source/GUI/FilterDisplay.h:11,17`), but the re-implementation + site has no comment at all. + +Desired behavior. The envelope preview cannot drift from the envelope DSP, and +the filter preview states at the code that it is schematic. + +Work. Add one shared helper for the curve mapping, for example +`attackShapeFor (float curve)` and `decayShapeFor (float curve)` in +`Source/Envelope.h`, and call it from both `Envelope::setParams` and +`EnvelopeDisplay::paint`. Add a short comment at the top of +`FilterDisplay::magnitude` that says the response is a schematic preview and does +not model the DSP, the drive, the slope or modulation, and point to +`Source/Filter.cpp`. Do not try to make the preview numerically match the +nonlinear DSP. + +Files in scope. `Source/Envelope.h`, `Source/Envelope.cpp`, +`Source/GUI/EnvelopeDisplay.cpp`, `Source/GUI/FilterDisplay.cpp`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. +`grep -n "2.7" Source/Envelope.h Source/Envelope.cpp Source/GUI/EnvelopeDisplay.cpp` +returns the two uses in the shared helper only, and nothing in the two callers. +`FilterDisplay.cpp` carries the approximate comment. + +--- + +## JT-12 Reuse `paramIds::` in `PluginProcessor` + +Type. Cleanup. +Priority. P3. + +Observed problem. `Source/Params.h:229-269` defines the `paramIds::` indexed +groups. `Source/Engine.cpp` and `Source/PluginEditor.cpp` now iterate those +groups. `Source/PluginProcessor.cpp` still builds its own arrays that mirror the +same groups: + +- `:102-106` five envelope arrays. +- `:121-127` seven LFO arrays. +- `:142-155` FX type, mix and parameter arrays. + +The arrays reference `ids::`, so no string literal is duplicated. The structure +is. + +Desired behavior. The parameter layout walks the shared groups instead of +re-declaring parallel arrays. + +Work. Replace the local arrays with `paramIds::envAttack` and the other groups, or +iterate them directly. Keep the parameter names and defaults exactly as they are, +because they define the host-visible layout and the saved-state order. Do not +change parameter IDs. + +Files in scope. `Source/PluginProcessor.cpp`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. +The parameter count and order are unchanged. A saved state from before the change +still loads. + +--- + +## JT-13 Guard the mono-buffer and capacity assumptions + +Type. Hardening. +Priority. P3. +Risk. No behavior change for the current 2-channel, 64-sample path. + +Observed problem. `FXProcessor::process` reads and writes channel 1 +unconditionally (`Source/FXProcessor.cpp:92,98`). It is safe today only because +`Engine` always hands it a 2-channel view (`Source/Engine.cpp:307`). A future +one-channel caller reads a null channel. Similarly, `FXProcessor::process` and +`SynthVoice::render` trust the sample count to fit the buffers sized in +`prepare` (`Source/FXProcessor.cpp:38`, `Source/SynthVoice.cpp:23`) with no +assert. The engine's 64-sample segmentation (`Source/Engine.cpp:270`) is the only +thing keeping that true. + +Desired behavior. The boundaries are explicit, and a debug build catches a +violation instead of corrupting memory. + +Work. In `FXProcessor::process`, handle `buffer.getNumChannels() < 2` by +processing only the channels present, or by returning early with a `jassert`. +Add `jassert (n <= wet.getNumSamples())` and +`jassert (numSamples <= scratch.getNumSamples())` at the top of the two render +functions. Do not add allocation and do not change the audio-thread behavior for +valid input. + +Files in scope. `Source/FXProcessor.cpp`, `Source/SynthVoice.cpp`. + +Acceptance. `BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run` passes. A +debug build asserts when `SynthVoice::render` is called with +`numSamples > scratch.getNumSamples()`. + +Conflict. Both files have uncommitted edits. + +--- + +## JT-14 Fix stale guidance and remaining prose slop + +Type. Cleanup. +Priority. P3. + +Observed problem. Two kinds of documentation drift. + +Guidance that contradicts the code: + +- `Source/params/AGENTS.md` says to use the `ids::` namespace for every + parameter ID and never to re-declare one outside `Params.h`. It does not mention + the `paramIds::` indexed groups at `Source/Params.h:229`, which the engine and + editor now use. +- `Source/synth/AGENTS.md` says "Wavetables are prebuilt during Engine + construction" but its example still ends with `// in prepareToPlay`. The real + call is `Source/Engine.cpp:22` in the constructor. +- `Source/engine/AGENTS.md` shows a "GOOD" example that calls + `mixBuffer.clear()` in `processBlock`. The code instead builds a non-owning view + per segment (`Source/Engine.cpp:307`). Update the example to match. + +Prose that still violates the unslop rules: + +- `.claude/skills/graft/SKILL.md` carries em dashes, an emoji at line 129, + boldface across whole sentences, and colon connectors. This is the largest + remaining prose problem. +- `build_windows.sh:3` has an em dash, and `:58-59` uses a colon as a connector. +- `AUDIT_REPORT.md:123,141,247,320,322` use colons as mid-sentence connectors, and + `:286` reads "very sparingly". +- `.claude/helpers/graft-hooks.cjs:67` and `.claude/helpers/graft-statusline.cjs:67` + have an em dash in a trailing comment. +- Root `AGENTS.md` has nine em dashes inside the generated + `` block. That block is produced by `graft init` from a + Graft template, so editing it in place will be overwritten on the next + `graft init`. Fix the template source, or leave the block and note it as + generated. + +Desired behavior. Guidance matches the code, and prose reads plainly. + +Work. Apply the fixes above. For each unslop edit, replace em dashes with a +comma or a full stop, split colon connectors into two sentences, remove the +emoji, and unbold body text. Keep literal technical terms such as "harness", +"vector" and "surface" when they name a real thing. Do not edit +`third_party/JUCE`. + +Files in scope. `Source/params/AGENTS.md`, `Source/synth/AGENTS.md`, +`Source/engine/AGENTS.md`, `.claude/skills/graft/SKILL.md`, +`.claude/helpers/graft-hooks.cjs`, `.claude/helpers/graft-statusline.cjs`, +`build_windows.sh`, `AUDIT_REPORT.md`, and root `AGENTS.md`. + +Acceptance. `bash -n` passes for `build_*.sh`. +`grep -rn "—" AGENTS.md Source/*/AGENTS.md build_windows.sh .claude/skills/graft/SKILL.md` +returns nothing outside the generated graft block. A build and harness run still +pass, since only comments and prose changed. + +Conflict. `AGENTS.md`, `AUDIT_REPORT.md` and the `Source/**/AGENTS.md` files have +uncommitted edits. Commit or freeze them first. + +--- + +## Considered and rejected + +These came up during verification and are deliberately not tasks. + +- Rename `Source/RAVEButton.{h,cpp}` to match `class RaveController`. Both + `Source/modulation/AGENTS.md:19` and root `AGENTS.md` say to keep the filenames + unless a rename is in scope. +- Rewrite `AUDIT_REPORT.md`. Its own header and root `AGENTS.md` mark it as a + historical snapshot, so it is not a pending instruction. Only its prose is + touched, in JT-14. +- Move `PluginEditor`, `FXProcessor`, `Resources` and `RAVEButton` into + subdirectories. The proposed moves in `AUDIT_REPORT.md` are suggestions, and + root `AGENTS.md` says the current layout is intentional until a move is part of + a requested task. +- Make `ModulationMatrix::connections` and `MacroControls::assignments` private + and force all mutation through locked members. Every current caller already + holds `Engine::getControlLock()`, and the change is a wide interface refactor, + not cleanup. Revisit only if a caller appears outside the lock. +- Make the lazy `WavetableLibrary::getTable` build branch unreachable. The + constructor prebuilds every index (`Source/Engine.cpp:22`), so the branch is + dead today. JT-13 covers the defensive asserts; deleting the lazy path is a + separate design decision. +- Add tests. `AGENTS.md` says to add test infrastructure only when requested. + +## Proposed dispatch order + +Dispatch one task at a time per file cluster. The conflicts column above names +the files with uncommitted edits. + +- Batch 1, independent files: JT-02, JT-09, JT-11, JT-13. +- Batch 2, editor cluster, serial: JT-01, then JT-03, then JT-04, then JT-06. +- Batch 3, dead-code cluster, serial across `SynthVoice` and `Filter`: JT-08, + then JT-07. +- Batch 4, wide edits, serial: JT-05, then JT-10, then JT-12, then JT-14. + +Each agent must run the harness before handing back: + +```bash +BUILD_DIR=/tmp/serumalt-check ./build_harness.sh --run +``` + +## Execution status + +All 14 tasks were dispatched and completed. Final consolidated verification on the +frozen tree: + +```bash +BUILD_DIR=/tmp/jt-final CMAKE_BUILD_PARALLEL_LEVEL=8 ./build_harness.sh --run +``` + +Result: exit 0, `1/1 Test #1: SerumAltTest ... Passed`, `100% tests passed`, and +zero first-party compiler warnings in `Source/` (down from 55 warning lines). + +| Id | Result | Note | +| --- | --- | --- | +| JT-01 | Done | `Source/GUI/SerumLookAndFeel.h` deleted. The class was never installed, so the GUI is unchanged. | +| JT-02 | Done | One `lfoShapeValue` function now serves `LFO` and `LFODisplay`. The sample-and-hold preview is a labelled schematic and no longer invents a sequence. | +| JT-03 | Done | Macro names come from `MacroControls::macroName` in the APVTS, the assign combo, the panels and the list. Only the `Params.cpp` serialisation tokens keep the old strings. | +| JT-04 | Done | Add and Assign grey out at `kMaxConnections` and `kMaxAssignments`, and rejected inserts no longer look like success. | +| JT-05 | Done | 55 first-party warning lines to 0. New `Source/ExactCompare.h` names the deliberate exact float comparisons instead of weakening them to an epsilon. | +| JT-06 | Done | The FX type combo is built from `FXProcessor::fxTypeName`; the duplicate literal list is gone. | +| JT-07 | Done | Removed the unused `oscAComponents`/`oscBComponents`, `Filter::svfLow`/`svfHigh`, `Engine::getActiveVoiceCount` and the per-block tally, and the unused `fmtSlope`. | +| JT-08 | Done | Removed the dead `SubOscillator` octave math, the assert-only `Oscillator::noteOn` frequency, the discarded `Wavetable` locals, and the unused `maxBlockSize` chain through `Filter` and `FilterBank`. | +| JT-09 | Done | The duplicated test loops are one `runPreset` helper. Output format and peak values are unchanged. | +| JT-10 | Done | Raw pi and two-pi literals replaced with `juce::MathConstants`. The only remaining match is the intentional "BAD" example in `Source/synth/AGENTS.md`. | +| JT-11 | Done | The envelope curve mapping lives in `Envelope.h` and both callers use it. `FilterDisplay::magnitude` now states that it is a schematic preview. | +| JT-12 | Done | `PluginProcessor::createParameterLayout` uses the `paramIds::` groups. A layout comparison confirmed all 96 `(id, name, default)` entries are identical and in the same order. | +| JT-13 | Done | `FXProcessor::process` returns early on a mono buffer, and capacity asserts guard `FXProcessor::process` and `SynthVoice::render`. | +| JT-14 | Done, one limitation | Guidance and prose fixed across the `AGENTS.md` files, `.claude/skills/graft/SKILL.md`, `build_windows.sh`, `AUDIT_REPORT.md` and the two helper scripts. Nine em dashes remain inside the generated `` block in root `AGENTS.md`. | + +The JT-14 limitation. The graft block in root `AGENTS.md` is generated by the +installed `@nanonets/graft` package, whose template carries the em dashes. +`graft init` would reproduce them, so the block was left as generated. Removing +them needs a change to the package template at +`/home/biggy/Documents/Coding/_skills/Graft/src/hosts/instructions.ts` and a +reinstall, which is outside this repository. + +Other notes. + +- The pre-existing uncommitted work in the tree was preserved throughout. It + includes the `FXProcessor` per-slot repair, the `SynthVoice::getModulationValue` + addition, the editor control-lock line, and the `AGENT.md` to `AGENTS.md` + renames. `HEAD` does not compile without the `FXProcessor` repair, so commit the + current tree before treating any single task as an isolated diff. +- Every task ran its own harness build in `/tmp`, so no `build_*` directory in the + repository was modified and `third_party/JUCE` was not touched. +- No task changed APVTS parameter IDs, so saved projects stay compatible. diff --git a/README.md b/README.md index c2bbb98..5c4447d 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,76 @@ # SerumAlt -A modern, commercial-quality wavetable synthesiser plugin (VST3 / AU / Standalone) -built with **JUCE 7**, **CMake** and **C++17**. SerumAlt is a self-contained synth -inspired by the workflow of Serum/Vital: two morphing wavetable oscillators, a full -filter section, four envelopes and LFOs, a modulation matrix, a reorderable FX rack, -four macros and a one-click **RAVE** button. +A wavetable synthesiser experiment built with JUCE 7, CMake and C++17. It targets +VST3, AU and Standalone formats, with a workflow inspired by Serum and Vital. +This is a fun proof of concept, not a commercial release. Development favors +working builds and hands-on listening over elaborate test infrastructure. ![SerumAlt](Source/Resources/logo.svg) ## Features -- **Two wavetable oscillators** — 256 frames × 2048 samples, bilinear morphing, - 8 warp modes (Bend+/-, Sync, PWM, Asym, Mirror, Fold), unison 1–16 with phase - spread/detune/width. -- **10 factory wavetables** (Basic Shapes, Saw PWM, Square Sync, Triangle Fold, - Vowel, Organ, Warm Saw, Digital, Glass, Bass), generated lazily and RAM-resident. -- **Sub oscillator** (sine/triangle, −2/−1/0 oct) and **noise** (white/pink). -- **Filter section** — two slots, 7 models (Ladder LP/HP/BP, Diode, Comb, Formant, - Screamer), 6/12/24 dB slopes, drive, keytrack, and serial/parallel/split routing. -- **4 ADSR envelopes** with curve tension and live preview. -- **4 LFOs** — tempo-sync, 7 shapes (sine/tri/saw/square/S&H/step/freehand), - editable step-sequencer and freehand drawing, fade-in and start delay. -- **Modulation matrix** — direct source→destination connections with bipolar depth. -- **FX rack** — 9 effects (Hyper/OTT, Chorus, Flanger, Phaser, Distortion, EQ, - Compressor, Delay, Reverb) in 8 reorderable slots. -- **4 macros** (Energy, Width, Drive, Atmosphere) with assignable destinations. -- **RAVE** — one-click boost (unison, width, drive, OTT, reverb) with snapshot/restore. -- **16 factory presets** with production-quality values. -- **Scalable GUI** (75% / 100% / 125% / 150% / 200%) — dark modern vector look, - Path-drawn controls, SVG logo/background, real-time waveform/filter/envelope/LFO - displays. +- Two wavetable oscillators, each with 256 frames of 2048 samples, bilinear + morphing, seven warp effects plus bypass, and up to 16 unison voices. +- Ten factory wavetables, built before audio rendering and kept in memory. +- Sine/triangle sub oscillator and white/pink noise. +- Two filter slots with seven models, drive, key tracking and serial, parallel + or split routing. +- Four ADSR envelopes and four free-running or tempo-synced LFOs, including + editable step and freehand shapes. +- A modulation matrix and four assignable macros. +- Nine effect types in eight reorderable slots, with independent state per slot. +- A non-destructive RAVE boost for unison, spread, drive, Hyper and reverb. +- Sixteen factory presets and a scalable vector GUI with waveform, envelope, + LFO and schematic filter previews. ## Architecture ``` Source/ PluginProcessor.{h,cpp} APVTS, presets, state, RAVE/UI-scale glue - PluginEditor.{h,cpp} Tabbed dark GUI, scaling, visualisations - Params.{h,cpp} Parameter IDs, enums, mod sources/targets, mappings - Wavetable.{h,cpp} 256×2048 tables + lazy library + PluginEditor.{h,cpp} Tabbed GUI, owned controls, scaling, previews + Params.{h,cpp} Parameter IDs, indexed groups, enums, mappings + Wavetable.{h,cpp} 256×2048 tables, prebuilt by the engine Oscillator.{h,cpp} Morph/warp/unison oscillator SubOscillator.{h,cpp} Sine/triangle sub NoiseOscillator.{h,cpp} White/pink noise - Filter.{h,cpp} 7 filter models - FilterBank.{h,cpp} 2 slots + routing + Filter.{h,cpp} Seven filter models and cached coefficients + FilterBank.{h,cpp} Two slots and routing Envelope.{h,cpp} ADSR with curve - LFO.{h,cpp} Synced LFO + shape editing + LFO.{h,cpp} Continuous phase, sync and shape editing ModulationMatrix.{h,cpp} Connections - FXProcessor.{h,cpp} Reorderable rack + FXProcessor.{h,cpp} Independent effect state per rack slot EffectUnits/ Hyper, Chorus, Flanger, Phaser, Distortion, EQ, Compressor, Delay, Reverb - MacroControls.{h,cpp} 4 assignable macros - RAVEButton.{h,cpp} RAVE snapshot/boost - SynthVoice.{h,cpp} 32-voice pool render path - Engine.{h,cpp} Per-block DSP + MIDI + FX + limiting - GUI/ Knob, Slider, ToggleButton, Display, Panel, - Waveform/Filter/Envelope/LFO displays - Presets/FactoryPresets.cpp 16 factory presets + MacroControls.{h,cpp} Four assignable macros + RAVEButton.{h,cpp} Render-time boost, without rewriting parameters + SynthVoice.{h,cpp} Per-voice oscillators, filters and envelopes + Engine.{h,cpp} 32 voices, MIDI timing, control snapshots, FX + GUI/ Controls and previews + Presets/FactoryPresets.cpp Factory preset definitions Resources/ SVG assets ``` -Key architectural decisions: a fixed 32-voice pool with pre-allocated unison -sub-voices (max 16/osc), scalar processing (SIMD deferred), control-rate modulation -(one level, no modulation-of-modulation), and a `juce::AudioProcessorValueTreeState` -backed parameter store that survives preset save/load together with the mod matrix, -macro assignments and LFO shape data. +The engine splits rendering at MIDI event positions and into segments of at most +64 samples. Buffers and effect instances are prepared ahead of time. GUI edits +use a control-side copy; audio tries to capture that copy once per host block and +keeps its previous settings if an edit is still in progress. + +### Behavior notes + +- Loading a factory preset resets sound parameters before applying its settings, + while preserving UI scale. +- RAVE is automatable and saved with the project. It changes the rendered sound + without moving the underlying knobs; turning it off removes the boost. +- Modulation of Master and FX mixes uses global sources directly. Envelope, + velocity, note and random sources for these destinations use the newest active voice. +- Effect types can repeat across slots. Reordering swaps settings, not running + delay or reverb tails. Preconstructing effects uses more memory but avoids + allocating effect instances while audio is running. +- Filter graphs are schematic previews, not measurements of the nonlinear DSP. +- LFO shape data is stored as JSON text inside the project's XML state. Older + saves containing only the literal `[Array]` did not preserve the shape data + and fall back to the default pattern. ## Building @@ -139,7 +145,10 @@ The Windows script reuses the toolchain file without rewriting it. It injects `moduleinfo.json` because the cross-compiled JUCE manifest helper cannot run on Linux. Keep this manifest's metadata in sync with `CMakeLists.txt`, `cmake/Plugin.cmake`, and the vendored VST3 SDK. MinGW links its C++ and threading -runtimes statically via `-static-libgcc -static-libstdc++ -static`. +runtimes statically via `-static-libgcc -static-libstdc++ -static`. Before staging, +the script verifies the linked module is x86-64, imports none of those MinGW +runtimes, and exports the VST3 entry points, so a module that a stock Windows host +could not load fails the build instead of being staged silently. macOS builds target the host architecture by default. To request a universal binary: diff --git a/Source/EffectUnits/AGENT.md b/Source/EffectUnits/AGENTS.md similarity index 96% rename from Source/EffectUnits/AGENT.md rename to Source/EffectUnits/AGENTS.md index 553737d..60c5f0a 100644 --- a/Source/EffectUnits/AGENT.md +++ b/Source/EffectUnits/AGENTS.md @@ -50,4 +50,4 @@ void prepare (double sampleRate, int) override } ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/Source/EffectUnits/Biquad.h b/Source/EffectUnits/Biquad.h index ddeec83..863d2c3 100644 --- a/Source/EffectUnits/Biquad.h +++ b/Source/EffectUnits/Biquad.h @@ -28,7 +28,7 @@ public: void setLowpass (double freq, double q) { - const double w0 = 2.0 * juce::MathConstants::pi * freq / sampleRate; + const double w0 = juce::MathConstants::twoPi * freq / sampleRate; const double cw = std::cos (w0); const double sw = std::sin (w0); const double alpha = sw / (2.0 * q); @@ -42,7 +42,7 @@ public: void setHighpass (double freq, double q) { - const double w0 = 2.0 * juce::MathConstants::pi * freq / sampleRate; + const double w0 = juce::MathConstants::twoPi * freq / sampleRate; const double cw = std::cos (w0); const double sw = std::sin (w0); const double alpha = sw / (2.0 * q); @@ -57,7 +57,7 @@ public: void setLowShelf (double freq, double gainDb) { const double a = std::pow (10.0, gainDb / 40.0); - const double w0 = 2.0 * juce::MathConstants::pi * freq / sampleRate; + const double w0 = juce::MathConstants::twoPi * freq / sampleRate; const double cw = std::cos (w0); const double sw = std::sin (w0); const double alpha = sw / 2.0 * std::sqrt (2.0); @@ -72,7 +72,7 @@ public: void setHighShelf (double freq, double gainDb) { const double a = std::pow (10.0, gainDb / 40.0); - const double w0 = 2.0 * juce::MathConstants::pi * freq / sampleRate; + const double w0 = juce::MathConstants::twoPi * freq / sampleRate; const double cw = std::cos (w0); const double sw = std::sin (w0); const double alpha = sw / 2.0 * std::sqrt (2.0); @@ -87,7 +87,7 @@ public: void setPeak (double freq, double gainDb, double q) { const double a = std::pow (10.0, gainDb / 40.0); - const double w0 = 2.0 * juce::MathConstants::pi * freq / sampleRate; + const double w0 = juce::MathConstants::twoPi * freq / sampleRate; const double cw = std::cos (w0); const double sw = std::sin (w0); const double alpha = sw / (2.0 * q); diff --git a/Source/EffectUnits/Chorus.cpp b/Source/EffectUnits/Chorus.cpp index 3f6553f..11e4de3 100644 --- a/Source/EffectUnits/Chorus.cpp +++ b/Source/EffectUnits/Chorus.cpp @@ -37,13 +37,13 @@ void ChorusUnit::process (float* l, float* r, int numSamples, const float p[4]) delayL[(size_t) writePos] = inL; delayR[(size_t) writePos] = inR; - lfoPhase += 6.28318530717958647692 * rate / sr; - if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692; + lfoPhase += juce::MathConstants::twoPi * rate / sr; + if (lfoPhase > juce::MathConstants::twoPi) lfoPhase -= juce::MathConstants::twoPi; float sumL = 0.0f, sumR = 0.0f; for (int v = 0; v < 2; ++v) { - const double mod = std::sin (lfoPhase + v * 3.14159265358979323846) * depth; + const double mod = std::sin (lfoPhase + v * juce::MathConstants::pi) * depth; const double d = base + mod + (v == 1 ? width * depth * 0.5 : 0.0); double readPos = writePos - d; if (readPos < 0.0) readPos += len; diff --git a/Source/EffectUnits/Flanger.cpp b/Source/EffectUnits/Flanger.cpp index 9ac922f..1e7d84e 100644 --- a/Source/EffectUnits/Flanger.cpp +++ b/Source/EffectUnits/Flanger.cpp @@ -35,12 +35,16 @@ void FlangerUnit::process (float* l, float* r, int numSamples, const float p[4]) { const float inL = l[i], inR = r[i]; - lfoPhase += 6.28318530717958647692 * rate / sr; - if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692; + lfoPhase += juce::MathConstants::twoPi * rate / sr; + if (lfoPhase > juce::MathConstants::twoPi) lfoPhase -= juce::MathConstants::twoPi; const double mod = std::sin (lfoPhase) * depth; + // base + mod is not guaranteed positive: at high depth the LFO offset + // can exceed the centre delay, so the read position runs ahead of the + // write position and needs the wrap in both directions to stay in range. double readPos = writePos - (base + mod); if (readPos < 0.0) readPos += len; + if (readPos >= (double) len) readPos -= len; const int i0 = (int) readPos; const int i1 = (i0 + 1) % len; const float frac = (float) (readPos - std::floor (readPos)); diff --git a/Source/EffectUnits/Phaser.cpp b/Source/EffectUnits/Phaser.cpp index 66971a2..7fa10a6 100644 --- a/Source/EffectUnits/Phaser.cpp +++ b/Source/EffectUnits/Phaser.cpp @@ -27,11 +27,15 @@ void PhaserUnit::process (float* l, float* r, int numSamples, const float p[4]) for (int i = 0; i < numSamples; ++i) { - lfoPhase += 6.28318530717958647692 * rate / sr; - if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692; + lfoPhase += juce::MathConstants::twoPi * rate / sr; + if (lfoPhase > juce::MathConstants::twoPi) lfoPhase -= juce::MathConstants::twoPi; const float sweep = (float) (0.5 + 0.5 * std::sin (lfoPhase)); - const float a = 0.3f + depth * sweep; // allpass coefficient + // The allpass cascade is stable only while the coefficient stays inside + // (-1, 1). depth can push 0.3 + depth * sweep above 1, which makes every + // stage self-excite, so the filter state runs away to inf/NaN and keeps + // producing it. Clamp at the point where the coefficient is computed. + const float a = juce::jlimit (0.0f, 0.99f, 0.3f + depth * sweep); float outL = l[i] + yL[5] * feedback; float outR = r[i] + yR[5] * feedback; diff --git a/Source/Engine.cpp b/Source/Engine.cpp index d9ad586..37daadc 100644 --- a/Source/Engine.cpp +++ b/Source/Engine.cpp @@ -56,16 +56,30 @@ void Engine::reset() fx.reset(); pitchBend = 0.0f; modWheel = 0.0f; - activeVoiceCount.store (0, std::memory_order_relaxed); mixBuffer.clear(); } +void Engine::requestReset() noexcept +{ + const juce::ScopedLock lock (controlLock); + resetRequested = true; +} + void Engine::captureControls() { const juce::ScopedTryLock lock (controlLock); if (! lock.isLocked()) return; + // A preset change asks for a clean start. reset() runs here, on the audio + // thread, because the message thread must never touch the voice, filter or + // FX state the render is reading. + if (resetRequested) + { + resetRequested = false; + reset(); + } + audioMatrix.connections.assign (matrix.connections.begin(), matrix.connections.begin() + juce::jmin (matrix.size(), ModulationMatrix::kMaxConnections)); for (int i = 0; i < kNumMacros; ++i) @@ -356,11 +370,6 @@ void Engine::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& m modWheel = message.getControllerValue() / 127.0f; } renderUntil (n); - - int active = 0; - for (const auto& voice : voices) - active += voice.isActive() ? 1 : 0; - activeVoiceCount.store (active, std::memory_order_relaxed); } } // namespace serum diff --git a/Source/Engine.h b/Source/Engine.h index 8a5f8ea..9a531e9 100644 --- a/Source/Engine.h +++ b/Source/Engine.h @@ -25,6 +25,12 @@ public: void prepare (double sampleRate, int blockSize, juce::AudioProcessorValueTreeState& apvts); void reset(); + // Ask for the DSP state to be cleared before the next block is rendered. + // Safe to call from the message thread while controlLock is held: the audio + // thread performs the reset in captureControls, so the DSP objects are only + // ever touched by the audio thread. + void requestReset() noexcept; + void processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midi, juce::AudioProcessorValueTreeState& apvts, @@ -43,8 +49,6 @@ public: const WavetableLibrary& getWavetables() const { return wavetables; } void setLfoShapeData (int index, const std::vector& data, int steps); - int getActiveVoiceCount() const { return activeVoiceCount.load (std::memory_order_relaxed); } - private: std::array voices; std::array lfos, controlLfos; @@ -53,6 +57,7 @@ private: ModulationMatrix matrix, audioMatrix; MacroControls macros, audioMacros; juce::CriticalSection controlLock; + bool resetRequested = false; // guarded by controlLock struct ParameterValue { @@ -67,7 +72,6 @@ private: float pitchBend = 0.0f; float modWheel = 0.0f; juce::uint64 noteCounter = 0; - std::atomic activeVoiceCount { 0 }; juce::AudioBuffer mixBuffer; diff --git a/Source/Envelope.cpp b/Source/Envelope.cpp index 9c1140e..9803b61 100644 --- a/Source/Envelope.cpp +++ b/Source/Envelope.cpp @@ -41,8 +41,8 @@ void Envelope::setParams (float attackSec, float decaySec, float sustainLevel, f releaseSamples = std::max (1.0, (double) releaseSec * sr); // curve 0 -> fast attack / long release curve; curve 1 -> slow attack / fast decay - attackShape = 0.3 + curve * 2.7; // 0.3 .. 3.0 - decayShape = 3.0 - curve * 2.7; // 3.0 .. 0.3 + attackShape = (double) attackShapeFor (curve); + decayShape = (double) decayShapeFor (curve); shape.attack = attackSec; shape.decay = decaySec; diff --git a/Source/Envelope.h b/Source/Envelope.h index 13aa2fc..fef239b 100644 --- a/Source/Envelope.h +++ b/Source/Envelope.h @@ -5,6 +5,11 @@ namespace serum { +// Curve-to-shape mapping shared by the envelope DSP and the GUI preview, so the +// two cannot drift apart. +inline float attackShapeFor (float curve) noexcept { return 0.3f + curve * 2.7f; } // 0.3 .. 3.0 +inline float decayShapeFor (float curve) noexcept { return 3.0f - curve * 2.7f; } // 3.0 .. 0.3 + // =========================================================================== // ADSR envelope with curve tension. Durations are in seconds; the sustain // level is 0..1. Retriggering (noteOn while active) starts from the current diff --git a/Source/ExactCompare.h b/Source/ExactCompare.h new file mode 100644 index 0000000..4e2f9fe --- /dev/null +++ b/Source/ExactCompare.h @@ -0,0 +1,42 @@ +#pragma once + +// Exact floating-point comparison helpers. +// +// The engine caches derived state - filter coefficients, per-voice parameter +// snapshots, GUI preview values - and re-derives it only when a stored value +// changes. Those checks must stay bit-exact: an epsilon compare would either +// keep stale cached state or skip a needed rebuild near the threshold, so the +// intent is named here instead of weakened at each call site. +// +// Every first-party use of == / != on floating point goes through one of these +// helpers, and the single -Wfloat-equal suppression lives here rather than +// being repeated across the call sites. + +#include + +#if defined(__GNUC__) || defined(__clang__) + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wfloat-equal" +#endif + +namespace serum +{ + +// Exact equality, no epsilon. Used where the exact value carries meaning, such +// as "the output level is exactly unity" or "the untouched tail is the zero +// default". +inline bool exactly (float a, float b) noexcept { return a == b; } +inline bool exactly (double a, double b) noexcept { return a == b; } + +// Exact inequality, for "the cached value changed" tests. +inline bool changed (float a, float b) noexcept { return a != b; } +inline bool changed (double a, double b) noexcept { return a != b; } + +// True when a stored double that should hold a whole number really does. +inline bool isWholeNumber (double v) noexcept { return std::floor (v) == v; } + +} // namespace serum + +#if defined(__GNUC__) || defined(__clang__) + #pragma GCC diagnostic pop +#endif diff --git a/Source/FXProcessor.cpp b/Source/FXProcessor.cpp index 3e4e9a8..d66e6be 100644 --- a/Source/FXProcessor.cpp +++ b/Source/FXProcessor.cpp @@ -33,17 +33,18 @@ FXProcessor::~FXProcessor() = default; void FXProcessor::prepare (double sampleRate, int maxBlockSize) { for (auto& slotUnits : units) - for (auto& u : slotUnits) - u->prepare (sampleRate, maxBlockSize); + for (auto& unit : slotUnits) + unit->prepare (sampleRate, maxBlockSize); wet.setSize (2, maxBlockSize, false, false, true); reset(); } void FXProcessor::reset() { - for (auto& u : units) - u->reset(); - dry.clear(); + for (auto& slotUnits : units) + for (auto& unit : slotUnits) + unit->reset(); + activeTypes.fill ((int) FxType::Off); wet.clear(); } @@ -61,52 +62,50 @@ juce::String FXProcessor::fxTypeName (int type) case FxType::Compressor: return "Compressor"; case FxType::Delay: return "Delay"; case FxType::Reverb: return "Reverb"; + case FxType::Count: return {}; default: return {}; } } void FXProcessor::process (juce::AudioBuffer& buffer, const FxSlotParams* slots, int numSlots) { + // The rack is stereo end to end; a mono buffer would read a channel that is not there. + jassert (buffer.getNumChannels() >= 2); + if (buffer.getNumChannels() < 2) + return; + const int n = buffer.getNumSamples(); + jassert (n <= wet.getNumSamples()); if (n <= 0) return; - for (int s = 0; s < numSlots; ++s) + for (int s = 0; s < juce::jmin (numSlots, kNumFxSlots); ++s) { const FxSlotParams& slot = slots[s]; - if (slot.type == (int) FxType::Off) - continue; - const int unitIdx = slot.type - 1; - if (unitIdx < 0 || unitIdx >= (int) units.size()) + if (unitIdx < 0 || unitIdx >= kNumEffectTypes) + { + activeTypes[(size_t) s] = (int) FxType::Off; continue; + } - dry.copyFrom (0, 0, buffer, 0, 0, n); - dry.copyFrom (1, 1, buffer, 1, 0, n); + auto& unit = *units[(size_t) s][(size_t) unitIdx]; + if (activeTypes[(size_t) s] != slot.type) + { + unit.reset(); + activeTypes[(size_t) s] = slot.type; + } wet.copyFrom (0, 0, buffer, 0, 0, n); - wet.copyFrom (1, 1, buffer, 1, 0, n); - - units[(size_t) unitIdx]->process (wet.getWritePointer (0), wet.getWritePointer (1), n, slot.p); + wet.copyFrom (1, 0, buffer, 1, 0, n); + unit.process (wet.getWritePointer (0), wet.getWritePointer (1), n, slot.p); const float mix = juce::jlimit (0.0f, 1.0f, slot.mix); - if (mix >= 1.0f) + for (int ch = 0; ch < 2; ++ch) { - buffer.copyFrom (0, 0, wet, 0, 0, n); - buffer.copyFrom (1, 0, wet, 1, 0, n); - } - else if (mix > 0.0f) - { - const float* dL = dry.getReadPointer (0); - const float* dR = dry.getReadPointer (1); - const float* wL = wet.getReadPointer (0); - const float* wR = wet.getReadPointer (1); - float* oL = buffer.getWritePointer (0); - float* oR = buffer.getWritePointer (1); + float* out = buffer.getWritePointer (ch); + const float* effect = wet.getReadPointer (ch); for (int i = 0; i < n; ++i) - { - oL[i] = dL[i] * (1.0f - mix) + wL[i] * mix; - oR[i] = dR[i] * (1.0f - mix) + wR[i] * mix; - } + out[i] = out[i] * (1.0f - mix) + effect[i] * mix; } } } diff --git a/Source/Filter.cpp b/Source/Filter.cpp index 724421f..7e6246e 100644 --- a/Source/Filter.cpp +++ b/Source/Filter.cpp @@ -1,4 +1,5 @@ #include "Filter.h" +#include "ExactCompare.h" namespace serum { @@ -18,14 +19,13 @@ namespace } } -void Filter::prepare (double sampleRate, int maxBlockSize) +void Filter::prepare (double sampleRate) { sr = sampleRate; combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay combWrite = 0; combDamp = 0.0; reset(); - (void) maxBlockSize; } void Filter::reset() @@ -38,14 +38,22 @@ void Filter::reset() a1 = a2 = a3 = 0.0; stage.fill (0.0); for (auto& s : formantState) s.fill (0.0); - std::fill (combLine.begin(), combLine.end(), 0.0f); + // combLine is one second of samples per filter, and a preset change now + // resets on the audio thread. It only needs clearing when the comb has + // actually written to it. + if (combDirty) + { + std::fill (combLine.begin(), combLine.end(), 0.0f); + combDirty = false; + } combWrite = 0; combDamp = 0.0; } void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept { - if (cutoffHz == lastCutoffHz && res == lastRes && type == lastType) + // Cache key: rebuild coefficients only when a parameter actually changes. + if (! changed (cutoffHz, lastCutoffHz) && ! changed (res, lastRes) && type == lastType) return; lastCutoffHz = cutoffHz; lastRes = res; @@ -89,6 +97,8 @@ void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept updateSvf (g, k); break; } + case FilterModel::Comb: + case FilterModel::Count: default: break; } @@ -96,7 +106,8 @@ void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept void Filter::updateSvf (double g, double k) noexcept { - if (g == lastG && k == lastK) + // Cache key: the SVF coefficients only change with g and k. + if (! changed (g, lastG) && ! changed (k, lastK)) return; lastG = g; lastK = k; @@ -105,17 +116,6 @@ void Filter::updateSvf (double g, double k) noexcept a3 = g * a2; } -double Filter::svfLow (double in, double g, double k) noexcept -{ - updateSvf (g, k); - const double v3 = in - ic2eq; - const double v1 = a1 * ic1eq + a2 * v3; - const double v2 = ic2eq + a2 * ic1eq + a3 * v3; - ic1eq = 2.0 * v1 - ic1eq; - ic2eq = 2.0 * v2 - ic2eq; - return v2; -} - double Filter::svfBand (double in, double g, double k) noexcept { updateSvf (g, k); @@ -127,17 +127,6 @@ double Filter::svfBand (double in, double g, double k) noexcept return v1; } -double Filter::svfHigh (double in, double g, double k) noexcept -{ - updateSvf (g, k); - const double v3 = in - ic2eq; - const double v1 = a1 * ic1eq + a2 * v3; - const double v2 = ic2eq + a2 * ic1eq + a3 * v3; - ic1eq = 2.0 * v1 - ic1eq; - ic2eq = 2.0 * v2 - ic2eq; - return in - k * v1 - v2; -} - double Filter::ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept { stages = juce::jlimit (1, 4, stages); @@ -193,6 +182,7 @@ double Filter::comb (double in, double freqHz, double res, double drive) noexcep const float feedback = clampF ((float) res * 0.9f, 0.0f, 0.98f); combLine[(size_t) combWrite] = (float) in + (float) combDamp * feedback; combWrite = (combWrite + 1) % len; + combDirty = true; return (double) y; } @@ -204,12 +194,12 @@ double Filter::formant (double in) noexcept for (int i = 0; i < 3; ++i) { const auto& c = formantCoefficients[(size_t) i]; - const double a1 = c[0]; - const double a2 = c[1]; - const double a3 = c[2]; + const double c0 = c[0]; + const double c1 = c[1]; + const double c2 = c[2]; const double v3 = in - formantState[(size_t) i][1]; - const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3; - const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * v3; + const double v1 = c0 * formantState[(size_t) i][0] + c1 * v3; + const double v2 = formantState[(size_t) i][1] + c1 * formantState[(size_t) i][0] + c2 * v3; formantState[(size_t) i][0] = 2.0 * v1 - formantState[(size_t) i][0]; formantState[(size_t) i][1] = 2.0 * v2 - formantState[(size_t) i][1]; out += v1 * gains[i]; @@ -269,6 +259,7 @@ float Filter::processSample (float in, float cutoffHz, float res, float drive, i case FilterModel::Screamer: out = screamer (in, drive); break; + case FilterModel::Count: default: break; } diff --git a/Source/Filter.h b/Source/Filter.h index 9c1279e..47801d7 100644 --- a/Source/Filter.h +++ b/Source/Filter.h @@ -14,7 +14,7 @@ namespace serum class Filter { public: - void prepare (double sampleRate, int maxBlockSize); + void prepare (double sampleRate); void reset(); // Process a mono buffer in-place. cutoffNorm is 0..1 (mapped to 20Hz..20kHz). @@ -43,6 +43,7 @@ private: std::vector combLine; int combWrite = 0; double combDamp = 0.0; + bool combDirty = false; // set once comb() has written to combLine // Formant: three parallel bandpass SVFs (state pairs). std::array, 3> formantState { { { { 0.0, 0.0 } }, { { 0.0, 0.0 } }, { { 0.0, 0.0 } } } }; @@ -50,9 +51,7 @@ private: void updateCoefficients (float cutoffHz, float res, int type) noexcept; void updateSvf (double g, double k) noexcept; - double svfLow (double in, double g, double k) noexcept; double svfBand (double in, double g, double k) noexcept; - double svfHigh (double in, double g, double k) noexcept; double ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept; double comb (double in, double freqHz, double res, double drive) noexcept; double formant (double in) noexcept; diff --git a/Source/FilterBank.cpp b/Source/FilterBank.cpp index ba39163..ea007a5 100644 --- a/Source/FilterBank.cpp +++ b/Source/FilterBank.cpp @@ -1,14 +1,15 @@ #include "FilterBank.h" +#include "ExactCompare.h" namespace serum { -void FilterBank::prepare (double sampleRate, int maxBlockSize) +void FilterBank::prepare (double sampleRate) { - f1L.prepare (sampleRate, maxBlockSize); - f1R.prepare (sampleRate, maxBlockSize); - f2L.prepare (sampleRate, maxBlockSize); - f2R.prepare (sampleRate, maxBlockSize); + f1L.prepare (sampleRate); + f1R.prepare (sampleRate); + f2L.prepare (sampleRate); + f2R.prepare (sampleRate); } void FilterBank::reset() @@ -67,8 +68,9 @@ void FilterBank::process (float* l, float* r, int numSamples, const FilterBankPa applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz); } - // Post-filter output level. - if (p.out != 1.0f) + // Post-filter output level. Skip the multiply only at exact unity so the + // bypass never changes the rendered samples. + if (! exactly (p.out, 1.0f)) { for (int i = 0; i < numSamples; ++i) { diff --git a/Source/FilterBank.h b/Source/FilterBank.h index 2728c8e..632b21f 100644 --- a/Source/FilterBank.h +++ b/Source/FilterBank.h @@ -30,7 +30,7 @@ struct FilterBankParams class FilterBank { public: - void prepare (double sampleRate, int maxBlockSize); + void prepare (double sampleRate); void reset(); void process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept; diff --git a/Source/GUI/AGENT.md b/Source/GUI/AGENTS.md similarity index 92% rename from Source/GUI/AGENT.md rename to Source/GUI/AGENTS.md index 1e6972d..ffcf603 100644 --- a/Source/GUI/AGENT.md +++ b/Source/GUI/AGENTS.md @@ -3,7 +3,6 @@ ## Owned files - Related editor implementation: `../PluginEditor.h`, `../PluginEditor.cpp` -- SerumLookAndFeel.h - Knob.h, Knob.cpp - Slider.h, Slider.cpp - ToggleButton.h, ToggleButton.cpp @@ -16,7 +15,6 @@ ## Rules -- Draw all controls through `SerumLookAndFeel`. - Use `theme::` colours from Resources.h instead of hardcoded colour values. - Own GUI attachments with `std::unique_ptr`. - Own component children by value or with `std::unique_ptr`; `addAndMakeVisible` only attaches and shows them, and does not manage their lifetime. @@ -44,4 +42,4 @@ const float v = processor.parameters.getRawParameterValue (ids::f1Cutoff) : 0.0f; ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/Source/GUI/EnvelopeDisplay.cpp b/Source/GUI/EnvelopeDisplay.cpp index d2831ab..53453f1 100644 --- a/Source/GUI/EnvelopeDisplay.cpp +++ b/Source/GUI/EnvelopeDisplay.cpp @@ -1,11 +1,14 @@ #include "EnvelopeDisplay.h" +#include "../Envelope.h" +#include "../ExactCompare.h" namespace serum { void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c) { - if (attack == a && decay == d && sustain == s && release == r && curve == c) + if (! changed (attack, a) && ! changed (decay, d) && ! changed (sustain, s) + && ! changed (release, r) && ! changed (curve, c)) return; attack = a; decay = d; sustain = s; release = r; curve = c; repaint(); @@ -19,8 +22,8 @@ void EnvelopeDisplay::paint (juce::Graphics& g) g.setColour (theme::outline); g.drawRoundedRectangle (b.expanded (4.0f).reduced (0.5f), 4.0f, 1.0f); - const float atkShape = 0.3f + curve * 2.7f; - const float decShape = 3.0f - curve * 2.7f; + const float atkShape = attackShapeFor (curve); + const float decShape = decayShapeFor (curve); // Normalise durations for display, allowing a short sustain plateau. const float aSec = maps::toSeconds (attack); diff --git a/Source/GUI/FilterDisplay.cpp b/Source/GUI/FilterDisplay.cpp index fc92730..f1e2983 100644 --- a/Source/GUI/FilterDisplay.cpp +++ b/Source/GUI/FilterDisplay.cpp @@ -1,8 +1,12 @@ #include "FilterDisplay.h" +#include "../ExactCompare.h" namespace serum { +// Schematic frequency-response preview for this view only. It does not model the +// real DSP in Source/Filter.cpp, nor the drive, the slope or modulation, so it is +// not expected to match the nonlinear filter numerically. float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int type) { const float w = (cutoffHz > 1.0f) ? freqHz / cutoffHz : 1.0f; @@ -29,7 +33,9 @@ float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int typ } return m / 3.0f; } + case FilterModel::LadderLP: case FilterModel::Diode: + case FilterModel::Count: default: return (denom > 1e-6f) ? 1.0f / denom : 0.0f; } @@ -37,7 +43,8 @@ float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int typ void FilterDisplay::setParams (int t, float c, float r, float d, int s) { - if (type == t && cutoff == c && res == r && drive == d && slope == s) + if (type == t && ! changed (cutoff, c) && ! changed (res, r) + && ! changed (drive, d) && slope == s) return; type = t; cutoff = c; res = r; drive = d; slope = s; repaint(); diff --git a/Source/GUI/Knob.cpp b/Source/GUI/Knob.cpp index 4597f46..d061438 100644 --- a/Source/GUI/Knob.cpp +++ b/Source/GUI/Knob.cpp @@ -3,6 +3,17 @@ namespace serum { +namespace +{ + // One shared look-and-feel for every knob. It outlives every Knob because + // it is a function-local static and the GUI owns the knobs. + KnobLookAndFeel& knobStyle() + { + static KnobLookAndFeel style; + return style; + } +} + Knob::Knob (const juce::String& name, std::function fmt) : formatter (std::move (fmt)) { @@ -12,6 +23,10 @@ Knob::Knob (const juce::String& name, std::function fmt) setDoubleClickReturnValue (true, 0.5); setName (name); setComponentID (name); + + // Draw the dial and the name strip through the theme, not through the + // shared default look-and-feel. + setLookAndFeel (&knobStyle()); } juce::String Knob::getTextFromValue (double value) @@ -21,6 +36,32 @@ juce::String Knob::getTextFromValue (double value) return formatPercent ((float) value); } +// The dial takes the top of the footprint and the name takes a strip at the +// bottom, so the name is always visible without a tooltip hover. +void Knob::resized() +{ + juce::Slider::resized(); + textArea = getLocalBounds().removeFromBottom ( + juce::jmin (KnobLookAndFeel::labelHeight, getHeight() / 2)); +} + +void Knob::paint (juce::Graphics& g) +{ + juce::Slider::paint (g); + + if (textArea.isEmpty()) + return; + + // Dim while disabled, bright while hovered, otherwise the normal label + // colour, so the three states are distinguishable without colour alone. + const auto colour = isEnabled() ? (isMouseOverOrDragging() ? theme::text : theme::textDim) + : theme::outline; + + g.setColour (colour); + g.setFont (juce::Font (11.0f)); + g.drawText (getName(), textArea, juce::Justification::centred, false); +} + void Knob::mouseDown (const juce::MouseEvent& e) { juce::Slider::mouseDown (e); @@ -56,6 +97,24 @@ void Knob::showValuePopup (const juce::MouseEvent& e) if (topLevel == nullptr) return; + constexpr int baseW = 84; + constexpr int baseH = 24; + constexpr int gap = 10; + + // The interface scale is applied to a parent component, not to this slider. + // Measure it from the local-space distance between two neighbouring points, + // so a scaled interface does not leave the readout at a fixed size. + float scale = 1.0f; + if (auto* parent = getParentComponent()) + { + const auto origin = getLocalPoint (parent, juce::Point (0, 0)).toFloat(); + const auto unitX = getLocalPoint (parent, juce::Point (1, 0)).toFloat(); + scale = origin.getDistanceFrom (unitX); + } + scale = juce::jlimit (0.5f, 2.5f, scale); + const int w = juce::roundToInt (baseW * scale); + const int h = juce::roundToInt (baseH * scale); + if (valuePopup == nullptr) { valuePopup = std::make_unique(); @@ -63,19 +122,20 @@ void Knob::showValuePopup (const juce::MouseEvent& e) valuePopup->setInterceptsMouseClicks (false, false); valuePopup->setColour (juce::Label::backgroundColourId, juce::Colours::transparentBlack); valuePopup->setColour (juce::Label::textColourId, theme::text); - valuePopup->setFont (juce::Font (14.0f, juce::Font::bold)); valuePopup->setJustificationType (juce::Justification::centred); topLevel->addAndMakeVisible (valuePopup.get()); } + valuePopup->setFont (juce::Font (14.0f * scale, juce::Font::bold)); valuePopup->setText (getTextFromValue (getValue()), juce::dontSendNotification); - constexpr int w = 84; - constexpr int h = 24; - constexpr int gap = 10; - - const auto cursor = topLevel->getLocalPoint (nullptr, e.getScreenPosition()); - valuePopup->setBounds (cursor.getX() - w / 2, cursor.getY() - h - gap, w, h); + // Keep the readout inside the window so a knob near an edge still shows it. + const auto popupPos = topLevel->getLocalPoint (nullptr, e.getScreenPosition()); + valuePopup->setBounds (juce::jlimit (0, juce::jmax (0, topLevel->getWidth() - w), + popupPos.getX() - w / 2), + juce::jlimit (0, juce::jmax (0, topLevel->getHeight() - h), + popupPos.getY() - h - gap), + w, h); } void Knob::hideValuePopup() @@ -83,4 +143,12 @@ void Knob::hideValuePopup() valuePopup.reset(); } +// A section switch can hide the view holding this knob mid-drag. The popup is a +// sibling of that view, so it has to be taken down here or it stays on screen. +void Knob::visibilityChanged() +{ + if (! isVisible()) + hideValuePopup(); +} + } // namespace serum diff --git a/Source/GUI/Knob.h b/Source/GUI/Knob.h index 58f4142..3a450c3 100644 --- a/Source/GUI/Knob.h +++ b/Source/GUI/Knob.h @@ -3,13 +3,16 @@ #include #include #include "../Resources.h" +#include "KnobLookAndFeel.h" namespace serum { // =========================================================================== -// Rotary knob — a juce::Slider styled by SerumLookAndFeel. The value readout is -// formatted by a user-provided function via getTextFromValue(). +// Rotary knob — a juce::Slider drawn by KnobLookAndFeel. The knob paints its +// own name below the dial, so no control is a bare arc whose only hint is a +// tooltip. The value readout is formatted by a user-provided function via +// getTextFromValue(). // // While the knob is being dragged it also shows a small floating label near the // mouse cursor with the live (formatted) value, giving immediate feedback @@ -23,8 +26,16 @@ public: void setFormatter (std::function f) { formatter = std::move (f); } + // Value a double click returns to. Call it with the parameter's own default + // so the gesture restores the real default, never a hardcoded 0.5. + void setDefaultValue (double v) { setDoubleClickReturnValue (true, v); } + juce::String getTextFromValue (double value) override; + void paint (juce::Graphics& g) override; + void resized() override; + void visibilityChanged() override; + void mouseDown (const juce::MouseEvent& e) override; void mouseDrag (const juce::MouseEvent& e) override; void mouseUp (const juce::MouseEvent& e) override; @@ -36,6 +47,7 @@ private: std::function formatter; std::unique_ptr valuePopup; + juce::Rectangle textArea; // name strip under the dial }; } // namespace serum diff --git a/Source/GUI/KnobLookAndFeel.cpp b/Source/GUI/KnobLookAndFeel.cpp new file mode 100644 index 0000000..c471d2b --- /dev/null +++ b/Source/GUI/KnobLookAndFeel.cpp @@ -0,0 +1,47 @@ +#include "KnobLookAndFeel.h" + +namespace serum +{ + +void KnobLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float rotaryStartAngle, float rotaryEndAngle, + juce::Slider& slider) +{ + const auto dial = juce::Rectangle ((float) x, (float) y, (float) width, (float) height); + if (dial.isEmpty()) + return; + + const float radius = juce::jmin (dial.getWidth(), dial.getHeight()) * 0.5f - 2.0f; + if (radius <= 1.0f) + return; + + const float lineW = juce::jlimit (1.5f, 5.0f, radius * 0.22f); + const float arcR = radius - lineW * 0.5f; + const float centreX = dial.getCentreX(); + const float centreY = dial.getCentreY(); + const float toAngle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + const bool live = slider.isEnabled(); + + juce::Path track; + track.addCentredArc (centreX, centreY, arcR, arcR, 0.0f, rotaryStartAngle, rotaryEndAngle, true); + g.setColour (theme::outline); + g.strokePath (track, juce::PathStrokeType (lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); + + if (live) + { + juce::Path value; + value.addCentredArc (centreX, centreY, arcR, arcR, 0.0f, rotaryStartAngle, toAngle, true); + g.setColour (theme::accent.withAlpha (slider.isMouseOverOrDragging() ? 1.0f : 0.75f)); + g.strokePath (value, juce::PathStrokeType (lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); + } + + const float pointer = radius * 0.62f; + g.setColour (live ? theme::text : theme::textDim); + g.drawLine (centreX + std::sin (toAngle) * pointer * 0.35f, + centreY - std::cos (toAngle) * pointer * 0.35f, + centreX + std::sin (toAngle) * pointer, + centreY - std::cos (toAngle) * pointer, + juce::jmax (1.5f, lineW * 0.5f)); +} + +} // namespace serum diff --git a/Source/GUI/KnobLookAndFeel.h b/Source/GUI/KnobLookAndFeel.h new file mode 100644 index 0000000..87c3442 --- /dev/null +++ b/Source/GUI/KnobLookAndFeel.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include "../Resources.h" + +namespace serum +{ + +// =========================================================================== +// Look-and-feel for Knob. +// +// Two jobs: +// +// 1. Draw the rotary with theme:: colours. The shared default look-and-feel +// paints it with its own palette, which ignores the rest of the editor. +// +// 2. Leave room for the knob's name. juce::LookAndFeel_V4 reduces the rotary +// bounds by a fixed ten pixels on each side, so a 76 pixel knob loses +// 20 pixels of dial and a smaller knob loses most of it. This version +// keeps the dial whole inside its footprint and reserves a text strip +// under it, which is the strip Knob paints its name into. +// =========================================================================== +class KnobLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + // Height of the name strip reserved under the dial. + static constexpr int labelHeight = 16; + + void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float rotaryStartAngle, float rotaryEndAngle, + juce::Slider& slider) override; +}; + +} // namespace serum diff --git a/Source/GUI/LFODisplay.cpp b/Source/GUI/LFODisplay.cpp index dbbae37..f97152e 100644 --- a/Source/GUI/LFODisplay.cpp +++ b/Source/GUI/LFODisplay.cpp @@ -1,52 +1,28 @@ #include "LFODisplay.h" +#include "../ExactCompare.h" namespace serum { void LFODisplay::setShapeData (const std::vector& data, int s) { - const int newSteps = juce::jlimit (2, 64, s); - const size_t dataSize = std::min (data.size(), size_t (64)); - if (steps == newSteps && shapeData.size() == 64 + const int newSteps = juce::jlimit (2, LFO::kShapePoints, s); + const auto dataSize = (std::ptrdiff_t) std::min (data.size(), size_t (LFO::kShapePoints)); + if (steps == newSteps && (int) shapeData.size() == LFO::kShapePoints && std::equal (data.begin(), data.begin() + dataSize, shapeData.begin()) - && std::all_of (shapeData.begin() + dataSize, shapeData.end(), [] (float v) { return v == 0.0f; })) + && std::all_of (shapeData.begin() + dataSize, shapeData.end(), [] (float v) { return exactly (v, 0.0f); })) return; steps = newSteps; shapeData.assign (data.begin(), data.begin() + dataSize); - shapeData.resize (64, 0.0f); + shapeData.resize ((size_t) LFO::kShapePoints, 0.0f); repaint(); } float LFODisplay::valueAt (float phase) const noexcept { - switch ((LfoShape) shape) - { - case LfoShape::Sine: return std::sin (phase * 6.2831853f); - case LfoShape::Triangle: return 1.0f - 4.0f * std::abs (phase - 0.5f); - case LfoShape::Saw: return 2.0f * phase - 1.0f; - case LfoShape::Square: return (phase < 0.5f) ? 1.0f : -1.0f; - case LfoShape::SampleHold: - { - const int step = juce::jlimit (0, 15, (int) (phase * 16.0f)); - return ((step * 2654435761u) & 0xffffu) / 32768.0f - 1.0f; - } - case LfoShape::StepSeq: - { - const int idx = juce::jlimit (0, steps - 1, (int) (phase * steps)); - return shapeData.empty() ? 0.0f : shapeData[(size_t) idx]; - } - case LfoShape::Freehand: - { - if (shapeData.empty()) return 0.0f; - const float pos = phase * (float) (steps - 1); - const int i0 = (int) pos; - const int i1 = juce::jmin (i0 + 1, steps - 1); - const float frac = pos - (float) i0; - return shapeData[(size_t) i0] * (1.0f - frac) + shapeData[(size_t) i1] * frac; - } - default: return 0.0f; - } + return lfoShapeValue (phase, (LfoShape) shape, steps, + shapeData.data(), (int) shapeData.size(), kSampleHoldLevel); } void LFODisplay::paint (juce::Graphics& g) @@ -75,6 +51,26 @@ void LFODisplay::paint (juce::Graphics& g) } } + // Sample and hold is stateful in the audio path, so the preview draws the + // held level as a flat schematic with its hold steps marked, and labels it, + // rather than inventing a sequence the synth will never play. + if (shape == (int) LfoShape::SampleHold) + { + const float stepW = (b.getWidth() - 8.0f) / (float) steps; + g.setColour (theme::outline); + for (int i = 1; i < steps; ++i) + { + const float x = b.getX() + 4.0f + stepW * (float) i; + g.drawLine (x, midY - b.getHeight() * 0.28f, x, midY + b.getHeight() * 0.28f, 1.0f); + } + + g.setColour (theme::textDim); + g.setFont (10.0f); + g.drawText ("S&H schematic", + juce::Rectangle (b.getX(), b.getY(), b.getWidth(), 14.0f).toNearestInt(), + juce::Justification::centred, false); + } + juce::Path path; path.startNewSubPath (b.getX() + 4.0f, midY); const int n = 256; @@ -99,7 +95,7 @@ void LFODisplay::editAt (float x, float y) const float v = juce::jlimit (-1.0f, 1.0f, (midY - y) / (b.getHeight() * 0.42f)); if (shapeData.empty()) - shapeData.assign (64, 0.0f); + shapeData.assign ((size_t) LFO::kShapePoints, 0.0f); if (shape == (int) LfoShape::StepSeq) { diff --git a/Source/GUI/LFODisplay.h b/Source/GUI/LFODisplay.h index 8d6eec2..1b50225 100644 --- a/Source/GUI/LFODisplay.h +++ b/Source/GUI/LFODisplay.h @@ -3,12 +3,17 @@ #include #include "../Resources.h" #include "../Params.h" +#include "../LFO.h" namespace serum { // =========================================================================== // LFO shape view. Step-sequencer and freehand shapes are click/drag editable. +// The preview evaluates the same lfoShapeValue() function as the audio path, so +// the drawn shape cannot drift from the sound. Sample and hold has no phase +// function (the audio level is stateful), so its preview is a labelled +// schematic rather than a fabricated sequence. // =========================================================================== class LFODisplay : public juce::Component { @@ -21,13 +26,21 @@ public: void mouseDown (const juce::MouseEvent& e) override; void mouseDrag (const juce::MouseEvent& e) override; + // Level the sample-and-hold preview draws. The audio LFO holds a level that + // the display cannot observe on the message thread, so the preview shows a + // flat schematic line at this value instead of inventing a sequence. + static constexpr float kSampleHoldLevel = 0.0f; + + // Value of the current shape at `phase` (0..1); the audio path calls the + // same lfoShapeValue() with its live sample-and-hold level. + float valueAt (float phase) const noexcept; + private: int shape = 0; std::vector shapeData; int steps = 16; std::function&, int)> onEdited; - float valueAt (float phase) const noexcept; void editAt (float x, float y); }; diff --git a/Source/GUI/SerumLookAndFeel.h b/Source/GUI/SerumLookAndFeel.h deleted file mode 100644 index e921d08..0000000 --- a/Source/GUI/SerumLookAndFeel.h +++ /dev/null @@ -1,156 +0,0 @@ -#pragma once - -#include -#include "../Resources.h" - -namespace serum -{ - -// =========================================================================== -// Dark vector look-and-feel. Rotary and linear sliders are drawn with juce::Path -// (arcs + indicator + thumb) so they stay crisp at every UI scale. -// =========================================================================== -class SerumLookAndFeel : public juce::LookAndFeel_V4 -{ -public: - SerumLookAndFeel() - { - setColour (juce::Slider::backgroundColourId, theme::panel); - setColour (juce::Slider::thumbColourId, theme::accent); - setColour (juce::Slider::trackColourId, theme::outline); - setColour (juce::Slider::rotarySliderFillColourId, theme::accent); - setColour (juce::Slider::rotarySliderOutlineColourId, theme::panelRaised); - setColour (juce::Slider::textBoxTextColourId, theme::text); - setColour (juce::Slider::textBoxBackgroundColourId, theme::panel); - setColour (juce::Slider::textBoxHighlightColourId, theme::accent); - setColour (juce::Slider::textBoxOutlineColourId, theme::outline); - - setColour (juce::ComboBox::backgroundColourId, theme::panelRaised); - setColour (juce::ComboBox::textColourId, theme::text); - setColour (juce::ComboBox::arrowColourId, theme::textDim); - setColour (juce::ComboBox::outlineColourId, theme::outline); - setColour (juce::ComboBox::buttonColourId, theme::panelRaised); - setColour (juce::ComboBox::focusedOutlineColourId, theme::accent); - setColour (juce::PopupMenu::backgroundColourId, theme::panelRaised); - setColour (juce::PopupMenu::textColourId, theme::text); - setColour (juce::PopupMenu::highlightedBackgroundColourId, theme::accent); - setColour (juce::PopupMenu::highlightedTextColourId, juce::Colours::black); - - setColour (juce::TextButton::buttonColourId, theme::panelRaised); - setColour (juce::TextButton::buttonOnColourId, theme::accent); - setColour (juce::TextButton::textColourOffId, theme::textDim); - setColour (juce::TextButton::textColourOnId, juce::Colours::black); - - setColour (juce::TextEditor::backgroundColourId, theme::panel); - setColour (juce::TextEditor::textColourId, theme::text); - setColour (juce::TextEditor::outlineColourId, theme::outline); - setColour (juce::TextEditor::focusedOutlineColourId, theme::accent); - } - - void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, - float sliderPos, float, float, juce::Slider& slider) override - { - const int labelH = 18; - const int knobH = height - labelH; - const int knobSize = juce::jmin (width, knobH) - 6; - const int cx = x + width / 2; - const int cy = y + knobH / 2; - const float radius = knobSize * 0.5f; - - const juce::Rectangle area ((float) cx - radius, (float) cy - radius, - radius * 2.0f, radius * 2.0f); - - // Body. - g.setColour (theme::panelRaised); - g.fillEllipse (area); - g.setColour (theme::outline); - g.drawEllipse (area, 1.0f); - - const float startAngle = juce::MathConstants::pi * 1.25f; // 225 deg - const float endAngle = juce::MathConstants::pi * -0.25f; - const float valueAngle = startAngle + sliderPos * (endAngle - startAngle); - - // Track arc. - juce::Path track; - track.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f, - 0.0f, startAngle, endAngle, true); - g.setColour (theme::outline); - g.strokePath (track, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved)); - - // Value arc. - juce::Path valueArc; - valueArc.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f, - 0.0f, startAngle, valueAngle, true); - g.setColour (theme::accent); - g.strokePath (valueArc, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved)); - - // Indicator. - const float indX = (float) cx + (radius - 9.0f) * std::cos (valueAngle); - const float indY = (float) cy + (radius - 9.0f) * std::sin (valueAngle); - g.setColour (theme::text); - g.drawLine ((float) cx, (float) cy, indX, indY, 2.0f); - g.setColour (theme::text); - g.fillEllipse (indX - 2.0f, indY - 2.0f, 4.0f, 4.0f); - - // Value readout. - g.setColour (theme::text); - g.setFont (juce::Font (10.0f, juce::Font::bold)); - g.drawText (slider.getTextFromValue (slider.getValue()), - juce::Rectangle (x, y, width, knobH - (int) radius + 8), - juce::Justification::centred, false); - - // Name label. - g.setColour (theme::textDim); - g.setFont (juce::Font (11.0f)); - g.drawText (slider.getName(), juce::Rectangle (x, y + knobH, width, labelH), - juce::Justification::centred, false); - } - - void drawLinearSlider (juce::Graphics& g, int x, int y, int width, int height, - float sliderPos, float, float, juce::Slider::SliderStyle, juce::Slider& slider) override - { - const bool vertical = height > width; - juce::Rectangle track; - - if (vertical) - { - track = juce::Rectangle (x + width / 2 - 3, y + 8, 6, height - 16); - } - else - { - track = juce::Rectangle (x + 8, y + height / 2 - 3, width - 16, 6); - } - - g.setColour (theme::outline); - g.fillRoundedRectangle (track.toFloat(), 3.0f); - - juce::Rectangle fill; - if (vertical) - { - const float h = track.getHeight() * sliderPos; - fill = juce::Rectangle ((float) track.getX(), (float) track.getBottom() - h, - (float) track.getWidth(), h); - } - else - { - fill = juce::Rectangle ((float) track.getX(), (float) track.getY(), - track.getWidth() * sliderPos, (float) track.getHeight()); - } - g.setColour (theme::accent); - g.fillRoundedRectangle (fill, 3.0f); - - // Thumb. - const float thumbC = vertical ? (track.getBottom() - track.getHeight() * sliderPos) - : (track.getX() + track.getWidth() * sliderPos); - juce::Point thumb (vertical ? (float) track.getCentreX() : thumbC, - vertical ? thumbC : (float) track.getCentreY()); - g.setColour (theme::text); - g.fillEllipse (thumb.x - 6.0f, thumb.y - 6.0f, 12.0f, 12.0f); - - g.setColour (theme::textDim); - g.setFont (juce::Font (10.0f)); - g.drawText (slider.getName(), x, y, width, height, juce::Justification::bottomLeft, false); - } -}; - -} // namespace serum diff --git a/Source/GUI/Slider.cpp b/Source/GUI/Slider.cpp index 5f5b5d6..2b7892c 100644 --- a/Source/GUI/Slider.cpp +++ b/Source/GUI/Slider.cpp @@ -3,8 +3,8 @@ namespace serum { -Slider::Slider (const juce::String& name, std::function formatter) - : Knob (name, std::move (formatter)) +Slider::Slider (const juce::String& name, std::function fmt) + : Knob (name, std::move (fmt)) { setSliderStyle (juce::Slider::LinearHorizontal); setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); diff --git a/Source/GUI/ToggleButton.cpp b/Source/GUI/ToggleButton.cpp index d9c6f42..6839497 100644 --- a/Source/GUI/ToggleButton.cpp +++ b/Source/GUI/ToggleButton.cpp @@ -33,21 +33,29 @@ void ToggleButton::paint (juce::Graphics& g) const float ledSize = juce::jmin (bounds.getHeight() - 16.0f, 16.0f); const juce::Rectangle led ((bounds.getWidth() - ledSize) * 0.5f, 4.0f, ledSize, ledSize); + const bool live = isEnabled(); + const bool hovered = live && isMouseOverOrDragging(); + if (state) { - g.setColour (onColour.withAlpha (0.35f)); + // Hover brightens the halo, disabled dims it, so the three states are + // distinguishable without relying on colour alone. + const float halo = live ? (hovered ? 0.55f : 0.35f) : 0.15f; + g.setColour (onColour.withAlpha (halo)); g.fillEllipse (led.expanded (6.0f)); - g.setColour (onColour); + g.setColour (live ? onColour : onColour.withAlpha (0.4f)); } else { - g.setColour (theme::outline); + g.setColour (hovered ? theme::textDim : theme::outline); } g.fillEllipse (led); g.setColour (state ? juce::Colours::black : theme::textDim); - g.drawEllipse (led, 1.0f); + g.drawEllipse (led, live ? (hovered ? 1.6f : 1.0f) : 0.8f); - g.setColour (state ? theme::text : theme::textDim); + const auto labelColour = ! live ? theme::outline + : (state ? theme::text : (hovered ? theme::text : theme::textDim)); + g.setColour (labelColour); g.setFont (juce::Font (11.0f)); g.drawText (label, 0, (int) (led.getBottom() + 2), getWidth(), 14, juce::Justification::centred, false); } diff --git a/Source/GUI/WaveformDisplay.h b/Source/GUI/WaveformDisplay.h index 6bc58ef..c894baf 100644 --- a/Source/GUI/WaveformDisplay.h +++ b/Source/GUI/WaveformDisplay.h @@ -1,6 +1,7 @@ #pragma once #include +#include "../ExactCompare.h" #include "../Resources.h" #include "../Wavetable.h" @@ -15,7 +16,7 @@ class WaveformDisplay : public juce::Component public: void setWavetables (const WavetableLibrary* lib) { if (wtLib != lib) { wtLib = lib; repaint(); } } void setWaveIndex (int index) { if (wave != index) { wave = index; repaint(); } } - void setFramePosition (float pos) { if (wtPos != pos) { wtPos = pos; repaint(); } } + void setFramePosition (float pos) { if (changed (wtPos, pos)) { wtPos = pos; repaint(); } } void setEnabled (bool e) { if (enabled != e) { enabled = e; repaint(); } } void paint (juce::Graphics& g) override; diff --git a/Source/LFO.cpp b/Source/LFO.cpp index 9530a24..4352e19 100644 --- a/Source/LFO.cpp +++ b/Source/LFO.cpp @@ -1,4 +1,5 @@ #include "LFO.h" +#include "ExactCompare.h" namespace serum { @@ -40,7 +41,7 @@ void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph, // Only restart the delay/fade timing when those knobs actually change, // so repeated block-rate setParams calls don't keep resetting the LFO. - if (delay != prevDelayParam || fade != prevFadeParam) + if (changed (delay, prevDelayParam) || changed (fade, prevFadeParam)) { delayCounter = delaySeconds * sr; fadeCounter = 0.0; @@ -62,39 +63,51 @@ void LFO::setShapeData (const std::vector& data, int steps) ? juce::jlimit (-1.0f, 1.0f, data[i]) : 0.0f; } -float LFO::shapeValue() noexcept +float lfoShapeValue (float phase, LfoShape shape, int steps, + const float* data, int dataSize, float sampleHoldValue) noexcept { - const float p = getPhase(); - switch ((LfoShape) shape) + const int numSteps = juce::jlimit (2, LFO::kShapePoints, steps); + const int numData = data != nullptr ? juce::jlimit (0, LFO::kShapePoints, dataSize) : 0; + + auto point = [data, numData] (int index) noexcept + { + return numData > 0 ? data[(size_t) juce::jlimit (0, numData - 1, index)] : 0.0f; + }; + + switch (shape) { case LfoShape::Sine: - return std::sin (p * juce::MathConstants::twoPi); + return std::sin (phase * juce::MathConstants::twoPi); case LfoShape::Triangle: - return 1.0f - 4.0f * std::abs (p - 0.5f); + return 1.0f - 4.0f * std::abs (phase - 0.5f); case LfoShape::Saw: - return 2.0f * p - 1.0f; + return 2.0f * phase - 1.0f; case LfoShape::Square: - return (p < 0.5f) ? 1.0f : -1.0f; + return (phase < 0.5f) ? 1.0f : -1.0f; case LfoShape::SampleHold: - return holdValue; + return sampleHoldValue; case LfoShape::StepSeq: - { - const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps)); - return shapeBuffer[(size_t) idx]; - } + return point (juce::jlimit (0, numSteps - 1, (int) (phase * (float) numSteps))); case LfoShape::Freehand: { - const float pos = p * (float) (shapeSteps - 1); + const float pos = phase * (float) (numSteps - 1); const int i0 = (int) pos; - const int i1 = juce::jmin (i0 + 1, shapeSteps - 1); + const int i1 = juce::jmin (i0 + 1, numSteps - 1); const float frac = pos - (float) i0; - return shapeBuffer[(size_t) i0] * (1.0f - frac) + shapeBuffer[(size_t) i1] * frac; + return point (i0) * (1.0f - frac) + point (i1) * frac; } + case LfoShape::Count: default: return 0.0f; } } +float LFO::shapeValue() noexcept +{ + return lfoShapeValue (getPhase(), (LfoShape) shape, shapeSteps, + shapeBuffer.data(), (int) shapeBuffer.size(), holdValue); +} + float LFO::process() noexcept { return advance (1); diff --git a/Source/LFO.h b/Source/LFO.h index 2974e9c..8068dae 100644 --- a/Source/LFO.h +++ b/Source/LFO.h @@ -62,4 +62,13 @@ private: float shapeValue() noexcept; }; +// =========================================================================== +// Pure shape evaluation shared by the audio render (LFO::shapeValue) and the +// GUI preview (LFODisplay::valueAt), so the drawn shape cannot drift from the +// played one. `sampleHoldValue` supplies the current held level for +// LfoShape::SampleHold, which is stateful in the audio path. +// =========================================================================== +float lfoShapeValue (float phase, LfoShape shape, int steps, + const float* data, int dataSize, float sampleHoldValue) noexcept; + } // namespace serum diff --git a/Source/MacroControls.cpp b/Source/MacroControls.cpp index f01add6..c6aa2f4 100644 --- a/Source/MacroControls.cpp +++ b/Source/MacroControls.cpp @@ -1,4 +1,5 @@ #include "MacroControls.h" +#include "ExactCompare.h" namespace serum { @@ -79,7 +80,7 @@ void MacroControls::fromValueTree (const juce::ValueTree& tree) continue; const double savedIndex = (double) mac.getProperty ("index", -1); if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumMacros - || std::floor (savedIndex) != savedIndex) + || ! isWholeNumber (savedIndex)) continue; const int index = (int) savedIndex; for (const auto& asg : mac) diff --git a/Source/Oscillator.cpp b/Source/Oscillator.cpp index 9861eb8..ba6ef80 100644 --- a/Source/Oscillator.cpp +++ b/Source/Oscillator.cpp @@ -1,4 +1,5 @@ #include "Oscillator.h" +#include "ExactCompare.h" namespace serum { @@ -12,9 +13,8 @@ void Oscillator::reset() paramsValid = false; } -void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed) +void Oscillator::noteOn (const OscParams& p, juce::uint32 seed) { - jassert (freqHz > 0.0); rng.setSeed (seed); initializedUnison = 0; paramsValid = false; @@ -25,8 +25,9 @@ void Oscillator::setParams (const OscParams& p) noexcept { const int uni = juce::jlimit (1, kMaxUnison, p.unison); const bool countChanged = ! paramsValid || uni != activeUnison; - const bool detuneChanged = countChanged || p.detune != cachedParams.detune; - const bool panChanged = countChanged || p.pan != cachedParams.pan || p.spread != cachedParams.spread; + const bool detuneChanged = countChanged || changed (p.detune, cachedParams.detune); + const bool panChanged = countChanged || changed (p.pan, cachedParams.pan) + || changed (p.spread, cachedParams.spread); if (! detuneChanged && ! panChanged) return; @@ -98,6 +99,7 @@ float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept } case WarpMode::PWM: return phase; case WarpMode::Fold: return phase; + case WarpMode::Count: return phase; default: return phase; } } @@ -114,6 +116,13 @@ float Oscillator::warpSample (float sample, const OscParams& p) const noexcept } case WarpMode::Fold: return std::sin (sample * (1.0f + amt * 5.0f) * 1.5707963267948966f); + case WarpMode::None: + case WarpMode::BendPlus: + case WarpMode::BendMinus: + case WarpMode::Sync: + case WarpMode::Asym: + case WarpMode::Mirror: + case WarpMode::Count: default: return sample; } diff --git a/Source/Oscillator.h b/Source/Oscillator.h index ffdfa69..e6460be 100644 --- a/Source/Oscillator.h +++ b/Source/Oscillator.h @@ -42,7 +42,7 @@ public: void reset(); // Initialise a fresh note's unison sub-voices: phase offsets, detune, pan and gain. - void noteOn (double freqHz, const OscParams& p, juce::uint32 seed); + void noteOn (const OscParams& p, juce::uint32 seed); void setParams (const OscParams& p) noexcept; // Accumulate this oscillator's contribution into outL/outR. diff --git a/Source/Params.cpp b/Source/Params.cpp index 2f6af7c..8ef95e8 100644 --- a/Source/Params.cpp +++ b/Source/Params.cpp @@ -24,6 +24,7 @@ juce::String modSourceName (ModSource s) case ModSource::Macro3: return "Macro 3"; case ModSource::Macro4: return "Macro 4"; case ModSource::Random: return "Random"; + case ModSource::Count: return {}; default: return {}; } } @@ -41,6 +42,7 @@ juce::String modSourceToString (ModSource s) case ModSource::Macro1: return "macro1"; case ModSource::Macro2: return "macro2"; case ModSource::Macro3: return "macro3"; case ModSource::Macro4: return "macro4"; case ModSource::Random: return "random"; + case ModSource::Count: return {}; default: return {}; } } @@ -88,6 +90,8 @@ juce::String modTargetName (ModTarget t) case ModTarget::Fx3Mix: return "FX 3 Mix"; case ModTarget::Fx4Mix: return "FX 4 Mix"; case ModTarget::Fx5Mix: return "FX 5 Mix"; case ModTarget::Fx6Mix: return "FX 6 Mix"; case ModTarget::Fx7Mix: return "FX 7 Mix"; case ModTarget::Fx8Mix: return "FX 8 Mix"; + case ModTarget::None: return {}; + case ModTarget::Count: return {}; default: return {}; } } @@ -127,6 +131,8 @@ juce::String modTargetParamId (ModTarget t) case ModTarget::Fx3Mix: return ids::fx3Mix; case ModTarget::Fx4Mix: return ids::fx4Mix; case ModTarget::Fx5Mix: return ids::fx5Mix; case ModTarget::Fx6Mix: return ids::fx6Mix; case ModTarget::Fx7Mix: return ids::fx7Mix; case ModTarget::Fx8Mix: return ids::fx8Mix; + case ModTarget::None: return {}; + case ModTarget::Count: return {}; default: return {}; } } diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 131cf8e..47c4fcf 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -19,7 +19,6 @@ namespace constexpr int knobSmall = 44; // small knob width (env / LFO rows) constexpr int rowHeight = knobSize + padding; // vertical pitch of knob rows constexpr int panelSpacing = 10; // gap between adjacent panels - constexpr int labelHeight = 16; // label strip height // Derived from the visual style (panel title bar and control sizes) so the // content grid stays aligned across every tab. @@ -28,7 +27,12 @@ namespace constexpr int buttonHeight = 26; // text button height constexpr int toggleWidth = 40; // "On"/"Sync" toggle width constexpr int toggleHeight = 36; // toggle height (LED + visible label) + constexpr int toggleWide = 60; // wider toggle for a word like "Bipolar" constexpr int headerHeight = toggleHeight; // top control row height + constexpr int depthKnobHeight = 60; // matrix / macro depth knob height + // The header row is centred on the depth knob, which is taller than a + // toggle, so the row centre follows the knob and not headerHeight. + constexpr int headerCentreY = titleHeight + depthKnobHeight / 2; constexpr int displayHeight = 66; // waveform / filter display height constexpr int envDisplayHeight = 56; // envelope display height constexpr int lfoDisplayHeight = 64; // LFO display height @@ -36,6 +40,24 @@ namespace constexpr int smallGap = 3; // horizontal spacing between small knobs constexpr int arrowWidth = 26; // FX up/down arrow button width constexpr int macroKnobSize = 120; // macro knob footprint + + // Tab content starts below the top bar, so every view shares one origin. + constexpr int tabBarHeight = 60; + + // Panel grids. Each tab lays its panels out in a fixed number of equal + // columns, so the column width lives here rather than beside the code + // that happens to use it. + constexpr int twoColW = 545; // two equal columns (OSC, FX) + constexpr int threeColW = 360; // three equal columns (FILTER) + constexpr int fourColW = 269; // four equal columns (MOD, MACRO) + constexpr int fourColGap = 8; // MOD and MACRO pack their columns tighter + + constexpr int oscPanelH = 308; // oscillator panel height + constexpr int smallPanelH = 150; // sub, noise and FX slot panel height + constexpr int filterPanelH = 300; // filter panel height + constexpr int envPanelH = 154; // envelope panel height + constexpr int lfoPanelH = 206; // LFO panel height + constexpr int macroPanelH = 158; // macro panel height } // Horizontal position of a grid column (0-based). @@ -85,8 +107,70 @@ namespace } juce::String fmtUnison (float v) { return juce::String (1 + (int) std::llround (v * 15.0f)); } - juce::String fmtSlope (float v) { const int s = (int) std::llround (v * 2.0f); return juce::String (s == 0 ? 6 : (s == 1 ? 12 : 24)) + " dB"; } - juce::String fmtDepth (float v) { return juce::String (v, 2); } + + // ----------------------------------------------------------------------- + // Display formatters. One per physical unit the editor shows. Each takes a + // normalised 0..1 parameter and returns the value the DSP reads, with its + // unit, so a readout and a tooltip can state the same range. + // ----------------------------------------------------------------------- + juce::String fmtGainDb (float v) { return juce::String ((v - 0.5f) * 30.0f, 1) + " dB"; } + juce::String fmtGainDb12 (float v) { return juce::String ((v - 0.5f) * 24.0f, 1) + " dB"; } + juce::String fmtCompThreshold (float v) { return juce::String (-40.0f + v * 40.0f, 1) + " dB"; } + juce::String fmtCompRatio (float v) { return juce::String (2.0f + v * 18.0f, 1) + ":1"; } + juce::String fmtCompAttack (float v) { return juce::String (2.0f + v * 98.0f, 0) + " ms"; } + juce::String fmtCompRelease (float v) { return juce::String (30.0f + v * 470.0f, 0) + " ms"; } + juce::String fmtDelayTime (float v) { return juce::String (maps::delayToMs (v), 0) + " ms"; } + juce::String fmtLfoRate (float v) { return juce::String (maps::rateToHz (v), 2) + " Hz"; } + juce::String fmtLfoFade (float v) { return juce::String (v * 8.0f, 2) + " s"; } + juce::String fmtLfoDelay (float v) { return juce::String (v * 4.0f, 2) + " s"; } + + // Beat division, as the period the LFO holds for one cycle. + juce::String fmtLfoBeat (float v) + { + static const char* const names[16] = { + "1/32", "1/16", "1/8", "1/6", "1/4", "1/3", "1/2", "1", + "1.5", "2", "3", "4", "6", "8", "12", "16" + }; + const int idx = juce::jlimit (0, 15, (int) std::llround (v * 15.0f)); + return juce::String ("1 cycle = ") + names[idx] + " beat" + (idx == 7 ? "" : "s"); + } + + // Phase and random phase are fractions of one cycle. + juce::String fmtDegrees (float v) { return juce::String (juce::roundToInt (v * 360.0f)) + " deg"; } + + // Filter output is a gain of 0 to 1.5, so 100 % is 1.5x, not unity. + juce::String fmtFilterOut (float v) { return juce::String (juce::roundToInt (v * 150.0f)) + " %"; } + + // Modulation depth spans -100 % to +100 %; a negative depth inverts. + juce::String fmtModDepth (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + " %"; } + + // Beat divisions, indexed the same way as maps::beatToMultiplier. + juce::String formatFrequency (float hz) + { + if (hz >= 1000.0f) + return juce::String (hz / 1000.0f, 1) + " kHz"; + return juce::String (hz, hz < 100.0f ? 1 : 0) + " Hz"; + } + + juce::String fmtChorusRate (float v) { return formatFrequency (0.1f + v * 4.9f); } + juce::String fmtFlangerRate (float v) { return formatFrequency (0.05f + v * 2.0f); } + juce::String fmtPhaserRate (float v) { return formatFrequency (0.05f + v * 2.0f); } + juce::String fmtDistTone (float v) { return formatFrequency (800.0f + v * 15200.0f); } + juce::String fmtEqMidFreq (float v) { return formatFrequency (200.0f + v * 4800.0f); } + + // Chorus and flanger depth is a delay excursion in milliseconds. + juce::String fmtChorusDepth (float v) { return juce::String (0.5f + v * 5.5f, 2) + " ms"; } + juce::String fmtFlangerDepth (float v) { return juce::String (0.3f + v * 4.7f, 2) + " ms"; } + + juce::String fmtDistDrive (float v) { return juce::String (1.0f + v * 24.0f, 1) + "x"; } + juce::String fmtDistOutput (float v) { return juce::String (0.4f + v * 1.2f, 2) + "x"; } + juce::String fmtHyperOutput (float v) { return juce::String (0.5f + v, 2) + "x"; } + juce::String fmtPhaserStages (float v) + { + return juce::String (juce::jlimit (2, 6, 2 + (int) (v * 4.0f + 0.5f))) + " stages"; + } + juce::String fmtPingPong (float v) { return v > 0.5f ? "On" : "Off"; } + juce::String fmtPredelayMs (float v) { return juce::String (v * 150.0f, 0) + " ms"; } void placeKnobs (std::vector& knobs, int x, int y, int w, int h, int gap = layout::gap) { @@ -101,17 +185,61 @@ namespace constexpr auto& kFxP3 = paramIds::fxP3; constexpr auto& kFxP4 = paramIds::fxP4; - constexpr const char* kFxParamNames[(int) FxType::Count][4] = { - { "P1", "P2", "P3", "P4" }, - { "Intensity", "Low Amount", "High Amount", "Output" }, - { "Rate", "Depth", "Width", "Unused" }, - { "Rate", "Depth", "Feedback", "Unused" }, - { "Rate", "Depth", "Feedback", "Stages" }, - { "Drive", "Shape", "Tone", "Output" }, - { "Low Gain", "Mid Gain", "High Gain", "Mid Freq" }, - { "Threshold", "Ratio", "Attack", "Release" }, - { "Time", "Feedback", "Damping", "Ping-Pong" }, - { "Size", "Damping", "Width", "Predelay" } + // How one FX slot parameter is presented: the name under the knob, the text + // that converts its normalised value to physical units, and the tooltip that + // states what it does and its range. + struct FxParamDisplay + { + const char* name; + const char* tooltip; + std::function formatter; + }; + + // Indexed by FxType then by parameter slot. Names and ranges come from the + // effect that reads the parameter in Source/EffectUnits/. + const FxParamDisplay kFxParamDisplay[(int) FxType::Count][4] = { + // Off: the rack skips the slot entirely, so say so rather than showing + // four knobs that do nothing. + { { "Unused", "Effect is off, this slot does nothing", formatPercent }, + { "Unused", "Effect is off, this slot does nothing", formatPercent }, + { "Unused", "Effect is off, this slot does nothing", formatPercent }, + { "Unused", "Effect is off, this slot does nothing", formatPercent } }, + { { "Intensity", "How far the multiband up and down compression goes, 0 to 100 %", formatPercent }, + { "Low Amount", "How much of the low band is fed back in, 40 to 100 %", formatPercent }, + { "High Amount", "How much of the high band is fed back in, 40 to 100 %", formatPercent }, + { "Output", "Output gain, 0.5 to 1.5x", fmtHyperOutput } }, + { { "Rate", "Modulation speed, 0.1 Hz to 5 Hz", fmtChorusRate }, + { "Depth", "Pitch swing, 0.5 ms to 6 ms of delay", fmtChorusDepth }, + { "Width", "Stereo width of the modulated copy, 0 to 100 %", formatPercent }, + { "Unused", "Not used by this effect", formatPercent } }, + { { "Rate", "Modulation speed, 0.05 Hz to 2.05 Hz", fmtFlangerRate }, + { "Depth", "Pitch swing, 0.3 ms to 5 ms of delay", fmtFlangerDepth }, + { "Feedback", "How much output is fed back in, 0 to 90 %", formatPercent }, + { "Unused", "Not used by this effect", formatPercent } }, + { { "Rate", "Modulation speed, 0.05 Hz to 2.05 Hz", fmtPhaserRate }, + { "Depth", "Sweep depth, 30 to 90 %", formatPercent }, + { "Feedback", "How much output is fed back in, 0 to 70 %", formatPercent }, + { "Stages", "Number of all-pass stages, 2 to 6", fmtPhaserStages } }, + { { "Drive", "Input gain into the shaper, 1x to 25x", fmtDistDrive }, + { "Shape", "Clipping shape. Low is soft, middle is hard, high folds.", formatPercent }, + { "Tone", "Tone filter cutoff, 800 Hz to 16 kHz", fmtDistTone }, + { "Output", "Output gain, 0.4x to 1.6x", fmtDistOutput } }, + { { "Low Gain", "Low band gain, -15 dB to +15 dB", fmtGainDb }, + { "Mid Gain", "Mid band gain, -12 dB to +12 dB", fmtGainDb12 }, + { "High Gain", "High band gain, -15 dB to +15 dB", fmtGainDb }, + { "Mid Freq", "Mid band centre frequency, 200 Hz to 5 kHz", fmtEqMidFreq } }, + { { "Threshold", "Level where compression starts, -40 dB to 0 dB", fmtCompThreshold }, + { "Ratio", "Input to output ratio above the threshold, 2:1 to 20:1", fmtCompRatio }, + { "Attack", "How fast the compressor reacts, 2 ms to 100 ms", fmtCompAttack }, + { "Release", "How fast the compressor lets go, 30 ms to 500 ms", fmtCompRelease } }, + { { "Time", "Delay time, 1 ms to 2000 ms", fmtDelayTime }, + { "Feedback", "How much output is fed back in, 0 to 92 %", formatPercent }, + { "Damping", "How much the repeats lose their top end, 0 to 95 %", formatPercent }, + { "Ping-Pong", "Bounces the repeats between left and right, on or off", fmtPingPong } }, + { { "Size", "Reverb room size, 20 to 99 %", formatPercent }, + { "Damping", "How much the tail loses its top end, 0 to 100 %", formatPercent }, + { "Width", "Stereo width of the tail, 0 to 100 %", formatPercent }, + { "Predelay", "Gap before the reverb starts, 0 ms to 150 ms", fmtPredelayMs } } }; } @@ -173,9 +301,15 @@ Knob* PluginEditor::makeKnob (juce::Component* parent, const juce::String& name, auto* k = control.get(); ownedControls.push_back (std::move (control)); parent->addAndMakeVisible (k); - if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr) + if (auto* param = paramId.isNotEmpty() ? processor.parameters.getParameter (paramId) : nullptr) + { + // A double click restores the parameter's own default. Without this a + // double click writes the knob's built-in 0.5 and silently changes any + // parameter whose default is not 0.5. + k->setDefaultValue (param->convertFrom0to1 (param->getDefaultValue())); sliderAttachments.push_back (std::make_unique ( processor.parameters, paramId, *k)); + } k->setTooltip (tooltip.isNotEmpty() ? tooltip : name); return k; } @@ -222,7 +356,7 @@ void PluginEditor::buildTopBar() presetCombo.onChange = [this] { processor.setCurrentProgram (presetCombo.getSelectedItemIndex()); }; root.addAndMakeVisible (presetCombo); presetCombo.setBounds (200, 16, 220, 24); - presetCombo.setTooltip ("Select a factory preset"); + presetCombo.setTooltip ("Loads a factory preset and replaces the current patch"); // UI scale. scaleCombo.addItemList ({ "75%", "100%", "125%", "150%", "200%" }, 1); @@ -230,25 +364,25 @@ void PluginEditor::buildTopBar() scaleCombo.onChange = [this] { processor.setUiScaleIndex (scaleCombo.getSelectedItemIndex()); }; root.addAndMakeVisible (scaleCombo); scaleCombo.setBounds (980, 16, 70, 24); - scaleCombo.setTooltip ("Interface scale (75% - 200%)"); + scaleCombo.setTooltip ("Interface scale, 75 % to 200 %"); // RAVE. - raveButton.setLabel ("RAVE"); + raveButton.setLabel ("Rave"); raveButton.setOnColour (theme::raveGlow); raveButton.setBounds (1062, 2, 50, 56); root.addAndMakeVisible (raveButton); - raveButton.setTooltip ("RAVE one-click boost (unison, width, drive, OTT, reverb)"); + raveButton.setTooltip ("Adds unison, width, drive, OTT and reverb to the current patch"); // RAVE uses a callback rather than a direct parameter attachment. raveButton.setOnClick ([this] (bool on) { processor.setRaveEnabled (on); }); // Tab bar. juce::TextButton* tabs[5] = { &tabOsc, &tabFilter, &tabMod, &tabFx, &tabMacro }; - const char* tabNames[5] = { "OSC", "FILTER", "MOD", "FX", "MACRO" }; - const char* tabTips[5] = { "Oscillators (Tab cycles sections)", - "Filters (Tab cycles sections)", - "Modulation: envelopes, LFOs and matrix (Tab cycles sections)", - "Effects rack (Tab cycles sections)", - "Macros (Tab cycles sections)" }; + const char* tabNames[5] = { "Osc", "Filter", "Mod", "FX", "Macro" }; + const char* tabTips[5] = { "Oscillators. Tab cycles sections.", + "Filters. Tab cycles sections.", + "Modulation. Envelopes, LFOs and the routing matrix.", + "Effects rack. Tab cycles sections.", + "Macros. Tab cycles sections." }; for (int i = 0; i < 5; ++i) { tabs[i]->setButtonText (tabNames[i]); @@ -262,7 +396,7 @@ void PluginEditor::buildTopBar() // Master. masterKnob.setBounds (906, 0, 64, 60); - masterKnob.setTooltip ("Master output volume (0-100%)"); + masterKnob.setTooltip ("Master output level, 0 % to 100 %"); root.addAndMakeVisible (masterKnob); sliderAttachments.push_back (std::make_unique ( processor.parameters, ids::master, masterKnob)); @@ -274,12 +408,12 @@ void PluginEditor::buildTopBar() void PluginEditor::buildOscTab() { - oscView.setBounds (0, 60, kBaseW, kBaseH - 60); + oscView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight); root.addChildComponent (oscView); - constexpr int colW = 545; // two equal columns filling kBaseW - constexpr int oscH = 308; - constexpr int subH = 150; + constexpr int colW = layout::twoColW; + constexpr int oscH = layout::oscPanelH; + constexpr int subH = layout::smallPanelH; oscAPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, oscH); oscBPanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, oscH); @@ -294,9 +428,9 @@ void PluginEditor::buildOscTab() const int row2Y = row1Y + layout::rowHeight; // --- Oscillator A --- - makeToggle (&oscAPanel, "On", ids::oscAOn, "Enable oscillator A")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); - makeCombo (&oscAPanel, ids::oscAWave, wavetableItems(), "Oscillator A wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight); - makeCombo (&oscAPanel, ids::oscAWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator A warp mode") + makeToggle (&oscAPanel, "On", ids::oscAOn, "Turns oscillator A on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); + makeCombo (&oscAPanel, ids::oscAWave, wavetableItems(), "Selects the wavetable, 10 factory tables")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight); + makeCombo (&oscAPanel, ids::oscAWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Selects the wavetable warp mode") ->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight); oscAWave.setWavetables (&processor.engine.getWavetables()); @@ -315,17 +449,19 @@ void PluginEditor::buildOscTab() std::vector rowA2 = { makeKnob (&oscAPanel, "Unison", ids::oscAUnison, fmtUnison, "Unison voices (1-16)"), - makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatPercent, "Unison detune (0-100%)"), - makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison spread (0-100%)"), - makeKnob (&oscAPanel, "Phase", ids::oscAPhase, formatPercent, "Phase (0-100%)"), - makeKnob (&oscAPanel, "Random Phase", ids::oscARandPh, formatPercent, "Random phase (0-100%)") + makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatCents, "Unison detune spread, 0 to 50 cents"), + makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison stereo spread (0-100%)"), + makeKnob (&oscAPanel, "Phase", ids::oscAPhase, fmtDegrees, "Start phase at note on, 0 to 360 degrees"), + makeKnob (&oscAPanel, "Rand phase", ids::oscARandPh, fmtDegrees, "Random start phase per voice, 0 to 360 degrees") }; placeKnobs (rowA2, layout::padding, row2Y, layout::knobSize, layout::knobSize); + // Unison is a whole number of voices, so the dial should rest on a detent. + rowA2[0]->setRange (0.0, 1.0, 1.0 / (kMaxUnison - 1)); // --- Oscillator B --- - makeToggle (&oscBPanel, "On", ids::oscBOn, "Enable oscillator B")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); - makeCombo (&oscBPanel, ids::oscBWave, wavetableItems(), "Oscillator B wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight); - makeCombo (&oscBPanel, ids::oscBWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator B warp mode") + makeToggle (&oscBPanel, "On", ids::oscBOn, "Turns oscillator B on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); + makeCombo (&oscBPanel, ids::oscBWave, wavetableItems(), "Selects the wavetable, 10 factory tables")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight); + makeCombo (&oscBPanel, ids::oscBWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Selects the wavetable warp mode") ->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight); oscBWave.setWavetables (&processor.engine.getWavetables()); @@ -344,32 +480,33 @@ void PluginEditor::buildOscTab() std::vector rowB2 = { makeKnob (&oscBPanel, "Unison", ids::oscBUnison, fmtUnison, "Unison voices (1-16)"), - makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatPercent, "Unison detune (0-100%)"), - makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison spread (0-100%)"), - makeKnob (&oscBPanel, "Phase", ids::oscBPhase, formatPercent, "Phase (0-100%)"), - makeKnob (&oscBPanel, "Random Phase", ids::oscBRandPh, formatPercent, "Random phase (0-100%)") + makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatCents, "Unison detune spread, 0 to 50 cents"), + makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison stereo spread (0-100%)"), + makeKnob (&oscBPanel, "Phase", ids::oscBPhase, fmtDegrees, "Start phase at note on, 0 to 360 degrees"), + makeKnob (&oscBPanel, "Rand phase", ids::oscBRandPh, fmtDegrees, "Random start phase per voice, 0 to 360 degrees") }; placeKnobs (rowB2, layout::padding, row2Y, layout::knobSize, layout::knobSize); + rowB2[0]->setRange (0.0, 1.0, 1.0 / (kMaxUnison - 1)); // --- Sub --- - makeToggle (&subPanel, "On", ids::subOn, "Enable sub oscillator")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); - makeCombo (&subPanel, ids::subShape, { "Sine", "Triangle" }, "Sub oscillator waveform")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight); - makeCombo (&subPanel, ids::subOct, { "-2 oct", "-1 oct", "0 oct" }, "Sub oscillator octave")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 130 + layout::gap, comboY, 90, layout::comboHeight); + makeToggle (&subPanel, "On", ids::subOn, "Turns the sub oscillator on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); + makeCombo (&subPanel, ids::subShape, { "Sine", "Triangle" }, "Sub oscillator waveform, sine or triangle")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight); + makeCombo (&subPanel, ids::subOct, { "-2 oct", "-1 oct", "0 oct" }, "Sub oscillator octave, 2 below to unison")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 130 + layout::gap, comboY, 90, layout::comboHeight); makeKnob (&subPanel, "Level", ids::subLevel, formatPercent, "Sub oscillator level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize); // --- Noise --- - makeToggle (&noisePanel, "On", ids::noiseOn, "Enable noise")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); - makeCombo (&noisePanel, ids::noiseType, { "White", "Pink" }, "Noise type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight); + makeToggle (&noisePanel, "On", ids::noiseOn, "Turns noise on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); + makeCombo (&noisePanel, ids::noiseType, { "White", "Pink" }, "Noise colour, white or pink")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight); makeKnob (&noisePanel, "Level", ids::noiseLevel, formatPercent, "Noise level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize); } void PluginEditor::buildFilterTab() { - filterView.setBounds (0, 60, kBaseW, kBaseH - 60); + filterView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight); root.addChildComponent (filterView); - constexpr int colW = 360; - constexpr int panelH = 300; // retains the original panel height (content top-aligned) + constexpr int colW = layout::threeColW; + constexpr int panelH = layout::filterPanelH; filter1Panel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, panelH); filter2Panel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, panelH); @@ -385,52 +522,52 @@ void PluginEditor::buildFilterTab() const int knobY = displayY + layout::displayHeight + layout::padding; // aligns with OSC row 1 // Filter 1 - makeToggle (&filter1Panel, "On", ids::f1On, "Enable filter 1")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); - makeCombo (&filter1Panel, ids::f1Type, filterTypes, "Filter 1 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight); - makeCombo (&filter1Panel, ids::f1Slope, slopes, "Filter 1 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight); + makeToggle (&filter1Panel, "On", ids::f1On, "Turns filter 1 on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); + makeCombo (&filter1Panel, ids::f1Type, filterTypes, "Selects the filter 1 model")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight); + makeCombo (&filter1Panel, ids::f1Slope, slopes, "Slope, 6, 12 or 24 dB per octave on the ladder and diode models")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight); filter1Panel.addAndMakeVisible (filter1Display); filter1Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight); std::vector f1 = { - makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff (20 Hz - 20 kHz)"), - makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance (0-100%)"), - makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive (0-100%)"), - makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 keytrack (0-100%)") + makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff, 20 Hz to 18 kHz"), + makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance, 0 % to 100 %"), + makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive, 0 % to 100 %. Damps the comb model."), + makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 key tracking, 0 % to 100 %") }; placeKnobs (f1, layout::padding, knobY, layout::knobSize, layout::knobSize); // Filter 2 - makeToggle (&filter2Panel, "On", ids::f2On, "Enable filter 2")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); - makeCombo (&filter2Panel, ids::f2Type, filterTypes, "Filter 2 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight); - makeCombo (&filter2Panel, ids::f2Slope, slopes, "Filter 2 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight); + makeToggle (&filter2Panel, "On", ids::f2On, "Turns filter 2 on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); + makeCombo (&filter2Panel, ids::f2Type, filterTypes, "Selects the filter 2 model")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight); + makeCombo (&filter2Panel, ids::f2Slope, slopes, "Slope, 6, 12 or 24 dB per octave on the ladder and diode models")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight); filter2Panel.addAndMakeVisible (filter2Display); filter2Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight); std::vector f2 = { - makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff (20 Hz - 20 kHz)"), - makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance (0-100%)"), - makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive (0-100%)"), - makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 keytrack (0-100%)") + makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff, 20 Hz to 18 kHz"), + makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance, 0 % to 100 %"), + makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive, 0 % to 100 %. Damps the comb model."), + makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 key tracking, 0 % to 100 %") }; placeKnobs (f2, layout::padding, knobY, layout::knobSize, layout::knobSize); // Routing - makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing (serial / parallel / split)")->setBounds (layout::padding, comboY, 160, layout::comboHeight); - makeKnob (&routingPanel, "Mix", ids::fMix, formatPercent, "Filter mix (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize); - makeKnob (&routingPanel, "Output", ids::fOut, formatPercent, "Filter output (0-100%)")->setBounds (layout::padding + layout::knobSize + layout::gap, displayY, layout::knobSize, layout::knobSize); + makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing, serial, parallel or split")->setBounds (layout::padding, comboY, 160, layout::comboHeight); + makeKnob (&routingPanel, "Mix", ids::fMix, formatPercent, "Blends filter 2 over filter 1 in parallel routing, 0 % to 100 %")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize); + makeKnob (&routingPanel, "Output", ids::fOut, fmtFilterOut, "Filter output gain, 0 % to 150 %")->setBounds (layout::padding + layout::knobSize + layout::gap, displayY, layout::knobSize, layout::knobSize); } void PluginEditor::buildModTab() { - modView.setBounds (0, 60, kBaseW, kBaseH - 60); + modView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight); root.addChildComponent (modView); const juce::StringArray lfoShapes = { "Sine", "Triangle", "Saw", "Square", "S&H", "Step", "Freehand" }; - constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100 - constexpr int colGap = 8; - constexpr int envH = 154; - constexpr int lfoH = 206; + constexpr int colW = layout::fourColW; + constexpr int colGap = layout::fourColGap; + constexpr int envH = layout::envPanelH; + constexpr int lfoH = layout::lfoPanelH; // Envelopes (4 across). for (int i = 0; i < kNumEnvelopes; ++i) @@ -449,11 +586,11 @@ void PluginEditor::buildModTab() const auto& idsC = paramIds::envCurve; std::vector knobs = { - makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time (0.5 ms - 12 s)"), - makeKnob (&envPanels[(size_t) i], "Decay", idsD[i], formatSeconds, "Decay time (0.5 ms - 12 s)"), - makeKnob (&envPanels[(size_t) i], "Sustain", idsS[i], formatPercent, "Sustain level (0-100%)"), - makeKnob (&envPanels[(size_t) i], "Release", idsR[i], formatSeconds, "Release time (0.5 ms - 12 s)"), - makeKnob (&envPanels[(size_t) i], "Curve", idsC[i], formatPercent, "Envelope curve (0-100%)") + makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time, 0.5 ms to 12 s"), + makeKnob (&envPanels[(size_t) i], "Decay", idsD[i], formatSeconds, "Decay time, 0.5 ms to 12 s"), + makeKnob (&envPanels[(size_t) i], "Sustain", idsS[i], formatPercent, "Sustain level, 0 % to 100 %"), + makeKnob (&envPanels[(size_t) i], "Release", idsR[i], formatSeconds, "Release time, 0.5 ms to 12 s"), + makeKnob (&envPanels[(size_t) i], "Curve", idsC[i], formatPercent, "Curve shape. Low is a fast attack and slow decay, high is the reverse.") }; placeKnobs (knobs, layout::padding, layout::titleHeight + layout::envDisplayHeight + layout::padding, @@ -475,18 +612,18 @@ void PluginEditor::buildModTab() const auto& fade = paramIds::lfoFade; const auto& delay = paramIds::lfoDelay; - makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "LFO shape")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight); - makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Tempo-sync LFO")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); + makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "Selects the LFO waveform")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight); + makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Locks the LFO to host tempo and uses the Beat knob")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight); lfoPanels[(size_t) i].addAndMakeVisible (lfoDisplays[(size_t) i]); lfoDisplays[(size_t) i].setBounds (layout::padding, bodyTop(), colW - 2 * layout::padding, layout::lfoDisplayHeight); std::vector knobs = { - makeKnob (&lfoPanels[(size_t) i], "Rate", rate[i], formatPercent, "LFO rate (0.02 Hz - 30 Hz)"), - makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], formatPercent, "Beat division (1/32 - 4 bars)"), - makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], formatPercent, "Phase (0-100%)"), - makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], formatPercent, "Fade in (0-100%)"), - makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], formatPercent, "Start delay (0-100%)") + makeKnob (&lfoPanels[(size_t) i], "Rate", rate[i], fmtLfoRate, "LFO rate, 0.02 Hz to 30 Hz"), + makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], fmtLfoBeat, "Cycle length, 1/32 beat to 16 beats"), + makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], fmtDegrees, "Start phase, 0 to 360 degrees"), + makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], fmtLfoFade, "Fade in time, 0 s to 8 s"), + makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], fmtLfoDelay, "Start delay, 0 s to 4 s") }; placeKnobs (knobs, layout::padding, bodyTop() + layout::lfoDisplayHeight + layout::padding, @@ -500,65 +637,70 @@ void PluginEditor::buildModTab() // Modulation matrix. const int matrixTop = lfoTop + lfoH + layout::panelSpacing; - const int matrixH = kBaseH - 60 - matrixTop - layout::margin; + const int matrixH = kBaseH - layout::tabBarHeight - matrixTop - layout::margin; matrixPanel.setBounds (layout::margin, matrixTop, kBaseW - 2 * layout::margin, matrixH); modView.addAndMakeVisible (matrixPanel); std::vector targetEnums; const juce::StringArray targetItems = modTargetItems (targetEnums); - const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob + const int mCenter = layout::headerCentreY; // centre line of the depth row modSourceCombo.addItemList (modSourceItems(), 1); modSourceCombo.setSelectedItemIndex (0, juce::dontSendNotification); modSourceCombo.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 160, layout::comboHeight); - modSourceCombo.setTooltip ("Modulation source"); + modSourceCombo.setTooltip ("Selects the modulation source"); matrixPanel.addAndMakeVisible (modSourceCombo); modTargetCombo.addItemList (targetItems, 1); modTargetCombo.setSelectedItemIndex (0, juce::dontSendNotification); modTargetCombo.setBounds (layout::padding + 160 + layout::gap, mCenter - layout::comboHeight / 2, 200, layout::comboHeight); - modTargetCombo.setTooltip ("Modulation destination"); + modTargetCombo.setTooltip ("Selects the modulation destination"); matrixPanel.addAndMakeVisible (modTargetCombo); const int depthX = layout::padding + 160 + layout::gap + 200 + layout::gap; modDepthKnob.setRange (-1.0, 1.0); modDepthKnob.setValue (0.5, juce::dontSendNotification); - modDepthKnob.setFormatter (fmtDepth); - modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60); - modDepthKnob.setTooltip ("Modulation depth (-1 to +1)"); + modDepthKnob.setDefaultValue (0.0); // a double click recentres the depth + modDepthKnob.setFormatter (fmtModDepth); + modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, layout::depthKnobHeight); + modDepthKnob.setTooltip ("Modulation amount for the next Add, -100 % to +100 % of the target range"); matrixPanel.addAndMakeVisible (modDepthKnob); modBipolarToggle.setLabel ("Bipolar"); - modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, 60, layout::toggleHeight); - modBipolarToggle.setTooltip ("Bipolar modulation depth"); + modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, layout::toggleWide, layout::toggleHeight); + modBipolarToggle.setTooltip ("Recentres a one-sided source so the modulation swings both ways, for the next Add"); matrixPanel.addAndMakeVisible (modBipolarToggle); modBipolarToggle.setToggleState (true); - const int addX = depthX + layout::knobSize + layout::gap + 60 + layout::gap; + const int addX = depthX + layout::knobSize + layout::gap + layout::toggleWide + layout::gap; modAddButton.setButtonText ("Add"); modAddButton.setBounds (addX, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight); - modAddButton.setTooltip ("Add modulation connection"); + modAddButton.setTooltip ("Adds the selected source and destination to the matrix"); modAddButton.onClick = [this, targetEnums] { const ModSource src = (ModSource) modSourceCombo.getSelectedItemIndex(); const int tid = modTargetCombo.getSelectedItemIndex(); if (tid >= 0 && tid < (int) targetEnums.size()) { + bool added = false; { const juce::ScopedLock lock (processor.engine.getControlLock()); - processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid], - (float) modDepthKnob.getValue(), - modBipolarToggle.getToggleState()); + added = processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid], + (float) modDepthKnob.getValue(), + modBipolarToggle.getToggleState()); } - updateModList(); + if (added) + updateModList(); + else + modAddButton.setEnabled (false); // at cap: keep list text and selection unchanged } }; matrixPanel.addAndMakeVisible (modAddButton); modRemoveButton.setButtonText ("Remove Last"); modRemoveButton.setBounds (addX + 70 + layout::gap, mCenter - layout::buttonHeight / 2, 110, layout::buttonHeight); - modRemoveButton.setTooltip ("Remove last modulation connection"); + modRemoveButton.setTooltip ("Removes the newest matrix row, older rows stay"); modRemoveButton.onClick = [this] { { @@ -573,7 +715,7 @@ void PluginEditor::buildModTab() modClearButton.setButtonText ("Clear"); modClearButton.setBounds (addX + 70 + layout::gap + 110 + layout::gap, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight); - modClearButton.setTooltip ("Clear all modulation connections"); + modClearButton.setTooltip ("Removes every modulation connection"); modClearButton.onClick = [this] { { @@ -584,11 +726,12 @@ void PluginEditor::buildModTab() }; matrixPanel.addAndMakeVisible (modClearButton); - const int listTop = layout::titleHeight + 60 + layout::padding; + const int listTop = layout::titleHeight + layout::depthKnobHeight + layout::padding; modList.setBounds (layout::padding, listTop, kBaseW - 2 * layout::margin - 2 * layout::padding, matrixH - listTop - layout::padding); modList.setReadOnly (true); + modList.setTooltip ("Every modulation connection, newest last. Depth is a fraction of the target range."); modList.setMultiLine (true, false); modList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack); modList.setColour (juce::TextEditor::textColourId, theme::text); @@ -598,14 +741,17 @@ void PluginEditor::buildModTab() void PluginEditor::buildFxTab() { - fxView.setBounds (0, 60, kBaseW, kBaseH - 60); + fxView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight); root.addChildComponent (fxView); - const juce::StringArray fxTypes = { "Off", "Hyper", "Chorus", "Flanger", "Phaser", - "Distortion", "EQ", "Compressor", "Delay", "Reverb" }; + // Built from the one label function so the item order always follows FxType, + // whose values are used directly as the combo selection / parameter value. + juce::StringArray fxTypes; + for (int t = 0; t < (int) FxType::Count; ++t) + fxTypes.add (FXProcessor::fxTypeName (t)); - constexpr int colW = 545; - constexpr int slotH = 150; + constexpr int colW = layout::twoColW; + constexpr int slotH = layout::smallPanelH; for (int i = 0; i < kNumFxSlots; ++i) { @@ -637,13 +783,17 @@ void PluginEditor::buildFxTab() fxUp[(size_t) i]->onClick = [this, i] { if (i > 0) swapFxSlots (i, i - 1); }; fxDown[(size_t) i]->onClick = [this, i] { if (i < kNumFxSlots - 1) swapFxSlots (i, i + 1); }; + // The first slot cannot move up and the last cannot move down, so grey + // the arrow out instead of leaving a click that does nothing. + fxUp[(size_t) i]->setEnabled (i > 0); + fxDown[(size_t) i]->setEnabled (i < kNumFxSlots - 1); std::vector knobs = { - makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Effect mix (0-100%)"), - makeKnob (&fxPanels[(size_t) i], "P1", kFxP1[i], formatPercent, "Effect parameter 1 (0-100%)"), - makeKnob (&fxPanels[(size_t) i], "P2", kFxP2[i], formatPercent, "Effect parameter 2 (0-100%)"), - makeKnob (&fxPanels[(size_t) i], "P3", kFxP3[i], formatPercent, "Effect parameter 3 (0-100%)"), - makeKnob (&fxPanels[(size_t) i], "P4", kFxP4[i], formatPercent, "Effect parameter 4 (0-100%)") + makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Dry and wet mix, 0 % to 100 %"), + makeKnob (&fxPanels[(size_t) i], "P1", kFxP1[i], formatPercent, "Effect parameter 1, set by the effect type"), + makeKnob (&fxPanels[(size_t) i], "P2", kFxP2[i], formatPercent, "Effect parameter 2, set by the effect type"), + makeKnob (&fxPanels[(size_t) i], "P3", kFxP3[i], formatPercent, "Effect parameter 3, set by the effect type"), + makeKnob (&fxPanels[(size_t) i], "P4", kFxP4[i], formatPercent, "Effect parameter 4, set by the effect type") }; placeKnobs (knobs, layout::padding, bodyTop(), layout::knobSize, layout::knobSize); fxKnobs[(size_t) i] = knobs; @@ -652,12 +802,12 @@ void PluginEditor::buildFxTab() void PluginEditor::buildMacroTab() { - macroView.setBounds (0, 60, kBaseW, kBaseH - 60); + macroView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight); root.addChildComponent (macroView); - constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100 - constexpr int colGap = 8; - constexpr int macroH = 158; + constexpr int colW = layout::fourColW; + constexpr int colGap = layout::fourColGap; + constexpr int macroH = layout::macroPanelH; for (int i = 0; i < kNumMacros; ++i) { @@ -674,56 +824,68 @@ void PluginEditor::buildMacroTab() // Macro assignment editor. const int assignTop = layout::margin + macroH + layout::panelSpacing; - const int assignH = kBaseH - 60 - assignTop - layout::margin; + const int assignH = kBaseH - layout::tabBarHeight - assignTop - layout::margin; macroAssignPanel.setBounds (layout::margin, assignTop, kBaseW - 2 * layout::margin, assignH); macroView.addAndMakeVisible (macroAssignPanel); std::vector targetEnums; const juce::StringArray targetItems = modTargetItems (targetEnums); - const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob + const int mCenter = layout::headerCentreY; // centre line of the depth row - macroAssignIndex.addItemList ({ "Macro 1", "Macro 2", "Macro 3", "Macro 4" }, 1); + juce::StringArray macroItems; + for (int i = 0; i < kNumMacros; ++i) + macroItems.add (MacroControls::macroName (i)); + macroAssignIndex.addItemList (macroItems, 1); macroAssignIndex.setSelectedItemIndex (0, juce::dontSendNotification); macroAssignIndex.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 140, layout::comboHeight); - macroAssignIndex.setTooltip ("Macro to assign"); + macroAssignIndex.setTooltip ("Selects the macro to edit"); + macroAssignIndex.onChange = [this] { updateMacroList(); }; macroAssignPanel.addAndMakeVisible (macroAssignIndex); macroAssignTarget.addItemList (targetItems, 1); macroAssignTarget.setSelectedItemIndex (0, juce::dontSendNotification); macroAssignTarget.setBounds (layout::padding + 140 + layout::gap, mCenter - layout::comboHeight / 2, 220, layout::comboHeight); - macroAssignTarget.setTooltip ("Destination parameter"); + macroAssignTarget.setTooltip ("Selects the destination parameter"); macroAssignPanel.addAndMakeVisible (macroAssignTarget); const int depthX = layout::padding + 140 + layout::gap + 220 + layout::gap; - macroDepthKnob.setFormatter (fmtDepth); - macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60); - macroDepthKnob.setTooltip ("Macro assignment depth (0 to 1)"); + // Half depth, so an Assign made without touching this knob still moves the + // destination. A depth of zero would create a silent assignment. + macroDepthKnob.setValue (0.5, juce::dontSendNotification); + macroDepthKnob.setDefaultValue (0.5); + macroDepthKnob.setFormatter (formatPercent); + macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, layout::depthKnobHeight); + macroDepthKnob.setTooltip ("Macro amount for the next Assign, 0 % to 100 % of the target range"); macroAssignPanel.addAndMakeVisible (macroDepthKnob); const int assignBtnX = depthX + layout::knobSize + layout::gap; macroAssignButton.setButtonText ("Assign"); macroAssignButton.setBounds (assignBtnX, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight); - macroAssignButton.setTooltip ("Assign destination to the selected macro"); + macroAssignButton.setTooltip ("Adds this destination to the selected macro"); macroAssignButton.onClick = [this, targetEnums] { const int macro = macroAssignIndex.getSelectedItemIndex(); const int tid = macroAssignTarget.getSelectedItemIndex(); - if (tid >= 0 && tid < (int) targetEnums.size()) + if (macro >= 0 && macro < kNumMacros && tid >= 0 && tid < (int) targetEnums.size()) { + bool added = false; { const juce::ScopedLock lock (processor.engine.getControlLock()); - processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid], - (float) macroDepthKnob.getValue()); + added = processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid], + (float) macroDepthKnob.getValue()); } - updateMacroList(); + if (added) + updateMacroList(); + else + macroAssignButton.setEnabled (false); // at cap: keep list text and selection unchanged } }; macroAssignPanel.addAndMakeVisible (macroAssignButton); macroClearButton.setButtonText ("Clear All"); macroClearButton.setBounds (assignBtnX + 80 + layout::gap, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight); - macroClearButton.setTooltip ("Clear all macro assignments"); + macroClearButton.setTooltip ("Removes every macro assignment from all four macros"); macroClearButton.onClick = [this] { { @@ -734,11 +896,12 @@ void PluginEditor::buildMacroTab() }; macroAssignPanel.addAndMakeVisible (macroClearButton); - const int listTop = layout::titleHeight + 60 + layout::padding; + const int listTop = layout::titleHeight + layout::depthKnobHeight + layout::padding; macroList.setBounds (layout::padding, listTop, kBaseW - 2 * layout::margin - 2 * layout::padding, assignH - listTop - layout::padding); macroList.setReadOnly (true); + macroList.setTooltip ("Every macro assignment, grouped by macro. Depth is a fraction of the target range."); macroList.setMultiLine (true, false); macroList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack); macroList.setColour (juce::TextEditor::textColourId, theme::text); @@ -751,6 +914,7 @@ void PluginEditor::swapFxSlots (int a, int b) if (a < 0 || b < 0 || a >= kNumFxSlots || b >= kNumFxSlots || a == b) return; + const juce::ScopedLock lock (processor.engine.getControlLock()); auto swapParam = [this] (const char* pa, const char* pb) { auto* p1 = processor.parameters.getParameter (pa); @@ -897,12 +1061,18 @@ void PluginEditor::updateVisuals() for (int p = 0; p < 4; ++p) { auto* knob = fxKnobs[(size_t) i][(size_t) p + 1]; - const juce::String name = kFxParamNames[type][p]; + const auto& spec = kFxParamDisplay[type][p]; + + // Label, tooltip and readout unit all come from the one table, so + // a renamed knob can never keep the previous effect's tooltip or + // show a percentage for a value the DSP reads in Hz or dB. + const juce::String name = spec.name; if (knob->getName() != name) { knob->setName (name); - knob->setTooltip (name == "Unused" ? "Not used by this effect" - : name + " (normalised 0-100%)"); + knob->setTooltip (spec.tooltip); + knob->setFormatter (spec.formatter); + knob->setEnabled (name != "Unused"); knob->repaint(); } } @@ -926,26 +1096,33 @@ void PluginEditor::updateVisuals() void PluginEditor::updateModList() { juce::String text; + int count = 0; { const juce::ScopedLock lock (processor.engine.getControlLock()); const auto& cons = processor.engine.getMatrix().connections; + count = (int) cons.size(); for (const auto& c : cons) text += modSourceName (c.source) + " -> " + modTargetName (c.target) + " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n"; } if (modList.getText() != text) modList.setText (text, false); + modAddButton.setEnabled (count < ModulationMatrix::kMaxConnections); } void PluginEditor::updateMacroList() { juce::String text; + int assigned = 0; + const int macro = macroAssignIndex.getSelectedItemIndex(); { const juce::ScopedLock lock (processor.engine.getControlLock()); for (int m = 0; m < kNumMacros; ++m) { text += MacroControls::macroName (m) + ":\n"; const auto& assigns = processor.engine.getMacros().assignments[(size_t) m]; + if (m == macro) + assigned = (int) assigns.size(); if (assigns.empty()) text += " (none)\n"; for (const auto& a : assigns) @@ -954,6 +1131,8 @@ void PluginEditor::updateMacroList() } if (macroList.getText() != text) macroList.setText (text, false); + macroAssignButton.setEnabled (macro >= 0 && macro < kNumMacros + && assigned < MacroControls::kMaxAssignments); } void PluginEditor::timerCallback() diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index 18aea76..c46cf24 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -2,7 +2,6 @@ #include #include "PluginProcessor.h" -#include "GUI/SerumLookAndFeel.h" #include "GUI/Knob.h" #include "GUI/Slider.h" #include "GUI/ToggleButton.h" @@ -36,7 +35,6 @@ public: private: SerumAltAudioProcessor& processor; - SerumLookAndFeel laf; static constexpr int kBaseW = 1120; static constexpr int kBaseH = 740; @@ -63,7 +61,6 @@ private: Panel oscAPanel { "Oscillator A" }, oscBPanel { "Oscillator B" }; Panel subPanel { "Sub" }, noisePanel { "Noise" }; WaveformDisplay oscAWave, oscBWave; - std::vector oscAComponents, oscBComponents; // --- FILTER --- Panel filter1Panel { "Filter 1" }, filter2Panel { "Filter 2" }, routingPanel { "Routing" }; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index b2e6298..db3f460 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -1,6 +1,7 @@ #include "PluginProcessor.h" #include "PluginEditor.h" #include "Presets/FactoryPresets.h" +#include "ExactCompare.h" namespace serum { @@ -98,12 +99,12 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea params.push_back (f (ids::fMix, "Filter Mix", 0.5f)); params.push_back (f (ids::fOut, "Filter Out", 0.667f)); - // Envelopes 1..4 - const char* envA[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A }; - const char* envD[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D }; - const char* envS[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S }; - const char* envR[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R }; - const char* envC[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve }; + // Envelopes 1..4 (IDs come from the shared paramIds groups) + constexpr auto& envA = paramIds::envAttack; + constexpr auto& envD = paramIds::envDecay; + constexpr auto& envS = paramIds::envSustain; + constexpr auto& envR = paramIds::envRelease; + constexpr auto& envC = paramIds::envCurve; const float envADef[4] = { 0.05f, 0.1f, 0.2f, 0.2f }; const float envDDef[4] = { 0.25f, 0.3f, 0.3f, 0.3f }; const float envSDef[4] = { 0.8f, 0.5f, 0.5f, 0.5f }; @@ -117,14 +118,14 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea params.push_back (f (envC[i], juce::String ("Env ") + juce::String (i + 1) + " Curve", 0.5f)); } - // LFOs 1..4 - const char* lfoRate[4] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate }; - const char* lfoSync[4] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync }; - const char* lfoBeat[4] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat }; - const char* lfoShape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape }; - const char* lfoPhase[4] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase }; - const char* lfoFade[4] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade }; - const char* lfoDelay[4] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay }; + // LFOs 1..4 (IDs come from the shared paramIds groups) + constexpr auto& lfoRate = paramIds::lfoRate; + constexpr auto& lfoSync = paramIds::lfoSync; + constexpr auto& lfoBeat = paramIds::lfoBeat; + constexpr auto& lfoShape = paramIds::lfoShape; + constexpr auto& lfoPhase = paramIds::lfoPhase; + constexpr auto& lfoFade = paramIds::lfoFade; + constexpr auto& lfoDelay = paramIds::lfoDelay; const float lfoShapeDef[4] = { 0.0f, 1.0f / 6.0f, 2.0f / 6.0f, 3.0f / 6.0f }; for (int i = 0; i < 4; ++i) { @@ -138,37 +139,31 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea params.push_back (f (lfoDelay[i], n + " Delay", 0.0f)); } - // FX slots 1..8 - const char* fxType[8] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type, - ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type }; - const char* fxMix[8] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix, - ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix }; - const char* fxP[8][4] = { - { ids::fx1P1, ids::fx1P2, ids::fx1P3, ids::fx1P4 }, - { ids::fx2P1, ids::fx2P2, ids::fx2P3, ids::fx2P4 }, - { ids::fx3P1, ids::fx3P2, ids::fx3P3, ids::fx3P4 }, - { ids::fx4P1, ids::fx4P2, ids::fx4P3, ids::fx4P4 }, - { ids::fx5P1, ids::fx5P2, ids::fx5P3, ids::fx5P4 }, - { ids::fx6P1, ids::fx6P2, ids::fx6P3, ids::fx6P4 }, - { ids::fx7P1, ids::fx7P2, ids::fx7P3, ids::fx7P4 }, - { ids::fx8P1, ids::fx8P2, ids::fx8P3, ids::fx8P4 } - }; + // FX slots 1..8 (IDs come from the shared paramIds groups) + constexpr auto& fxType = paramIds::fxType; + constexpr auto& fxMix = paramIds::fxMix; + const char* const fxP[4][8] = { { paramIds::fxP1[0], paramIds::fxP1[1], paramIds::fxP1[2], paramIds::fxP1[3], + paramIds::fxP1[4], paramIds::fxP1[5], paramIds::fxP1[6], paramIds::fxP1[7] }, + { paramIds::fxP2[0], paramIds::fxP2[1], paramIds::fxP2[2], paramIds::fxP2[3], + paramIds::fxP2[4], paramIds::fxP2[5], paramIds::fxP2[6], paramIds::fxP2[7] }, + { paramIds::fxP3[0], paramIds::fxP3[1], paramIds::fxP3[2], paramIds::fxP3[3], + paramIds::fxP3[4], paramIds::fxP3[5], paramIds::fxP3[6], paramIds::fxP3[7] }, + { paramIds::fxP4[0], paramIds::fxP4[1], paramIds::fxP4[2], paramIds::fxP4[3], + paramIds::fxP4[4], paramIds::fxP4[5], paramIds::fxP4[6], paramIds::fxP4[7] } }; for (int i = 0; i < 8; ++i) { const juce::String n = juce::String ("FX ") + juce::String (i + 1); params.push_back (f (fxType[i], n + " Type", 0.0f)); params.push_back (f (fxMix[i], n + " Mix", 0.5f)); - params.push_back (f (fxP[i][0], n + " P1", 0.5f)); - params.push_back (f (fxP[i][1], n + " P2", 0.5f)); - params.push_back (f (fxP[i][2], n + " P3", 0.5f)); - params.push_back (f (fxP[i][3], n + " P4", 0.5f)); + params.push_back (f (fxP[0][i], n + " P1", 0.5f)); + params.push_back (f (fxP[1][i], n + " P2", 0.5f)); + params.push_back (f (fxP[2][i], n + " P3", 0.5f)); + params.push_back (f (fxP[3][i], n + " P4", 0.5f)); } - // Macros - params.push_back (f (ids::macro1, "Macro 1", 0.0f)); - params.push_back (f (ids::macro2, "Macro 2", 0.0f)); - params.push_back (f (ids::macro3, "Macro 3", 0.0f)); - params.push_back (f (ids::macro4, "Macro 4", 0.0f)); + // Macros (display names come from MacroControls::macroName, the single source) + for (int i = 0; i < kNumMacros; ++i) + params.push_back (f (paramIds::macros[i], MacroControls::macroName (i), 0.0f)); return { params.begin(), params.end() }; } @@ -266,6 +261,9 @@ void SerumAltAudioProcessor::loadFactoryPreset (int index) continue; restoreLfoShapesFromState ({}); + // A preset starts from clean DSP state, so the previous preset's filter, + // delay and reverb tails cannot bleed into the new one. + engine.requestReset(); currentProgram.store (index); } @@ -356,6 +354,8 @@ void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBy engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX")); engine.getMacros().fromValueTree (state.getChildWithName ("MACROS")); restoreLfoShapesFromState (state); + // Loading a saved project is a patch change too: start from clean state. + engine.requestReset(); const double savedProgram = (double) state.getProperty ("currentProgram", 0); currentProgram.store (std::isfinite (savedProgram) @@ -406,7 +406,7 @@ void SerumAltAudioProcessor::restoreLfoShapesFromState (const juce::ValueTree& s continue; const double savedIndex = (double) lfo.getProperty ("index", -1); if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumLfos - || std::floor (savedIndex) != savedIndex) + || ! isWholeNumber (savedIndex)) continue; const int index = (int) savedIndex; if (restored[(size_t) index]) diff --git a/Source/Presets/AGENT.md b/Source/Presets/AGENTS.md similarity index 94% rename from Source/Presets/AGENT.md rename to Source/Presets/AGENTS.md index 52e425c..a3cf383 100644 --- a/Source/Presets/AGENT.md +++ b/Source/Presets/AGENTS.md @@ -30,4 +30,4 @@ pr.params = { { "oscAUnison", 0.4f } }; pr.params = { p (ids::oscAUnison, unison (7)) }; ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/Source/Resources/AGENT.md b/Source/Resources/AGENTS.md similarity index 94% rename from Source/Resources/AGENT.md rename to Source/Resources/AGENTS.md index 4a4e474..bc7bb1a 100644 --- a/Source/Resources/AGENT.md +++ b/Source/Resources/AGENTS.md @@ -31,4 +31,4 @@ g.setColour (juce::Colour (0xff4fc3f7)); g.setColour (theme::accent); ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/Source/SubOscillator.cpp b/Source/SubOscillator.cpp index 7cd78fd..665b358 100644 --- a/Source/SubOscillator.cpp +++ b/Source/SubOscillator.cpp @@ -3,12 +3,8 @@ namespace serum { -void SubOscillator::noteOn (double freqHz, int octaveShift) +void SubOscillator::noteOn() { - // octaveShift: -2, -1 or 0 (from the subOct param). - const double mult = (octaveShift == -2) ? 0.25 : (octaveShift == -1) ? 0.5 : 1.0; - (void) freqHz; - (void) mult; phase = 0.0; } @@ -18,14 +14,14 @@ void SubOscillator::processAdd (double freqHz, int shape, float level, float& ou return; // The octave shift is baked into freqHz by the voice; here we just phase-accumulate. - const double inc = kTwoPi * freqHz / sr; + const double inc = juce::MathConstants::twoPi * freqHz / sr; phase += inc; - phase -= std::floor (phase * (1.0 / kTwoPi)) * kTwoPi; + phase -= std::floor (phase * (1.0 / juce::MathConstants::twoPi)) * juce::MathConstants::twoPi; float s = 0.0f; if (shape == (int) SubShape::Triangle) { - const float p = (float) (phase * (1.0 / kTwoPi)); + const float p = (float) (phase * (1.0 / juce::MathConstants::twoPi)); s = 1.0f - 4.0f * std::abs (p - 0.5f); } else diff --git a/Source/SubOscillator.h b/Source/SubOscillator.h index f2f038f..610e584 100644 --- a/Source/SubOscillator.h +++ b/Source/SubOscillator.h @@ -15,7 +15,7 @@ public: void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; } void reset() { phase = 0.0; } - void noteOn (double freqHz, int octaveShift); + void noteOn(); // Accumulate into out (mono; the caller pans/levels it). void processAdd (double freqHz, int shape, float level, float& out) noexcept; @@ -23,7 +23,6 @@ public: private: double sr = 44100.0; double phase = 0.0; - static constexpr double kTwoPi = 6.28318530717958647692; }; } // namespace serum diff --git a/Source/SynthVoice.cpp b/Source/SynthVoice.cpp index d83dc3b..8372b6a 100644 --- a/Source/SynthVoice.cpp +++ b/Source/SynthVoice.cpp @@ -17,7 +17,7 @@ void SynthVoice::prepare (double sampleRate, int maxBlockSize) oscB.prepare (sampleRate); sub.prepare (sampleRate); noise.prepare (sampleRate); - filters.prepare (sampleRate, maxBlockSize); + filters.prepare (sampleRate); for (auto& e : env) e.prepare (sampleRate); scratch.setSize (2, maxBlockSize, false, false, true); @@ -55,7 +55,7 @@ void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce:: juce::Random rng (noteSeed); noteRandom = rng.nextFloat(); - sub.noteOn (freqHz, -1); + sub.noteOn(); noise.noteOn (noteSeed); for (auto& e : env) @@ -71,8 +71,20 @@ void SynthVoice::noteOff() e.noteOff(); } +float SynthVoice::getModulationValue (ModSource source) const noexcept +{ + if (source >= ModSource::Env1 && source <= ModSource::Env4) + return env[(size_t) ((int) source - (int) ModSource::Env1)].getValue(); + if (source == ModSource::Velocity) return velocity; + if (source == ModSource::Note) return clampF ((float) note / 127.0f, 0.0f, 1.0f); + if (source == ModSource::Random) return noteRandom; + return 0.0f; +} + void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept { + jassert (numSamples <= scratch.getNumSamples()); + if (! active || numSamples <= 0) return; @@ -118,14 +130,14 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC float v = src[(int) c.source]; if (c.bipolar && ! isBipolarSource (c.source)) v = v * 2.0f - 1.0f; - mod[(int) c.target] += v * c.depth; + mod[(size_t) c.target] += v * c.depth; } } if (ctx.macros != nullptr) { for (int m = 0; m < kNumMacros; ++m) for (const auto& a : ctx.macros->assignments[(size_t) m]) - mod[(int) a.target] += ctx.macroValues[m] * a.depth; + mod[(size_t) a.target] += ctx.macroValues[m] * a.depth; } // 5. Modulated oscillator parameters. @@ -157,8 +169,8 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC // without resetting existing phases when modulated at control rate. if (oscillatorsNeedNoteOn) { - oscA.noteOn (freqA, a, seed); - oscB.noteOn (freqB, b, seed + 1); + oscA.noteOn (a, seed); + oscB.noteOn (b, seed + 1); oscillatorsNeedNoteOn = false; } else diff --git a/Source/SynthVoice.h b/Source/SynthVoice.h index 98e3782..7e4e967 100644 --- a/Source/SynthVoice.h +++ b/Source/SynthVoice.h @@ -72,6 +72,7 @@ public: bool isReleased() const noexcept { return released; } int getNote() const noexcept { return note; } juce::uint64 getNoteId() const noexcept { return noteId; } + float getModulationValue (ModSource source) const noexcept; private: Oscillator oscA, oscB; diff --git a/Source/Tests/AGENT.md b/Source/Tests/AGENTS.md similarity index 96% rename from Source/Tests/AGENT.md rename to Source/Tests/AGENTS.md index 53ebe48..275da74 100644 --- a/Source/Tests/AGENT.md +++ b/Source/Tests/AGENTS.md @@ -37,4 +37,4 @@ if (! ok) ++failures; return failures == 0 ? 0 : 1; ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/Source/Tests/TestMain.cpp b/Source/Tests/TestMain.cpp index 9fb965d..a2954b4 100644 --- a/Source/Tests/TestMain.cpp +++ b/Source/Tests/TestMain.cpp @@ -9,6 +9,8 @@ // =========================================================================== namespace { + const int notes[3] = { 48, 60, 67 }; + bool hasNaN (const juce::AudioBuffer& b) { for (int ch = 0; ch < b.getNumChannels(); ++ch) @@ -32,6 +34,36 @@ namespace } return p; } + + // Plays three notes through the current preset, releases them partway, and + // reports whether the render stayed finite and non-silent overall. + bool runPreset (serum::SerumAltAudioProcessor& processor, const juce::String& label, double maxPeakFloor) + { + juce::MidiBuffer midi; + for (int n : notes) + midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0); + + juce::AudioBuffer buffer (2, 512); + float maxPeak = 0.0f; + bool nan = false; + + for (int block = 0; block < 240; ++block) + { + buffer.clear(); + processor.processBlock (buffer, midi); + midi.clear(); + if (block == 180) + for (int n : notes) + midi.addEvent (juce::MidiMessage::noteOff (1, n), 0); + nan = nan || hasNaN (buffer); + maxPeak = std::max (maxPeak, peak (buffer)); + } + + const bool ok = ! nan && maxPeak > (float) maxPeakFloor; + std::cout << (ok ? "PASS" : "FAIL") << " [" << label << "]" + << " peak=" << maxPeak << (nan ? " " : "") << "\n"; + return ok; + } } int main() @@ -44,64 +76,18 @@ int main() const int numPresets = processor.getNumPrograms(); int failures = 0; - const int notes[3] = { 48, 60, 67 }; - // Default (init) preset. - { - juce::MidiBuffer midi; - for (int n : notes) - midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0); - - juce::AudioBuffer buffer (2, 512); - float maxPeak = 0.0f; - bool nan = false; - - for (int block = 0; block < 240; ++block) - { - buffer.clear(); - processor.processBlock (buffer, midi); - midi.clear(); - if (block == 180) - for (int n : notes) - midi.addEvent (juce::MidiMessage::noteOff (1, n), 0); - nan = nan || hasNaN (buffer); - maxPeak = std::max (maxPeak, peak (buffer)); - } - - const bool ok = ! nan && maxPeak > 0.001f; - if (! ok) ++failures; - std::cout << (ok ? "PASS" : "FAIL") << " [Init] peak=" << maxPeak << (nan ? " " : "") << "\n"; - } + if (! runPreset (processor, "Init", 0.001)) + ++failures; // Every factory preset. for (int preset = 0; preset < numPresets; ++preset) { processor.setCurrentProgram (preset); + const juce::String name = processor.getProgramName (preset); - juce::MidiBuffer midi; - for (int n : notes) - midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0); - - juce::AudioBuffer buffer (2, 512); - float maxPeak = 0.0f; - bool nan = false; - - for (int block = 0; block < 240; ++block) - { - buffer.clear(); - processor.processBlock (buffer, midi); - midi.clear(); - if (block == 180) - for (int n : notes) - midi.addEvent (juce::MidiMessage::noteOff (1, n), 0); - nan = nan || hasNaN (buffer); - maxPeak = std::max (maxPeak, peak (buffer)); - } - - const bool ok = ! nan && maxPeak > 0.001f; - if (! ok) ++failures; - std::cout << (ok ? "PASS" : "FAIL") << " [" << processor.getProgramName (preset) << "]" - << " peak=" << maxPeak << (nan ? " " : "") << "\n"; + if (! runPreset (processor, name, 0.001)) + ++failures; } // RAVE toggle + parameter churn while playing (no crash / NaN). @@ -129,6 +115,90 @@ int main() std::cout << (ok ? "PASS" : "FAIL") << " [RAVE toggle / param churn]" << (nan ? " " : "") << "\n"; } + // FX stability: a preset change selects an FX type and its parameters, so + // every unit has to survive the extremes of its own parameter range. A unit + // that lets its state run away keeps producing inf/NaN, which is heard as + // grainy audio that gets no better. + { + const char* const typeLabels[] = { "Hyper", "Chorus", "Flanger", "Phaser", + "Distortion", "EQ", "Compressor", "Delay", "Reverb" }; + + for (int type = 1; type <= 9; ++type) + { + processor.setCurrentProgram (0); + auto set = [&processor] (const char* id, float value) + { + if (auto* p = processor.parameters.getParameter (id)) + p->setValueNotifyingHost (value); + }; + set ("fx1Type", (float) type / 9.0f); + set ("fx1Mix", 1.0f); + set ("fx1P1", 1.0f); + set ("fx1P2", 1.0f); + set ("fx1P3", 1.0f); + set ("fx1P4", 1.0f); + + juce::MidiBuffer midi; + midi.addEvent (juce::MidiMessage::noteOn (1, 60, (juce::uint8) 100), 0); + + juce::AudioBuffer buffer (2, 512); + bool nan = false; + for (int block = 0; block < 120; ++block) + { + buffer.clear(); + processor.processBlock (buffer, midi); + midi.clear(); + nan = nan || hasNaN (buffer); + } + + const bool ok = ! nan; + if (! ok) ++failures; + std::cout << (ok ? "PASS" : "FAIL") << " [FX " << typeLabels[type - 1] + << " at full parameter range]" << (nan ? " " : "") << "\n"; + } + } + + // A preset change starts from clean DSP state: the tail of the preset being + // left behind must not bleed into the one being loaded. + { + juce::AudioBuffer buffer (2, 512); + processor.setCurrentProgram (9); // Glass Bell, reverb mix 0.6 + + juce::MidiBuffer midi; + for (int n : notes) + midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0); + for (int block = 0; block < 40; ++block) + { + buffer.clear(); + processor.processBlock (buffer, midi); + midi.clear(); + } + for (int n : notes) + midi.addEvent (juce::MidiMessage::noteOff (1, n), 0); + for (int block = 0; block < 10; ++block) + { + buffer.clear(); + processor.processBlock (buffer, midi); + midi.clear(); + } + const float tail = peak (buffer); + + processor.setCurrentProgram (0); // the change under test + float residual = 0.0f; + for (int block = 0; block < 2; ++block) + { + buffer.clear(); + juce::MidiBuffer empty; + processor.processBlock (buffer, empty); + residual = std::max (residual, peak (buffer)); + } + + const bool ok = tail > 0.01f && residual < 1.0e-4f; + if (! ok) ++failures; + std::cout << (ok ? "PASS" : "FAIL") << " [preset change clears the previous patch]" + << " tail=" << tail << " residual=" << residual << "\n"; + } + std::cout << "\n" << (failures == 0 ? "ALL QA CHECKS PASSED" : "QA FAILURES DETECTED") << " (" << failures << " failures)\n"; return failures == 0 ? 0 : 1; diff --git a/Source/Wavetable.cpp b/Source/Wavetable.cpp index db4ae28..fa56dc0 100644 --- a/Source/Wavetable.cpp +++ b/Source/Wavetable.cpp @@ -18,7 +18,6 @@ void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int n frames.assign (kFrames, std::vector (kTableSize, 0.0f)); const float invN = 1.0f / (float) kTableSize; - constexpr double twoPi = 6.28318530717958647692; for (int f = 0; f < kFrames; ++f) { @@ -36,8 +35,8 @@ void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int n const float a = ampFn (f, h); const double m = std::abs ((double) a); mag[(size_t) h] = m; - phase[(size_t) h] = (a < 0.0f) ? twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base - const double ang = twoPi * (double) h * (double) invN; + phase[(size_t) h] = (a < 0.0f) ? juce::MathConstants::twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base + const double ang = juce::MathConstants::twoPi * (double) h * (double) invN; cosStep[(size_t) h] = std::cos (ang); sinStep[(size_t) h] = std::sin (ang); maxAmp = std::max (maxAmp, m); @@ -153,7 +152,6 @@ void WavetableLibrary::buildTable (int index) const namespace { inline float normT (int frame) { return (float) frame / 255.0f; } - constexpr float kPi = 3.14159265358979323846f; } // sine -> saw morph @@ -178,8 +176,8 @@ Wavetable WavetableLibrary::makeSawPwm() { const float t = normT (f); const float duty = 0.08f + 0.42f * t; // 8% -> 50% - const float a = std::sin (kPi * (float) h * duty); - return (2.0f / ((float) h * kPi)) * a; + const float a = std::sin (juce::MathConstants::pi * (float) h * duty); + return (2.0f / ((float) h * juce::MathConstants::pi)) * a; }, 48); return wt; } @@ -192,7 +190,7 @@ Wavetable WavetableLibrary::makeSquareSync() { const float t = normT (f); const float ratio = 1.0f + 3.0f * t; - const float comb = 0.5f + 0.5f * std::cos (2.0f * kPi * (float) h * ratio); + const float comb = 0.5f + 0.5f * std::cos (2.0f * juce::MathConstants::pi * (float) h * ratio); return (1.0f / std::pow ((float) h, 0.8f)) * comb; }, 48); return wt; @@ -211,7 +209,7 @@ Wavetable WavetableLibrary::makeTriangleFold() { const int k = (h - 1) / 2; const float sign = ((k & 1) == 0) ? 1.0f : -1.0f; - tri = sign * 8.0f / (kPi * kPi * (float) (h * h)); + tri = sign * 8.0f / (juce::MathConstants::pi * juce::MathConstants::pi * (float) (h * h)); } const float saw = 1.0f / (float) h; return tri + (saw - tri) * t; @@ -235,15 +233,11 @@ Wavetable WavetableLibrary::makeVowel() float from[3], to[3]; if (t < 0.5f) { - const float u = t * 2.0f; for (int k = 0; k < 3; ++k) { from[k] = aa[k]; to[k] = ee[k]; } - (void) u; } else { - const float u = (t - 0.5f) * 2.0f; for (int k = 0; k < 3; ++k) { from[k] = ee[k]; to[k] = ii[k]; } - (void) u; } float tt = (t < 0.5f) ? t * 2.0f : (t - 0.5f) * 2.0f; diff --git a/Source/engine/AGENT.md b/Source/engine/AGENTS.md similarity index 51% rename from Source/engine/AGENT.md rename to Source/engine/AGENTS.md index 234846c..c7fd0c2 100644 --- a/Source/engine/AGENT.md +++ b/Source/engine/AGENTS.md @@ -3,16 +3,16 @@ ## Related source files This directory contains guidance; the implementation remains in `../Engine.h` -and `../Engine.cpp`. The preparation rules below are requirements for changes; -current buffer resizing and lazy wavetable issues are recorded in the root AGENT.md. +and `../Engine.cpp`. ## Rules - Own the voice pool, LFOs, wavetable library, FX rack, matrix and macros by value. -- Read every APVTS value through the null-guarding `v()` helper. -- Build the `RenderContext` once per block and pass it to voices as `const&`. -- Size `mixBuffer` once in `prepare()` and only `clear()` it per block. -- Prebuild wavetables in `prepare()` before the first render. +- Bind APVTS pointers in `prepare()`; `v()` reads captured values without constructing parameter-name strings. +- Build the `RenderContext` once per host block and refresh its modulation inputs for each render segment. +- Render up to 64 samples at a time, splitting at MIDI event offsets and respecting prepared buffer capacity. +- Size `mixBuffer` once in `prepare()`; use non-owning views for shorter segments. +- Prebuild wavetables in the Engine constructor, before either GUI painting or audio rendering can read them. - Clamp the final output with the soft limiter. - Never call `setSize` on any buffer inside `processBlock`. @@ -33,16 +33,18 @@ void processBlock (...) ``` ```cpp -// GOOD: size once, clear per block +// GOOD: size once, then wrap a non-owning view per segment void prepare (double sr, int blockSize) { mixBuffer.setSize (2, blockSize, false, false, true); } void processBlock (...) { - mixBuffer.clear(); + // No allocation; the view only borrows mixBuffer's channels. + juce::AudioBuffer block (mixBuffer.getArrayOfWritePointers(), 2, segmentLength); + block.clear(); ... } ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/Source/modulation/AGENT.md b/Source/modulation/AGENTS.md similarity index 55% rename from Source/modulation/AGENT.md rename to Source/modulation/AGENTS.md index 54cad59..45bcb8d 100644 --- a/Source/modulation/AGENT.md +++ b/Source/modulation/AGENTS.md @@ -15,20 +15,20 @@ directory: - Require a cap on connection and assignment counts (`kMaxConnections`, `kMaxAssignments`). - Serialise and restore state through `juce::ValueTree` so presets survive save and load. - Never accept a `ModTarget::None` connection; reject it and return false. -- Use `ids::` for every APVTS parameter reference in RaveController. +- RaveController applies a non-destructive boost to render settings. It does not write APVTS parameters or keep a restoration snapshot. - Keep the existing RAVEButton filenames unless a rename is explicitly in scope. ## GUI/audio state -The current public connection and assignment vectors are shared mutable state. -GUI writes can race with audio-thread iteration. A mutex taken only by the writer -does not protect an unlocked reader, and taking a blocking mutex in the audio -callback conflicts with the real-time requirements. +Hold `Engine::getControlLock()` while reading or editing the control-side matrix, +macro assignments, or LFO shapes. The audio thread tries that lock once per block +and copies into reserved audio-side storage. If the lock is busy, it keeps the +previous settings and continues rendering. Never iterate the control-side vectors +from the render path. -When fixing this path, publish a stable snapshot or use a bounded thread-safe -handoff. Keep allocation and snapshot reclamation off the audio thread. Validate -reader and writer lifetimes together, including preset and state restore. +Master and FX-mix modulation use global sources directly. Per-voice sources for +those destinations come from the newest active voice. If `addConnection` or `addAssignment` returns false, handle the failed insert. -This file refines the repository-root AGENT.md for the related sources. +This file refines the repository-root AGENTS.md for the related sources. diff --git a/Source/params/AGENT.md b/Source/params/AGENTS.md similarity index 78% rename from Source/params/AGENT.md rename to Source/params/AGENTS.md index 3991f67..d7eaceb 100644 --- a/Source/params/AGENT.md +++ b/Source/params/AGENTS.md @@ -9,6 +9,10 @@ and `../Params.cpp`. - Use the `ids::` namespace for every APVTS parameter ID string. - Require one `ids::` entry per audio parameter, and one entry only. +- Reach indexed parameters through the `paramIds::` arrays in `../Params.h`, which pair each + ID with its slot: `paramIds::oscillators`, `paramIds::envAttack`, `paramIds::lfoRate`, + `paramIds::fxType`, `paramIds::macros` and their siblings. Use a single `ids::` entry only + when the parameter has no index. - Use `enum class` for every enum. Give each a trailing `Count` enumerator. - Use `inline constexpr` for parameter ID strings and `k`-prefixed integer constants. - Keep all 0..1 to physical unit conversions in the `maps::` namespace. @@ -36,4 +40,4 @@ if (auto* p = apvts.getParameter (ids::f1Cutoff)) return p->getValue(); ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/Source/plugin/AGENT.md b/Source/plugin/AGENTS.md similarity index 71% rename from Source/plugin/AGENT.md rename to Source/plugin/AGENTS.md index a0a10af..93c7de8 100644 --- a/Source/plugin/AGENT.md +++ b/Source/plugin/AGENTS.md @@ -13,7 +13,10 @@ This directory contains guidance; the implementation remains in - Guard `getRawParameterValue` and `getParameter` results before dereferencing them. - Return silently on null or invalid state XML and invalid ValueTrees. - Use raw `new` only for JUCE-owned objects (`createEditor`, `createPluginFilter`). -- Prefer const accessors over the public `parameters`, `engine` and `rave` fields for new code. +- Hold the engine control lock across preset and state changes; preserve parameter IDs and normalized values for saved-project compatibility. +- Factory presets reset sound parameters before applying overrides, while preserving UI scale. +- Save one copy of each custom state section. LFO shape arrays are stored as JSON text inside XML. +- RAVE is an ordinary parameter that enables a render-time boost; it does not rewrite other parameters. ## IF-THEN @@ -38,4 +41,4 @@ if (! state.isValid()) return; ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/Source/synth/AGENT.md b/Source/synth/AGENTS.md similarity index 77% rename from Source/synth/AGENT.md rename to Source/synth/AGENTS.md index 1876b9d..033a3f0 100644 --- a/Source/synth/AGENT.md +++ b/Source/synth/AGENTS.md @@ -3,8 +3,7 @@ ## Related source files This directory contains guidance. All files listed below remain in the parent -`Source/` directory. The preparation rules below are requirements for changes, -not a claim that lazy wavetable allocation has already been fixed. +`Source/` directory. - Wavetable.h, Wavetable.cpp - Oscillator.h, Oscillator.cpp @@ -26,10 +25,11 @@ not a claim that lazy wavetable allocation has already been fixed. - Use `(size_t)` casts when indexing containers with int loop variables. - Pass global per-block state into voices as a `const RenderContext&`. - Never allocate memory in the audio path. +- Keep oscillator phase when changing held-note parameters. LFO phase offsets are separate from the running phase. ## IF-THEN -- IF a wavetable is read in the render path THEN build all tables in `prepare()` by calling `WavetableLibrary::prebuild()` before the first block. +- Wavetables are prebuilt during Engine construction. Audio and GUI code read them without triggering generation. - IF a parameter is read every block THEN snapshot it into a struct (`OscParams`, `RenderContext`) instead of reading the APVTS per sample. ## Examples @@ -40,8 +40,8 @@ const Wavetable& wt = library.getTable (wave); ``` ```cpp -// GOOD: build once at prepare time, then only read const references -library.prebuild(); // in prepareToPlay +// GOOD: build once in the Engine constructor, then only read const references +library.prebuild(); // in the Engine constructor const Wavetable& wt = library.getTable (wave); // lookup, no allocation ``` @@ -55,4 +55,4 @@ const double step = 6.28318530717958647692 * freq / sr; const double step = juce::MathConstants::twoPi * freq / sr; ``` -This file overrides /AGENT.md where they conflict. +This file overrides /AGENTS.md where they conflict. diff --git a/UI_UX_AUDIT.md b/UI_UX_AUDIT.md new file mode 100644 index 0000000..96e87d9 --- /dev/null +++ b/UI_UX_AUDIT.md @@ -0,0 +1,412 @@ +# SerumAlt UI and UX audit + +Written from the working tree on branch `main`. The user report was "much of +features arent correctly described and UI is bearly functional". That bundles +two problems, and this audit keeps them apart: + +1. Description. Controls lack names, units and plain descriptions. +2. Function. Some controls do not reach the DSP they claim to drive. + +The single largest finding explains the "barely functional" complaint on its +own. No knob ever painted its name. `Knob` is a `juce::Slider` with +`NoTextBox`, and JUCE does not draw a slider's component name, so every one of +the roughly fifty knobs was an unlabelled arc whose only identification was a +tooltip hover. The label strings passed to `makeKnob` were correct but +invisible, and the runtime FX rename table in `updateVisuals` never reached a +user's eyes. + +--- + +## How this was produced + +Reconnaissance read `Source/PluginEditor.{h,cpp}`, `Source/Params.{h,cpp}`, +`Source/Resources.{h,cpp}`, `Source/PluginProcessor.cpp`, `Source/Engine.cpp`, +the nine effects in `Source/EffectUnits/`, and every file in `Source/GUI/`. +Four specialist passes then covered naming and descriptions, dead controls, +interaction state, and layout plus accessibility. Findings were checked against +`AUDIT_REPORT.md` (a codebase structure audit, no UI text coverage) and +`JANITOR_TASKS.md` (fourteen tasks, all reported done; JT-01 removed the unused +look-and-feel, JT-06 built the FX type list from `FXProcessor::fxTypeName`). +Nothing in either file duplicates the findings below. + +The two prior documents also correct the task brief in one respect. +`Source/GUI/SerumLookAndFeel.h` no longer exists; JT-01 deleted it because it was +never installed. The GUI has no custom look-and-feel, so "draw all controls +through `SerumLookAndFeel`" resolves to "use `theme::` colours", which this work +does. + +Verification used `BUILD_DIR=/tmp/... ./build_harness.sh --run`. +`UI_UX_AUDIT.md` is the deliverable that survives; the fixes listed as applied +are already in the tree. + +--- + +## Status vocabulary + +- Functioning. The parameter is read by the DSP and changes what you hear. +- Dead. Nothing reads it, or the thing that reads it throws the value away. +- Conditional. Read and audible only in some modes or for some effect types. +- Design decision. Fixing it needs a choice about behaviour or layout, so it was + left alone and recorded for the user. + +--- + +## Control inventory + +Every user-visible control, by tab. "Readout" is what the knob shows while you +drag it. + +### Top bar + +| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status | +|---|---|---|---|---|---|---|---| +| Preset combo | n/a | (preset name) | Select a factory preset | Loads one of the factory presets and replaces the whole patch | Preset | Loads a factory preset and replaces the current patch | Functioning | +| RAVE toggle | `rave` | RAVE | RAVE one-click boost (unison, width, drive, OTT, reverb) | Render-time override: unison to 8, spread and detune to full, both filter drives to 60 %, Hyper intensity to 100 %, reverb mix +0.4 | Rave | Adds unison, width, drive, OTT and reverb to the current patch | Functioning, description was accurate, label case fixed | +| Scale combo | `uiScale` | 75%..200% | Interface scale (75% - 200%) | Window scale, 75 % to 200 %. Index maps to a five-entry scale table | Scale | Interface scale, 75 % to 200 % | Functioning | +| Master knob | `master` | (invisible) | Master output volume (0-100%) | Master output gain, 0 to 100 % of full scale | Master | Master output level, 0 % to 100 % | Functioning. Percent is not the engineering unit for a gain, see deferred D-8 | +| Master readout | `master` | MASTER | none | Painted numeric readout of the same parameter | Master | none needed | Functioning | +| Tabs | n/a | OSC, FILTER, MOD, FX, MACRO | Oscillators (Tab cycles sections) etc. | Switches the visible section. Plain Tab also cycles sections | Osc, Filter, Mod, FX, Macro | Oscillators. Tab cycles sections. | Functioning, label case fixed | + +### Oscillator tab + +Oscillator A and Oscillator B have identical control sets. Rows 1 and 2 are +listed once. + +| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status | +|---|---|---|---|---|---|---|---| +| On | `oscAOn` / `oscBOn` | On | Enable oscillator A | Gates the oscillator in `Oscillator::render` | On | Turns oscillator A on or off | Functioning | +| Wavetable | `oscAWave` / `oscBWave` | (combo, no visible label) | Oscillator A wavetable | Selects one of 10 factory wavetables | Table | Selects the wavetable, 10 factory tables | Functioning, combo shows its own item text | +| Warp mode | `oscAWarp` / `oscBWarp` | (combo) | Oscillator A warp mode | Selects one of 8 warp modes | Warp | Selects the wavetable warp mode | Functioning | +| Wavetable position | `oscAWtPos` / `oscBWtPos` | Wavetable Position | Wavetable position (0-100%) | Frame position in the table, 0 to 100 % | Wt position | Wavetable frame position, 0 % to 100 % | Functioning | +| Warp amount | `oscAWarpAmt` / `oscBWarpAmt` | Warp Amount | Warp amount (0-100%) | Warp depth, 0 to 100 % | Warp amount | Warp depth, 0 % to 100 % | Functioning | +| Coarse | `oscACoarse` / `oscBCoarse` | Coarse | Pitch coarse (-24 to +24 semitones) | Coarse pitch, -24 to +24 semitones | Coarse | Coarse pitch, -24 to +24 semitones | Functioning | +| Fine | `oscAFine` / `oscBFine` | Fine | Pitch fine (-100 to +100 cents) | Fine pitch, -100 to +100 cents | Fine | Fine pitch, -100 to +100 cents | Functioning | +| Level | `oscALevel` / `oscBLevel` | Level | Oscillator A level (0-100%) | Linear amplitude gain, 0 to 1 | Level | Oscillator A level, 0 % to 100 % | Functioning | +| Pan | `oscAPan` / `oscBPan` | Pan | Oscillator A pan (L100-R100) | Stereo position, hard left to hard right | Pan | Oscillator A pan, hard left to hard right | Functioning | +| Unison | `oscAUnison` / `oscBUnison` | Unison | Unison voices (1-16) | Voice count, 1 to 16. Now detented so the dial rests on whole voices | Unison | Unison voices, 1 to 16 | Functioning | +| Detune | `oscADetune` / `oscBDetune` | Detune | Unison detune (0-100%) | Per-voice detune spread, -50 to +50 cents | Detune | Unison detune spread, -50 to +50 cents | Functioning. Readout used to say "50 %" for 25 cents | +| Spread | `oscASpread` / `oscBSpread` | Spread | Unison spread (0-100%) | Stereo spread of the unison voices, 0 to 100 % | Spread | Unison stereo spread, 0 % to 100 % | Functioning | +| Phase | `oscAPhase` / `oscBPhase` | Phase | Phase (0-100%) | Start phase at note on, 0 to 360 degrees | Phase | Start phase at note on, 0 to 360 degrees | Functioning. Readout used to say a percentage | +| Random phase | `oscARandPh` / `oscBRandPh` | Random Phase | Random phase (0-100%) | Random start phase per voice, up to 360 degrees | Rand phase | Random start phase per voice, 0 to 360 degrees | Functioning. Readout used to say a percentage | +| Sub On | `subOn` | On | Enable sub oscillator | Gates the sub oscillator | On | Turns the sub oscillator on or off | Functioning | +| Sub shape | `subShape` | (combo) | Sub oscillator waveform | Sine or triangle | Shape | Sub oscillator waveform, sine or triangle | Functioning | +| Sub octave | `subOct` | (combo) | Sub oscillator octave | -2, -1 or 0 octaves, engine reads `value - 2` | Octave | Sub oscillator octave, 2 below to unison | Functioning | +| Sub level | `subLevel` | Level | Sub oscillator level (0-100%) | Linear amplitude gain | Level | Sub oscillator level, 0 % to 100 % | Functioning | +| Noise On | `noiseOn` | On | Enable noise | Gates the noise source | On | Turns noise on or off | Functioning | +| Noise type | `noiseType` | (combo) | Noise type | White or pink | Type | Noise colour, white or pink | Functioning | +| Noise level | `noiseLevel` | Level | Noise level (0-100%) | Linear amplitude gain | Level | Noise level, 0 % to 100 % | Functioning | + +### Filter tab + +| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status | +|---|---|---|---|---|---|---|---| +| On | `f1On` / `f2On` | On | Enable filter 1 | Gates the filter in `FilterBank` | On | Turns filter 1 on or off | Functioning | +| Type | `f1Type` / `f2Type` | (combo) | Filter 1 type | One of 7 models: Ladder LP, Ladder HP, Ladder BP, Diode, Comb, Formant, Screamer | Type | Selects the filter 1 model | Functioning | +| Slope | `f1Slope` / `f2Slope` | (combo) 6 dB, 12 dB, 24 dB | Filter 1 slope | One, two or four one-pole stages on the ladder and diode models. Comb, Formant and Screamer ignore it | Slope | Slope, 6, 12 or 24 dB per octave on the ladder and diode models | Conditional, see D-2 | +| Cutoff | `f1Cutoff` / `f2Cutoff` | Cutoff | Filter 1 cutoff (20 Hz - 20 kHz) | Cutoff, 20 Hz to 20 kHz before key tracking, clamped to 18 kHz after it | Cutoff | Filter 1 cutoff, 20 Hz to 18 kHz | Functioning. Tooltip overstated the reachable top | +| Resonance | `f1Res` / `f2Res` | Resonance | Filter 1 resonance (0-100%) | Resonance feedback, 0 to 98 % | Resonance | Filter 1 resonance, 0 % to 100 % | Functioning | +| Drive | `f1Drive` / `f2Drive` | Drive | Filter 1 drive (0-100%) | Per-sample drive. Damps the comb model. Formant ignores it | Drive | Filter 1 drive, 0 % to 100 %. Damps the comb model. | Conditional, see D-3 | +| Keytrack | `f1Key` / `f2Key` | Keytrack | Filter 1 keytrack (0-100%) | Cutoff follows note pitch, 100 % is full tracking | Keytrack | Filter 1 key tracking, 0 % to 100 % | Functioning | +| Routing | `fRoute` | (combo) | Filter routing (serial / parallel / split) | Serial, parallel or split | Routing | Filter routing, serial, parallel or split | Functioning | +| Mix | `fMix` | Mix | Filter mix (0-100%) | Crossfades filter 1 into filter 2. Read only in parallel routing | Mix | Blends filter 2 over filter 1 in parallel routing, 0 % to 100 % | Conditional, see D-1 | +| Output | `fOut` | Output | Filter output (0-100%) | Output gain, engine multiplies by 1.5, so the 0.667 default is unity | Output | Filter output gain, 0 % to 150 % | Functioning. The old readout understated the range: 67 % is unity | + +### Modulation tab + +| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status | +|---|---|---|---|---|---|---|---| +| Attack | `env1A`..`env4A` | Attack | Attack time (0.5 ms - 12 s) | Attack time, 0.5 ms to 12 s, cubic taper | Attack | Attack time, 0.5 ms to 12 s | Functioning | +| Decay | `env1D`..`env4D` | Decay | Decay time (0.5 ms - 12 s) | Decay time, 0.5 ms to 12 s | Decay | Decay time, 0.5 ms to 12 s | Functioning | +| Sustain | `env1S`..`env4S` | Sustain | Sustain level (0-100%) | Sustain level, 0 to 100 % | Sustain | Sustain level, 0 % to 100 % | Functioning | +| Release | `env1R`..`env4R` | Release | Release time (0.5 ms - 12 s) | Release time, 0.5 ms to 12 s | Release | Release time, 0.5 ms to 12 s | Functioning | +| Curve | `env1Curve`..`env4Curve` | Curve | Envelope curve (0-100%) | Shape exponent, mapped to 0.3 to 3.0 | Curve | Curve shape. Low is a fast attack and slow decay, high is the reverse. | Functioning. The readout is still a percentage of an exponent, see D-9 | +| Envelope graph | n/a | n/a | none | ADSR curve preview, drawn from the same shape functions as the DSP | n/a | Add a tooltip naming the envelope | Functioning | +| Shape | `lfo1Shape`..`lfo4Shape` | (combo) | LFO shape | Sine, Triangle, Saw, Square, S&H, Step, Freehand | Shape | Selects the LFO waveform | Functioning | +| Sync | `lfo1Sync`..`lfo4Sync` | Sync | Tempo-sync LFO | Locks the rate to host tempo and uses Beat instead of Rate | Sync | Locks the LFO to host tempo and uses the Beat knob | Functioning | +| Rate | `lfo1Rate`..`lfo4Rate` | Rate | LFO rate (0.02 Hz - 30 Hz) | Rate, 0.02 Hz to 30 Hz, log taper | Rate | LFO rate, 0.02 Hz to 30 Hz | Functioning. Readout used to say "50 %" for about 0.77 Hz | +| Beat | `lfo1Beat`..`lfo4Beat` | Beat | Beat division (1/32 - 4 bars) | Period of one cycle, one of 16 divisions from 1/32 beat to 16 beats | Beat | Cycle length, 1/32 beat to 16 beats | Functioning. Readout used to say a percentage, and the old tooltip was wrong at the fast end | +| Phase | `lfo1Phase`..`lfo4Phase` | Phase | Phase (0-100%) | Start phase offset, 0 to 360 degrees | Phase | Start phase, 0 to 360 degrees | Functioning. Readout used to say a percentage | +| Fade | `lfo1Fade`..`lfo4Fade` | Fade | Fade in (0-100%) | Fade in time, 0 to 8 seconds | Fade | Fade in time, 0 s to 8 s | Functioning. Readout and tooltip both said percent for seconds | +| Delay | `lfo1Delay`..`lfo4Delay` | Delay | Start delay (0-100%) | Start delay, 0 to 4 seconds | Delay | Start delay, 0 s to 4 s | Functioning. Readout and tooltip both said percent for seconds | +| LFO graph | n/a | n/a | none | Shape preview. Step and Freehand are drag-editable | n/a | Describe the drag gesture | Functioning, discoverability is D-11 | +| Source | n/a | (combo) | Modulation source | One of 17 sources | Source | Selects the modulation source | Functioning | +| Target | n/a | (combo) | Modulation destination | One of 35 destinations | Target | Selects the modulation destination | Functioning | +| Depth | n/a | (invisible) | Modulation depth (-1 to +1) | Amount applied to the next Add, -100 % to +100 % of the target range. Negative inverts | Depth | Modulation amount for the next Add, -100 % to +100 % of the target range | Functioning. Readout and tooltip rewritten | +| Bipolar | n/a | Bipolar | Bipolar modulation depth | Recentres a one-sided source for the next Add | Bipolar | Recentres a one-sided source so the modulation swings both ways, for the next Add | Functioning | +| Add | n/a | Add | Add modulation connection | Appends a connection, capped at 32 | Add | Adds the selected source and destination to the matrix | Functioning | +| Remove last | n/a | Remove Last | Remove last modulation connection | Removes the newest row only | Remove last | Removes the newest matrix row, older rows stay | Functioning | +| Clear | n/a | Clear | Clear all modulation connections | Removes every connection | Clear all | Removes every modulation connection | Functioning | +| Connection list | n/a | (read-only text) | none | Source, target, depth and polarity per row | n/a | Every modulation connection, newest last | Functioning | + +### Effects tab + +Each of the eight slots has the same shape: a type combo, up and down arrows, +and five knobs. The knob names, tooltips and readout units follow the selected +effect, and that table was rebuilt from the DSP each effect actually runs. + +| Control | Parameter | Current label | What it does and its unit | Readout unit | Status | +|---|---|---|---|---|---| +| Type | `fx1Type`..`fx8Type` | (combo) | Off, Hyper, Chorus, Flanger, Phaser, Distortion, EQ, Compressor, Delay, Reverb | n/a | Functioning | +| Up / Down | n/a | arrow glyphs | Swaps this slot's six parameters with the neighbour | n/a | Functioning. The end arrows are now disabled instead of doing nothing | +| Mix | `fx1Mix`..`fx8Mix` | Mix | Dry and wet mix, 0 to 100 % | percent | Functioning | +| Off P1..P4 | `fx*P1`..`fx*P4` | Unused | The rack skips an Off slot, so all four do nothing | n/a | Dead by design, now labelled and greyed | +| Hyper | | Intensity, Low Amount, High Amount, Output | 0-100 %, 40-100 %, 40-100 %, 0.5x-1.5x | percent, percent, percent, x | Functioning | +| Chorus | | Rate, Depth, Width, Unused | 0.1-5 Hz, 0.5-6 ms, 0-100 %, nothing | Hz, ms, percent | Rate and Depth used to read as percent; P4 is genuinely dead | +| Flanger | | Rate, Depth, Feedback, Unused | 0.05-2.05 Hz, 0.3-5 ms, 0-90 %, nothing | Hz, ms, percent | Rate and Depth used to read as percent; P4 is genuinely dead | +| Phaser | | Rate, Depth, Feedback, Stages | 0.05-2.05 Hz, 30-90 %, 0-70 %, 2-6 | Hz, percent, percent, count | Rate used to read as percent; Stages used to read "50 %" | +| Distortion | | Drive, Shape, Tone, Output | 1x-25x, 0-100 %, 800 Hz-16 kHz, 0.4x-1.6x | x, percent, Hz, x | All four used to read as percent | +| EQ | | Low Gain, Mid Gain, High Gain, Mid Freq | +/-15 dB, +/-12 dB, +/-15 dB, 200 Hz-5 kHz | dB, dB, dB, Hz | All four used to read as percent | +| Compressor | | Threshold, Ratio, Attack, Release | -40-0 dB, 2:1-20:1, 2-100 ms, 30-500 ms | dB, ratio, ms, ms | All four used to read as percent, and two of them share a name with a different unit on the Modulation tab | +| Delay | | Time, Feedback, Damping, Ping-Pong | 1-2000 ms, 0-92 %, 0-95 %, on or off | ms, percent, percent, on/off | Time used to read as percent; Ping-Pong used to read "50 %" | +| Reverb | | Size, Damping, Width, Predelay | 20-99 %, 0-100 %, 0-100 %, 0-150 ms | percent, percent, percent, ms | Predelay used to read as percent | + +The old tooltips were worse than the readouts. They were only rewritten when a +knob's name changed, so two effects sharing a name (Rate, Damping, Feedback, +Output) left the previous effect's tooltip on screen, and every one of them +claimed "normalised 0-100%". + +### Macro tab + +| Control | Parameter | Current label | Current tooltip | What it does and its unit | Recommended label | Recommended tooltip | Status | +|---|---|---|---|---|---|---|---| +| Macro knobs | `macro1`..`macro4` | Energy, Width, Drive, Atmosphere | (name) macro (0-100%) | Macro amount, 0 to 100 %, drives its assigned destinations | Energy / Width / Drive / Atmosphere | Energy macro, 0 % to 100 %, drives its assigned destinations | Functioning. Names come from `MacroControls::macroName`; the macro panels have no title of their own, so the knob label is the only identifier | +| Macro | n/a | (combo) | Macro to assign | Selects which of the four macros to edit | Macro | Selects the macro to edit | Functioning | +| Target | n/a | (combo) | Destination parameter | One of 35 destinations | Target | Selects the destination parameter | Functioning | +| Depth | n/a | (invisible) | Macro assignment depth (0 to 1) | Amount applied to the next Assign, 0 to 100 % of the target range | Depth | Macro amount for the next Assign, 0 % to 100 % of the target range | Functioning. Started at zero, so a default Assign was silent; now starts at half | +| Assign | n/a | Assign | Assign destination to the selected macro | Appends an assignment, capped at 8 per macro | Assign | Adds this destination to the selected macro | Functioning | +| Clear all | n/a | Clear All | Clear all macro assignments | Empties all four macros | Clear all | Removes every macro assignment from all four macros | Functioning | +| Assignment list | n/a | (read-only text) | none | Target and depth per macro | n/a | Every macro assignment, grouped by macro | Functioning | + +--- + +## Applied mechanical fixes + +Every item below was applied, compiles, and passes the harness. + +### Description fixes + +- A1. Knobs now paint their name. New `Source/GUI/KnobLookAndFeel.{h,cpp}` draws + the dial from `theme::` colours and reserves a 16 px strip at the foot of the + knob for the label, which `Knob::paint` fills. This is the change that makes + every other label in the editor visible for the first time. +- A2. All eight FX parameter knobs are named, tooltipped and unit-formatted per + effect type from one table (`kFxParamDisplay` in `PluginEditor.cpp`), replacing + the two tables that disagreed with each other. +- A3. Unit formatters added: LFO rate in Hz, LFO beat as a cycle length, LFO fade + and delay in seconds, oscillator detune and both phases in cents and degrees, + filter output as a percentage of its real 0-150 % range, modulation depth as + -100 % to +100 %, macro depth as 0-100 %, compressor threshold and ratio, + filter and EQ gains in dB, chorus and flanger rate in Hz and depth in ms, + distortion drive and output as multiples, phaser stage count, ping-pong as on + or off, reverb predelay in ms. +- A4. Tooltips rewritten for every control that had a vague one: all eight + effect types, both filters (cutoff now says 18 kHz, which is the real ceiling), + all four envelopes, all four LFOs, the modulation matrix buttons, the macro + buttons, the filters' drive and slope, both lists, the preset and scale combos, + and the RAVE toggle. +- A5. Labels corrected where the name was wrong or abbreviated: tab labels are + now Osc/Filter/Mod/FX/Macro, "Random Phase" is "Rand phase" to fit its 76 px + knob, and the RAVE toggle reads "Rave". + +### Function fixes that were one line each + +- B1. Double click restored a hardcoded 0.5 for every knob. Forty-nine knob + parameters have a different default, so the gesture silently wrote the wrong + value. `makeKnob` now reads the parameter's own default through + `AudioProcessorParameter::convertFrom0to1`. The modulation depth knob double + clicks to its neutral zero, and the macro depth knob to its new half. +- B2. The macro depth knob started at zero, so an Assign made without touching + it created an inaudible assignment. It now starts at half. +- B3. Unison had no detent, so the dial rested between voices. Both unison knobs + now step in whole voices. +- B4. The first FX slot's up arrow and the last slot's down arrow were enabled + but did nothing. They are now disabled. +- B5. An FX parameter the selected effect ignores stayed live and editable. Those + knobs are now disabled and labelled Unused. +- B6. The knob value popup was never clamped to the window, so it was cut off for + knobs near an edge. It is now clamped on both axes. +- B7. The popup is parented to the editor while the interface scale is applied to + a child, so it ignored the 75-200 % scale. It now measures the scale from its + own transform and sizes itself to match. +- B8. Switching section mid-drag could strand the popup on screen, because it is + not a child of the hidden view. `Knob::visibilityChanged` now takes it down. + +### Interaction and accessibility fixes + +- C1. Toggle buttons repainted on mouse activity but never read the hover state, + so every On, Sync and Bipolar LED looked identical under the cursor. `paint` + now brightens the LED and the name on hover and dims both when disabled. +- C2. `ToggleButton::paint` ignored `isEnabled()`. It no longer does. +- C3. Knobs now take keyboard focus. `juce::Slider` disables focus in its own + constructor, which left every knob unreachable without a mouse and cut off the + accessible value interface. + +### Layout constant fixes + +`Source/GUI/AGENTS.md` requires every position to be derived from the `layout` +namespace. Each tab declared its own private `colW`, `colGap` and panel heights, +and the tab-bar height was the bare literal `60` in six places. Those metrics now +live in `layout`, and the per-tab locals alias them, so the value has one home: + +- L1. Named the tab-bar height as `layout::tabBarHeight` and used it in all five + tab views plus the matrix and macro panel height calculations. +- L2. Hoisted the panel grids: `twoColW`, `threeColW`, `fourColW`, `fourColGap`, + and the panel heights `oscPanelH`, `smallPanelH`, `filterPanelH`, `envPanelH`, + `lfoPanelH`, `macroPanelH`. +- L3. Named `depthKnobHeight` for the matrix and macro depth knob, which was the + literal `60` in two `setBounds` calls and again inside both list tops. +- L4. Named `toggleWide` for the Bipolar toggle width, which was the literal `60` + in its `setBounds` and again in the `addX` offset that had to stay in step + with it. +- L5. Named `headerCentreY` for the header row centre line, which was + `titleHeight + 30` in two places with the `30` unexplained. + +The first attempt at L5 derived the centre from `headerHeight`, which is the +36 px toggle, and produced 40 instead of 52. That would have shifted the whole +matrix and macro header row by 12 px. It is derived from `depthKnobHeight` now, +and a separate check compiles the namespace and asserts all fourteen new +constants equal the literals they replaced. + +Two layout reports were left in place rather than acted on. The top bar still +positions its controls with literals (`presetCombo` at 200, 16, 220, 24; +`masterKnob` at 906, 0, 64, 60; the tab pitch of 92, and so on), because naming +those means deciding what the top bar's own grid is, which is a design choice +rather than a rename. The `layout::labelHeight` constant was deleted: it was +declared and never read. + +--- + +## Deferred items needing a design decision + +None of these were applied. Each changes behaviour, layout or sound, and each +needs the user's call. + +- D-1. Filter Mix does nothing in serial and split routing. `FilterBank` reads + `p.mix` only in the parallel branch. Either define a crossfade for the other + two routes or disable the knob in them. Real DSP work. +- D-2. Filter Slope does nothing for Comb, Formant and Screamer, and on + Ladder BP the 6 dB and 12 dB settings produce the same two-stage filter. + Making the label honest needs DSP work; the tooltip now at least names the + models it affects. +- D-3. Filter Drive does nothing for Formant. `Filter::formant` is called + without the drive argument. +- D-4. The Modulation and Macro depth knobs and the Bipolar toggle only affect + the next Add or Assign. They look like live controls for an existing + connection. Relabel them as defaults, or make them edit the selected row. +- D-5. The first FX slot's down arrow moves an effect earlier in the chain while + pointing down. In the two-column grid the reading order wraps right to left + between rows, so slot 2 sits to the right of slot 3. Number the slots, or + relabel the arrows for a two-column grid. +- D-6. The Filter tab leaves about 370 px of empty space below three 300 px + panels. Nothing breaks, but the tab looks unfinished. +- D-7. The OSC tab puts Sub and Noise in a second row beneath both oscillators. + The reading order is fine; the geometry suggests Sub belongs to Osc A and + Noise to Osc B without saying so. +- D-8. Master and the oscillator, sub and noise Level knobs show a percentage + for a linear gain. dB is the engineering unit. Changing it changes what every + readout says, so it was left as percent. +- D-9. Envelope Curve shows a percentage of a shape exponent that runs 0.3 to + 3.0. "Amount" or a named curve would be better than a percentage. +- D-10. No control marks itself as changed from its default. Nothing reads + `getDefaultValue()`. +- D-11. Step and Freehand LFO shapes are drag-editable, but nothing says so: no + tooltip on an editable graph, no cursor change, no hover state, no undo, and a + preset load silently discards the drawn shape. `LFODisplay` is not a + `SettableTooltipClient` today. +- D-12. Plain Tab switches section, which means JUCE's focus traversal never + runs and a keyboard-only user cannot reach any control except a combo box or a + text editor. This is a convention choice: keep Tab for sections and move it to + Ctrl+Tab, or keep Tab for controls and use function keys for sections. +- D-13. `ToggleButton` derives from `juce::Component`, so it has no accessible + role, name or state, and cannot be activated from the keyboard. Deriving from + `juce::Button` would fix all of that at once but changes the class contract. +- D-14. The waveform, filter, envelope and LFO graphs have no text or numeric + equivalent, so a screen reader announces nothing for any of them. Each needs a + description string. +- D-15. Knobs have no focus outline. `hasKeyboardFocus` is read nowhere. +- D-16. No group is marked, so a group that the selected shape ignores keeps its + old values. + +--- + +## Verified as functioning, with evidence + +The following were suspected dead and are not. Recorded so the check is not +repeated. + +- Modulation depth and polarity reach `SynthVoice` and `Engine`, and the bipolar + flag changes the modulation math. +- Macro depth reaches both the global and per-voice modulation sums. +- Remove Last removes the newest matrix row, matching its label, with no hidden + ordering rule. +- The master readout and the master knob drive the same parameter the output + stage uses. +- The filter type and LFO shape combo indices agree between the combo, the + stored value, the DSP and the display, for all 7 and all 7 values. +- The UI scale combo's five items map exactly onto the five-entry scale table. +- All six On toggles gate their DSP. All eight warps, all four sub and noise + controls, all four envelopes, all four LFOs, and the matrix and macro editors + are live. +- RAVE's tooltip matched its implementation exactly. +- No knob can write outside its parameter range, and the Unison readout and the + DSP round identically. + +--- + +## Janitor cleanups made beyond UI text + +Kept separate from the fixes above. + +- J-1. Deleted the now-dead formatter `fmtDepth`, which the modulation and macro + depth knobs were the only users of. +- J-2. Deleted `layout::labelHeight`, which was declared and never read. Toggle + and readout label sizes are hardcoded, so the constant described nothing. +- J-3. Replaced the duplicated per-effect label table with the single + `kFxParamDisplay` table, which removes the latent stale-tooltip bug rather than + only fixing its symptoms. +- J-4. `Source/GUI/Slider.h` and `Slider.cpp` define a horizontal slider that no + code constructs. Reported only: deleting it means editing `CMakeLists.txt` and + the GUI module's owned-file list, which is wider than this change. Recommend + it as a follow-up. +- J-5. `MacroControls::removeAssignment` is defined and never called. The Macro + tab has no control that would call it, so a single macro assignment cannot be + deleted. Reported only: adding the control is a new feature. + +--- + +## What could not be verified + +There is no headless way to check a visual change in this repository. The +harness drives `SerumAltAudioProcessor` and never constructs the editor, so it +proves the code compiles and the audio path still renders every preset, and it +proves nothing about the pixels. The knob label, the toggle hover state, the +disabled FX arrows and the popup clamping were reasoned from the JUCE 7.0.12 +sources in `third_party/JUCE` and checked against them line by line, but they +have not been seen on screen. Do not treat a passing harness run as visual +proof. + +One consequence worth stating plainly: the new knob look-and-feel replaces +`LookAndFeel_V4::drawRotarySlider` for knobs only. Every other control still uses +the shared default look-and-feel, exactly as before. + +--- + +## Verification + +- `BUILD_DIR=/tmp/sa-verify ./build_harness.sh --run` reaches `100% tests passed` + with zero first-party warnings. +- The parameter surface is unchanged, checked at runtime rather than by reading + the source. A temporary build printed every parameter of the live processor as + `id | name | default`. It reported 155 parameters (the 96 declared in + `createParameterLayout` plus the 59 generated entries), and the sorted list had + fingerprint `62b4556078a261a5b1aaa8ba34081f44` both before and after the UI + edits. No ID, name, range or default moved. The only parameter interaction the + fix added is a read of `getDefaultValue()` and `convertFrom0to1()`. +- Nothing under `Source/Params.h`, `Source/Params.cpp` or + `createParameterLayout` in `Source/PluginProcessor.cpp` was edited. +- `third_party/JUCE` is untouched. The untracked `third_party/JUCE/DemoRunner` + and `third_party/JUCE/Projucer` files are upstream binaries dated 2024-04-15, + not build products of this work. +- No checked-in `build_*` directory was written to. Every build used a directory + under `/tmp`. +- The pre-existing uncommitted work in the tree was preserved. diff --git a/build_windows.sh b/build_windows.sh index 1987844..18e7ddf 100755 --- a/build_windows.sh +++ b/build_windows.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # --------------------------------------------------------------------------- -# build_windows.sh — cross-compile SerumAlt VST3 for Windows using MinGW-w64 +# build_windows.sh, cross-compile SerumAlt VST3 for Windows using MinGW-w64 # --------------------------------------------------------------------------- set -euo pipefail @@ -23,7 +23,8 @@ fi for tool in cmake ninja pkg-config "${CC:-cc}" "${CXX:-c++}" \ x86_64-w64-mingw32-gcc x86_64-w64-mingw32-g++ \ x86_64-w64-mingw32-windres x86_64-w64-mingw32-ar \ - x86_64-w64-mingw32-ranlib x86_64-w64-mingw32-strip; do + x86_64-w64-mingw32-ranlib x86_64-w64-mingw32-strip \ + x86_64-w64-mingw32-objdump; do if ! command -v "$tool" >/dev/null 2>&1; then echo "ERROR: $tool not found. See README.md prerequisites." >&2 exit 1 @@ -51,15 +52,51 @@ cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -G Ninja \ echo "Building Windows VST3..." cmake --build "$BUILD_DIR" --config "$BUILD_TYPE" --target SerumAlt_VST3 -# 5. Inject the VST3 moduleinfo.json. +# 5. Verify the linked module can load on a stock Windows host. +# The MinGW C++/threading runtimes are not part of this bundle and do not exist +# on a stock Windows machine, so a module that still imports one of them fails +# to load and the host then omits the plugin from its scan list. The static +# -runtime link options in cmake/Plugin.cmake are meant to prevent that; check +# it here so a stale build tree or a dropped flag fails the build loudly +# instead of shipping a bundle that appears nowhere in the host. +VST3_BUNDLE="$BUILD_DIR/${PLUGIN_NAME}_artefacts/$BUILD_TYPE/VST3/$PLUGIN_NAME.vst3" +VST3_BINARY="$VST3_BUNDLE/Contents/x86_64-win/$PLUGIN_NAME.vst3" +if [[ ! -f "$VST3_BINARY" ]]; then + echo "ERROR: cross-build produced no module at $VST3_BINARY" >&2 + exit 1 +fi +if ! x86_64-w64-mingw32-objdump -f "$VST3_BINARY" | grep -q 'i386:x86-64'; then + echo "ERROR: $VST3_BINARY is not an x86-64 module" >&2 + exit 1 +fi +VST3_DUMP="$(x86_64-w64-mingw32-objdump -p "$VST3_BINARY")" +for runtime in libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll; do + if grep -qF "DLL Name: $runtime" <<<"$VST3_DUMP"; then + echo "ERROR: $VST3_BINARY still imports $runtime." >&2 + echo " A stock Windows host cannot resolve it and will not list the" >&2 + echo " plugin. Restore the static-runtime link options in" >&2 + echo " cmake/Plugin.cmake and rebuild with a fresh BUILD_DIR." >&2 + exit 1 + fi +done +# JUCE implements only these three module entry points on Windows; bundleEntry and +# bundleExit are macOS-only, and the VST3 SDK's Windows loader requires only +# GetPluginFactory, treating InitDll/ExitDll as optional. +for entry in GetPluginFactory InitDll ExitDll; do + if ! grep -qE "[[:space:]]${entry}\$" <<<"$VST3_DUMP"; then + echo "ERROR: $VST3_BINARY does not export $entry" >&2 + exit 1 + fi +done + +# 6. Inject the VST3 moduleinfo.json. # The automatic manifest step is disabled when cross-compiling (see # cmake/Plugin.cmake), because JUCE's manifest helper is a host tool that would # otherwise be built as a Windows executable. The moduleinfo content is -# deterministic for this Windows plugin: it depends on the plugin's +# deterministic for this Windows plugin. It depends on the plugin's # name, manufacturer/plugin codes, and version, so we write the same metadata # that the native Windows helper generates. Keep it in sync with the metadata # in CMakeLists.txt and cmake/Plugin.cmake, including the vendored VST3 SDK version. -VST3_BUNDLE="$BUILD_DIR/${PLUGIN_NAME}_artefacts/$BUILD_TYPE/VST3/$PLUGIN_NAME.vst3" echo "Writing Windows VST3 manifest..." mkdir -p "$VST3_BUNDLE/Contents/Resources" cat > "$VST3_BUNDLE/Contents/Resources/moduleinfo.json" <<'EOF' @@ -122,7 +159,7 @@ cat > "$VST3_BUNDLE/Contents/Resources/moduleinfo.json" <<'EOF' } EOF -# 6. Copy the resulting .vst3 bundle to SerumAlt_Windows/ without deleting files. +# 7. Copy the resulting .vst3 bundle to SerumAlt_Windows/ without deleting files. echo "Staging Windows VST3 in $OUTPUT_DIR..." cmake -E copy_directory "$VST3_BUNDLE" "$OUTPUT_DIR/$PLUGIN_NAME.vst3" diff --git a/opencode.json b/opencode.json index 32d36b7..a4a7e15 100644 --- a/opencode.json +++ b/opencode.json @@ -1,4 +1,5 @@ { + "$schema": "https://opencode.ai/config.json", "mcp": { "graft": { "type": "local",