Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/config/params.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 25 additions & 13 deletions src/dsp/voice.h
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
}

Expand Down Expand Up @@ -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
Expand Down
Loading