Skip to content

Commit 6e6cd05

Browse files
Joakim Langkildeclaude
andcommitted
perf: cut audio-CPU on the voice path + LTO flash savings
No audible change; verified to build clean (FLASH 98.2% -> 97.1%). - Voice filter coefficients (Svf sinf+powf / Moog polynomial) are recomputed only when cutoff/resonance/filter-type change, not every sample -- a real saving for static-filter patches (cutoff held, no filter envelope). - Unison detune frequency multipliers + 1/u gain are precomputed per block instead of a per-sample division per oscillator. - The final hard clamp to [-1,1] is folded into the master limiter, dropping a whole extra per-block buffer pass in main.cpp. - master.h: the param-smoothing coefficient uses params::audio::kBlockSize instead of a hardcoded 48, so block size is a clean one-line lever. - Enable -flto (compile + link); keep usb_identity.c out of LTO so its descriptor override stays deterministic. Nets ~1.5 KB of internal flash back. Note: the original "hoist SetFreq out of the loop" idea was dropped -- DaisySP's SetFreq is the costly call, not the oscillator's, so the coefficient guard above is where the saving actually is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f00d8d3 commit 6e6cd05

5 files changed

Lines changed: 64 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this pr
55
uses [Semantic Versioning](https://semver.org/) (`vMAJOR.MINOR.PATCH`).
66

77
## [Unreleased]
8+
- **Lower audio-CPU on the voice path** (no audible change): the per-voice filter only
9+
recomputes its coefficients when cutoff / resonance / filter-type actually change, so
10+
static-filter patches skip a `sinf`+`powf` (Svf) or coefficient polynomial (Moog) every
11+
sample; unison detune offsets are precomputed per block instead of dividing per sample;
12+
and the final hard clamp is folded into the master limiter (one less buffer pass).
13+
- **Link-time optimization** (`-flto`) trims ~1.5 KB of internal flash (98.2% → 97.1%);
14+
`usb_identity.c` is kept out of LTO so its USB-descriptor override stays deterministic.
815
- **Contributor tooling.** Added `CONTRIBUTING.md`, GitHub issue/PR templates, a
916
`.clang-format` (Google C++, 4-space, 100-col) + `.editorconfig`, and a CI job that
1017
enforces formatting. New `scripts/setup.{sh,ps1}` one-shot bootstrap (submodules + libs).

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,15 @@ include $(SYSTEM_FILES_DIR)/Makefile
3737
# rely on isnan/isfinite. Appended to CFLAGS (CPPFLAGS = $(CFLAGS), so C++ inherits
3838
# it); deliberately NOT on ASFLAGS. Denormals are already flushed via FPSCR.FZ.
3939
CFLAGS += -ffast-math -fno-finite-math-only
40+
41+
# Link-time optimization. The app is effectively one big translation unit
42+
# (main.cpp pulls in all the header-only DSP), so the win is modest, but it trims
43+
# ~1.5 KB of the scarce 128 KB internal flash and lets cross-object inlining reach
44+
# hothouse.cpp. Needs the flag at compile and link.
45+
CFLAGS += -flto
46+
LDFLAGS += -flto
47+
48+
# Keep the USB descriptor override out of LTO: usb_identity.c wins over libDaisy's
49+
# archive copy purely by link order, so compile it as a plain object to keep that
50+
# symbol resolution deterministic (-fno-lto wins as the last flag).
51+
$(BUILD_DIR)/usb_identity.o: CFLAGS += -fno-lto

src/dsp/voice.h

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,22 @@ class Voice {
182182
u = 1;
183183
else if (u > kUni)
184184
u = kUni;
185+
// Unison detune offsets + 1/u gain depend only on (u, detune) -- both
186+
// per-block -- so recompute them once when they change, not per sample.
187+
if (u != lastU_ || detune != lastDetune_) {
188+
lastU_ = u;
189+
lastDetune_ = detune;
190+
for (int i = 0; i < u; ++i)
191+
uniMul_[i] =
192+
1.0f + ((u > 1) ? ((float)i / (u - 1) - 0.5f) * 2.0f * detune : 0.0f);
193+
uniGain_ = 1.0f / (float)u;
194+
}
185195
sig = 0.0f;
186196
for (int i = 0; i < u; ++i) {
187-
float off = (u > 1) ? ((float)i / (u - 1) - 0.5f) * 2.0f * detune : 0.0f;
188-
osc_[i].SetFreq(f * (1.0f + off));
197+
osc_[i].SetFreq(f * uniMul_[i]);
189198
sig += osc_[i].Process();
190199
}
191-
sig *= 1.0f / (float)u;
200+
sig *= uniGain_;
192201
sig += sub_.Process() * subLvl;
193202
} else { // ---- wavetable: scan + FM + fold ----
194203
float pinc = f / sr_;
@@ -211,14 +220,26 @@ class Voice {
211220
}
212221
// Pre-filter saturation -> grit (and dirties the filter for fat/acid tones).
213222
float drv = Saturate(sig, drive) * 0.6f;
214-
if (filterType < 0.5f) { // clean 2-pole Svf
215-
flt_.SetFreq(fc);
216-
flt_.SetRes(res * 0.85f);
223+
// Filter coefficients only depend on (fc, res, filter type). SetFreq is costly
224+
// (Svf: sinf+powf; Moog: a polynomial), so skip it on samples where none changed
225+
// -- a big saving for static-filter patches (cutoff held, no filter envelope).
226+
const int fltSel = (filterType < 0.5f) ? 0 : 1;
227+
const bool coefDirty = (fc != lastFc_) || (res != lastRes_) || (fltSel != lastFltSel_);
228+
lastFc_ = fc;
229+
lastRes_ = res;
230+
lastFltSel_ = fltSel;
231+
if (fltSel == 0) { // clean 2-pole Svf
232+
if (coefDirty) {
233+
flt_.SetFreq(fc);
234+
flt_.SetRes(res * 0.85f);
235+
}
217236
flt_.Process(drv);
218237
return flt_.Low() * env * vel_;
219238
}
220-
mflt_.SetFreq(fc);
221-
mflt_.SetRes(res * 0.95f); // fat 4-pole MoogLadder
239+
if (coefDirty) {
240+
mflt_.SetFreq(fc);
241+
mflt_.SetRes(res * 0.95f); // fat 4-pole MoogLadder
242+
}
222243
return mflt_.Process(drv) * env * vel_;
223244
}
224245

@@ -245,6 +266,13 @@ class Voice {
245266
float sr_ = 48000.f;
246267
float wtPhase_ = 0.f, fmPhase_ = 0.f; // wavetable carrier + FM modulator phases
247268
bool gate_ = false;
269+
// Cached per-block work (sentinels force a recompute on the first sample):
270+
float lastFc_ = -1.f, lastRes_ = -1.f; // filter coefficients (see Process)
271+
int lastFltSel_ = -1; // 0 = Svf, 1 = Moog
272+
float uniMul_[kUni] = {1.f, 1.f, 1.f, 1.f}; // unison detune frequency multipliers
273+
float uniGain_ = 1.f; // 1 / unison count
274+
int lastU_ = -1; // unison count the multipliers were built for
275+
float lastDetune_ = -1.f;
248276
};
249277

250278
} // namespace synthbox

src/fx/master.h

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class MasterChain {
4444
sm_vol_ = vol_;
4545
sm_cut_ = cut01_;
4646
sm_res_ = res01_;
47-
par_ = 1.0f - expf(-48.0f / (0.015f * sr_)); // assumes 48-sample blocks
47+
par_ = 1.0f - expf(-static_cast<float>(params::audio::kBlockSize) / (0.015f * sr_));
4848
// DC blocker: one-pole high-pass ~20 Hz to strip any offset built up by
4949
// resonant filters / wavefolding / drive before it eats output headroom.
5050
dc_r_ = 1.0f - (2.0f * 3.14159265f * 20.0f / sr_);
@@ -106,13 +106,15 @@ class MasterChain {
106106
private:
107107
// Peak limiter: scale both channels by a shared gain that drops fast when the
108108
// louder channel would exceed the ceiling and recovers slowly. Keeps the mix
109-
// under the ceiling without the harshness of per-sample hard clipping.
109+
// under the ceiling without the harshness of per-sample hard clipping. The final
110+
// clamp to [-1, 1] is the backstop for the sub-millisecond attack slip, so the
111+
// output can never leave range whatever the limiter gain is mid-transient.
110112
inline void Limit(float& l, float& r) {
111113
const float peak = fmaxf(fabsf(l), fabsf(r));
112114
const float desired = peak > kCeiling ? kCeiling / peak : 1.0f;
113115
gain_ += (desired - gain_) * (desired < gain_ ? atk_ : rel_);
114-
l *= gain_;
115-
r *= gain_;
116+
l = daisysp::fclamp(l * gain_, -1.0f, 1.0f);
117+
r = daisysp::fclamp(r * gain_, -1.0f, 1.0f);
116118
}
117119

118120
// One-pole DC-blocking high-pass per channel: y = x - x1 + R*y1.

src/main.cpp

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,9 @@ void AudioCallback(AudioHandle::InputBuffer in, AudioHandle::OutputBuffer out, s
164164
// Global FX runs for Synth/Granular only: Generative has its own reverb (running both
165165
// = double ReverbSc = overload), and the CPU watchdog sheds it under sustained load.
166166
if (g_active != MODE_GENERATIVE && !g_overload) g_fx.Process(out[0], out[1], size);
167-
g_master.Process(out[0], out[1], size); // master filter (LP/BP/HP) + volume + limiter
168-
// Final backstop behind the master limiter: catch the sub-millisecond attack
169-
// slip so the output can never leave [-1, 1], whatever the limiter is doing.
170-
for (size_t i = 0; i < size; ++i) {
171-
out[0][i] = out[0][i] > 1.0f ? 1.0f : (out[0][i] < -1.0f ? -1.0f : out[0][i]);
172-
out[1][i] = out[1][i] > 1.0f ? 1.0f : (out[1][i] < -1.0f ? -1.0f : out[1][i]);
173-
}
167+
// Master filter (LP/BP/HP) + volume + peak limiter, with a final hard clamp to
168+
// [-1, 1] folded into the limiter so the output can never leave range.
169+
g_master.Process(out[0], out[1], size);
174170
}
175171
g_cpu.OnBlockEnd();
176172

0 commit comments

Comments
 (0)