From aa15a0e2e820bb01e68e5def8d170f8be9b161a3 Mon Sep 17 00:00:00 2001 From: joak0068 <1211331+joak0068@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:10:00 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20kill=20synth=20crackle=20=E2=80=94?= =?UTF-8?q?=20control-rate=20filter=20coefs=20+=20bigger=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intermittent crackle in Synth mode came from per-block CPU spikes, not sustained load (the watchdog only sheds after ~150ms, so transient peaks slipped through). Two changes attack the peak: - voice.h: recompute the filter SetFreq (Svf sinf+powf / Moog polynomial) at control rate (every kCoefInterval=8 samples, ~6 kHz) instead of every sample. A moving filter envelope changes fc every sample, which used to force a per-sample SetFreq on every voice; a chord put all 6 voices on that path at once and spiked a block past the deadline. Unchanged-coef skip is kept (static patches stay free); a filter-type switch still forces an immediate recompute. Inaudible for sweeps. - params.h: audio block size 48 -> 64 (~1.3 ms @ 48 kHz) for more headroom against transient spikes and to amortize per-block control work. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/params.h | 2 +- src/dsp/voice.h | 38 +++++++++++++++++++++++++------------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/config/params.h b/src/config/params.h index 9452b9f..324f756 100644 --- a/src/config/params.h +++ b/src/config/params.h @@ -37,7 +37,7 @@ constexpr char kFwVersion[] = "0.4.0"; // Audio engine // ---------------------------------------------------------------------------- namespace audio { -constexpr int kBlockSize = 48; // samples/channel per callback +constexpr int kBlockSize = 64; // samples/channel per callback (~1.3 ms @ 48 kHz) // Sample rate is set via SaiHandle config in main.cpp (48 kHz). } // namespace audio diff --git a/src/dsp/voice.h b/src/dsp/voice.h index 2054546..5e8906e 100644 --- a/src/dsp/voice.h +++ b/src/dsp/voice.h @@ -223,25 +223,35 @@ class Voice { // Pre-filter saturation -> grit (and dirties the filter for fat/acid tones). float drv = Saturate(sig, drive) * 0.6f; // Filter coefficients only depend on (fc, res, filter type). SetFreq is costly - // (Svf: sinf+powf; Moog: a polynomial), so skip it on samples where none changed - // -- a big saving for static-filter patches (cutoff held, no filter envelope). + // (Svf: sinf+powf; Moog: a polynomial). Two savings stack: + // 1. skip it when nothing changed -- free for static-filter patches. + // 2. update at CONTROL RATE (every kCoefInterval samples), not per sample -- + // a moving filter envelope changes fc every sample, which used to force a + // per-sample SetFreq on every voice; on a chord that spiked one block past + // the deadline and crackled. ~6 kHz coef updates are inaudible for sweeps. + // A filter-TYPE switch forces an immediate recompute so the new filter isn't stale. const int fltSel = (filterType < 0.5f) ? 0 : 1; - const bool coefDirty = (fc != lastFc_) || (res != lastRes_) || (fltSel != lastFltSel_); - lastFc_ = fc; - lastRes_ = res; + const bool typeChanged = (fltSel != lastFltSel_); lastFltSel_ = fltSel; - if (fltSel == 0) { // clean 2-pole Svf - if (coefDirty) { - flt_.SetFreq(fc); - flt_.SetRes(res * 0.85f); + if (typeChanged) coefCountdown_ = 0; + if (--coefCountdown_ <= 0) { + coefCountdown_ = kCoefInterval; + if (fc != lastFc_ || res != lastRes_ || typeChanged) { + lastFc_ = fc; + lastRes_ = res; + if (fltSel == 0) { + flt_.SetFreq(fc); + flt_.SetRes(res * 0.85f); + } else { + mflt_.SetFreq(fc); + mflt_.SetRes(res * 0.95f); // fat 4-pole MoogLadder + } } + } + if (fltSel == 0) { // clean 2-pole Svf flt_.Process(drv); return flt_.Low() * env * vel_; } - if (coefDirty) { - mflt_.SetFreq(fc); - mflt_.SetRes(res * 0.95f); // fat 4-pole MoogLadder - } return mflt_.Process(drv) * env * vel_; } @@ -269,8 +279,10 @@ class Voice { float wtPhase_ = 0.f, fmPhase_ = 0.f; // wavetable carrier + FM modulator phases bool gate_ = false; // Cached per-block work (sentinels force a recompute on the first sample): + static constexpr int kCoefInterval = 8; // recompute filter coefs every N samples float lastFc_ = -1.f, lastRes_ = -1.f; // filter coefficients (see Process) int lastFltSel_ = -1; // 0 = Svf, 1 = Moog + int coefCountdown_ = 0; // samples until the next coef recompute float uniMul_[kUni] = {1.f, 1.f, 1.f, 1.f}; // unison detune frequency multipliers float uniGain_ = 1.f; // 1 / unison count int lastU_ = -1; // unison count the multipliers were built for From c2923f4ab9136d404e9cdb9e75e333264603d303 Mon Sep 17 00:00:00 2001 From: joak0068 <1211331+joak0068@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:10:00 +0200 Subject: [PATCH 2/4] docs: document the worst-case CPU stress test in CONTRIBUTING Describes the heaviest engine configuration (Synth + 6 voices/4 unison/ analog/Moog + reverb + master filter, MIDI-flooded) and how to read the SysEx CPU meter and watchdog when changing DSP or voice counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26b3b76..08079ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,32 @@ shared between them lives in globals in `main.cpp`: If you introduce new shared state, document which context owns it and prefer a single writer. +## Stress-testing CPU load + +The audio callback has a hard deadline (one block, `kBlockSize`/48 kHz). A `CpuLoadMeter` +(`g_cpu`) tracks average/peak callback load and reports it over SysEx (cmd `0x02`); a +watchdog (`params::watchdog`) sheds the global FX and halves Synth polyphony after sustained +overload. When you touch the DSP or the voice count, verify the **worst case** still has +headroom — and watch the **peak**, not just the average, since a single over-deadline block +crackles even when the average looks fine. + +The heaviest configuration the engine can produce: + +- **Mode = Synth**, **FX = Reverb** (`ReverbSc` is the costly one), **master filter on** with + high resonance. +- Synth params (CC 40+): **voices = max (6)**, **unison = max (4)**, **engine = analog** + (4 PolyBLEP osc + sub per voice), **filter = Moog** (4-pole), **drive up**. +- **LFO→cutoff** depth up and **chaos speed** (CC 18) maxed so modulation churns every block. +- **MIDI-flood**: hold all 6 voices *and* retrigger fast with a short attack/decay, so every + voice's filter envelope stays in motion — that is what exercises the filter-coefficient + path on all voices at once (see the control-rate `SetFreq` in `dsp/voice.h`). + +This pins 6 voices × 5 oscillators + 6 Moog filters + `ReverbSc` + master filter + limiter +simultaneously. A "pass" is: no audible crackle in the ~150 ms before the watchdog trips, and +the watchdog trips and then recovers cleanly (LED returns to heartbeat, full polyphony) once +the flood stops. Granular at 12 grains / max density + reverb is a lighter, separate path +worth a second check. + ## Build scripts Each script exists as a `.sh`/`.ps1` pair (`scripts/build.{sh,ps1}`, etc.). They are thin From 4d7583aa7af0463a1d6648b46180f070abc7d59c Mon Sep 17 00:00:00 2001 From: joak0068 <1211331+joak0068@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:36:34 +0200 Subject: [PATCH 3/4] docs: changelog entry for the synth crackle fix Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e1c7b..a6b05eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this pr uses [Semantic Versioning](https://semver.org/) (`vMAJOR.MINOR.PATCH`). ## [Unreleased] +- **Fix: intermittent crackle in Synth mode under load.** The crackle was a per-block CPU + spike, not sustained load (so the watchdog, which only sheds after ~150 ms, never caught + it): a moving filter envelope changed the cutoff every sample, forcing a per-sample + `SetFreq` (Svf `sinf`+`powf` / Moog polynomial) on every voice — and a chord put all 6 + voices on that path at once, tipping a block past its deadline. The voice filter now + recomputes its coefficients at **control rate** (every 8 samples, ~6 kHz — inaudible for + sweeps) while keeping the existing "skip when unchanged" fast path for static patches. + The audio **block size also goes 48 → 64** (~1.3 ms @ 48 kHz) for more headroom against + transient spikes. ## [v0.4.0] - 2026-06-25 - **Presets** (`io/presets.h`): three per mode, stored in QSPI. Hold Footswitch 2 to enter From 6faed52ff3e27ac9d70624e72ad3802604e77729 Mon Sep 17 00:00:00 2001 From: joak0068 <1211331+joak0068@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:37:51 +0200 Subject: [PATCH 4/4] style: clang-format voice.h Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dsp/voice.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dsp/voice.h b/src/dsp/voice.h index 5e8906e..4430aba 100644 --- a/src/dsp/voice.h +++ b/src/dsp/voice.h @@ -282,7 +282,7 @@ class Voice { static constexpr int kCoefInterval = 8; // recompute filter coefs every N samples float lastFc_ = -1.f, lastRes_ = -1.f; // filter coefficients (see Process) int lastFltSel_ = -1; // 0 = Svf, 1 = Moog - int coefCountdown_ = 0; // samples until the next coef recompute + int coefCountdown_ = 0; // samples until the next coef recompute float uniMul_[kUni] = {1.f, 1.f, 1.f, 1.f}; // unison detune frequency multipliers float uniGain_ = 1.f; // 1 / unison count int lastU_ = -1; // unison count the multipliers were built for