A follow-up audit of SYNTH/1, my JUCE wavetable synth. The oscillator has been measured, rewritten and measured again. Everything that modulates it had never been measured once. I pointed a meter at five of those paths and all five were wrong, one of them by a factor of thirteen.
In the plugin post I wrote about finding that my oscillator had been aliasing over most of the keyboard for a year, and about the rewrite that fixed it: mip-mapped tables, cubic Hermite interpolation, a double phase accumulator. The number at the end of that story was 96 dB of alias rejection at A4 and 139 dB at C8, measured by compiling the shipping header against a JUCE stub.
That is the audio-rate half of the synth. It runs once per sample and I now have a meter pointed at it.
The other half runs once per processBlock, and I had never pointed anything at it at all. The LFO, the portamento, the unison summing, the phase warp: all of it control-rate code that I wrote by feel, listened to, and shipped. This post is what happened when I finally measured it.
All five are fixed now, and the last section has the after numbers. I am writing them up as they were found, because the interesting part of each one is not the patch, it is why I could not hear it.
The rule I should have written down first
processBlock gets called with whatever buffer size the host feels like. 32 samples in a low-latency live rig, 2048 in a mixing session, and it can change while the plugin is loaded.
So anything you advance once per block is a signal sampled at fs/N, with its own Nyquist at fs/2N:
| buffer size | control rate at 44.1 kHz | control Nyquist |
|---|---|---|
| 128 | 344 Hz | 172 Hz |
| 512 | 86.1 Hz | 43.1 Hz |
| 1024 | 43.1 Hz | 21.5 Hz |
| 2048 | 21.5 Hz | 10.8 Hz |
| 4096 | 10.8 Hz | 5.4 Hz |
That table is two lines of arithmetic and it is the whole post. A modulation source that can be set faster than the control Nyquist does not run fast: it folds, exactly like an oscillator above half the sample rate, for exactly the same reason.
My LFO goes to 20 Hz.
1. The LFO is sampled at the buffer size
Here is the entire LFO, verbatim from PluginProcessor::processBlock:
const float lfoRate = lfoRateParam->load();
lfoPhase += (lfoRate / static_cast<float> (currentSR)) * static_cast<float> (N);
if (lfoPhase >= 1.0f) lfoPhase -= 1.0f;
const float lfoVal = std::sin (lfoPhase * juce::MathConstants<float>::twoPi);
lfoVisBuf.write (lfoVal);
const float cutoffMod = lfoVal * lfoCutoffDepthParam->load() * 4000.0f;
const float pitchMod = lfoVal * lfoPitchDepthParam->load();
One sin() per block. The phase advances by rate * N / fs each time, which is correct bookkeeping: over a second the LFO completes exactly rate cycles of phase. What it does not do is produce rate Hz of modulation, because the sine is only evaluated fs/N times a second.
The condition is the standard one. The modulation is what the knob says while
rate < fs / (2N) => N < fs / (2 * rate)
and folds above it. At rate = 20 Hz and 44.1 kHz that threshold is N = 1102 samples. A 1024-sample buffer is fine. The next power of two is not.
Measured, by replicating those four lines in float32 and taking the FFT of the block-rate sequence:
set | N=32 | N=64 | N=128 | N=256 | N=512 | N=1024 | N=2048 | N=4096
-------------------------------------------------------------------------------------
1.0 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00
5.0 | 5.00 | 5.00 | 5.00 | 5.00 | 5.01 | 5.01 | 5.01 | 5.01
20.0 | 20.00 | 20.00 | 20.00 | 20.00 | 20.03 | 20.03 | 1.50 | 1.50
At a 2048-sample buffer, the 20 Hz setting produces 1.5 Hz. Not a rough 20 Hz, not a steppy 20 Hz. A different, slower, unrelated frequency: 21.5 - 20 = 1.5, the fold, right where the arithmetic says it will be.
Three things make this worse than it first looks.
The rate knob becomes host-dependent. The same project, the same preset, a different buffer size, and the vibrato is a slow wobble. Nothing in the UI hints at it.
Smoothing hides steps, not folds. The cutoff modulation goes through a 20 ms smoother (smoothCutoffMod.reset (sampleRate, 0.02)), so the staircase gets rounded off on its way to the filter and never sounds like zipper noise. It sounds smooth. It is smoothly at the wrong frequency. A lowpass after the sampler cannot undo aliasing any more than a lowpass after your converter can.
The LFO scope is drawing the bug. lfoVisBuf.write (lfoVal) is fed from the same per-block value, so the on-screen scope in the MOD tab shows the folded waveform faithfully. The evidence was on the front panel the whole time and I read it as "the scope redraws at 30 Hz, of course it looks chunky".
One more sharp edge in those four lines: if (lfoPhase >= 1.0f) lfoPhase -= 1.0f; subtracts once. Past N = fs / rate (2205 samples at 20 Hz) the per-block increment exceeds 1.0 and one subtraction no longer brings the phase back into range, so it grows without bound instead of wrapping. The output is still a sine of a folded frequency, so it does not announce itself, but in float32 an unbounded accumulator quietly coarsens over a long session.
The fix is not subtle: advance the LFO on a fixed sub-block so its rate stops being a function of the host. juce::Synthesiser::renderNextBlock takes MIDI positions as absolute offsets into the buffer, so the same MidiBuffer can be handed to each chunk with only startSample moving, and the voices never notice:
for (int offset = 0; offset < numSamples; offset += kControlBlockSize)
{
const int chunk = juce::jmin (kControlBlockSize, numSamples - offset);
lfoPhase += phaseStep * chunk;
lfoPhase -= std::floor (lfoPhase); // not a single conditional subtract
const float lfoVal = std::sin (static_cast<float> (lfoPhase) * twoPi);
for (int i = 0; i < synth.getNumVoices(); ++i)
if (auto* v = dynamic_cast<SynthVoice*> (synth.getVoice (i)))
v->setLFOMod (lfoVal * cutoffDepth * 4000.0f, lfoVal * pitchDepth);
synth.renderNextBlock (buffer, midi, offset, chunk);
}
kControlBlockSize = 32 puts the control rate at 1378 Hz and the modulation ceiling at 689 Hz, two decades above anything the rate knob can ask for, at any buffer size. The green trace in the figure above is that version: 20.00 Hz whatever the host does. std::floor replaces the conditional subtract for the same reason: it wraps a phase no matter how large the increment gets.
2. Portamento is quantised to the host buffer
Same failure, different knob. From SynthVoice::renderNextBlock:
// Block-rate glide + pitch LFO
const float glideHz = smoothedFreqHz.skip (numSamples);
const float pMod = smoothPitchMod.skip (numSamples);
const float modBase = glideHz * std::pow (2.0f, pMod / 12.0f);
smoothedFreqHz is a juce::SmoothedValue<float, ValueSmoothingTypes::Multiplicative>, which is the right choice: pitch is logarithmic, so a glide should be a constant ratio per sample and not a constant number of hertz. skip(n) advances the smoother n steps and returns where it landed, so the ramp rate is exactly right.
It is read once per block. The oscillator frequency is therefore piecewise constant over each buffer, and a portamento is a staircase whose step height is set by the host:
cents per step = 1200 * N / (glideTime * fs)
For a one-octave glide in 100 ms:
| buffer size | pitch updates | cents per step |
|---|---|---|
| 64 | 68.9 | 17.4 |
| 128 | 34.5 | 34.8 |
| 256 | 17.2 | 69.7 |
| 512 | 8.6 | 139.3 |
| 1024 | 4.3 | 278.6 |
At 512 samples a fast glide moves in 139-cent steps, larger than a semitone. At 1024 it is 279 cents and four steps: that is not a portamento, that is an arpeggio. And again the knob behaves differently depending on a setting in a different application.
Unlike the LFO this one has a real cost attached, which is why I suspect past-me did it on purpose and then forgot. Per-sample pitch means calling setFrequency per sample on up to eight unison oscillators per voice, and setFrequency does a log2 for the mip index. The honest middle is a sub-block, which is the same sub-block the LFO now needs: because renderNextBlock is called per 32-sample chunk, skip(numSamples) is handed 32 instead of the host's buffer, and the fix arrives for free. 8.7 cents per step, at every buffer size. That is the green trace above, and 1/32 of the cost of doing it per sample.
3. Cutoff is smooth, pitch is not, and the expensive one is the one I did not need
The two modulation destinations inside the voice are handled differently, three lines apart. Pitch, above, is skip(numSamples): one value per block. Cutoff is inside the per-sample loop:
for (int i = 0; i < numSamples; ++i)
{
const float cMod = smoothCutoffMod.getNextValue();
const float env = adsr.getNextSample();
svFilter.setCutoffFrequency (juce::jlimit (20.0f, 20000.0f, cutoff + cMod));
...
}
getNextValue() per sample. Smooth, correct, and the most expensive line in the voice, because of what JUCE does behind that setter:
void StateVariableTPTFilter<SampleType>::update()
{
g = static_cast<SampleType> (std::tan (juce::MathConstants<double>::pi * cutoffFrequency / sampleRate));
R2 = static_cast<SampleType> (1.0 / resonance);
h = static_cast<SampleType> (1.0 / (1.0 + R2 * g + g * g));
}
A std::tan and two divisions, per sample, per voice. Sixteen voices at 44.1 kHz is 705,600 tangents a second, and the loop calls it unconditionally: with the LFO cutoff depth at zero, cMod is a constant and every one of those recomputes the same three coefficients.
So the voice pays per-sample cost for the modulation path that also happens to be easy to make cheap, and takes the block-rate shortcut on the path where per-sample actually costs something. I would defend block-rate pitch to a reviewer. I cannot defend recomputing a filter coefficient 705,600 times a second to apply a modulation of zero.
The fix is a cached comparison, and it costs nothing when the modulation is moving:
const float target = juce::jlimit (20.0f, maxCutoffHz, cutoff + cMod);
if (std::abs (target - lastCutoffHz) > lastCutoffHz * 1.0e-4f)
{
svFilter.setCutoffFrequency (target);
lastCutoffHz = target;
}
The threshold is relative, so it is 0.0002 of a semitone at any cutoff rather than a fixed number of hertz that would be inaudible at 8 kHz and a staircase at 40 Hz. With the LFO depth at zero the smoother converges and the tangents stop entirely.
maxCutoffHz is the other half of that line, and a bug of its own: see the small print below.
4. Two of the four warp modes are one warp mode
The oscillator's applyWarp offers None, Sync, Bend and PWM. Here are the last two, verbatim and adjacent in the source:
case 2: // Bend - non-linear phase redistribution
{
const double k = 0.05 + warpAmount * 0.9;
return (phase < k) ? phase / (2.0 * k)
: 0.5 + (phase - k) / (2.0 * (1.0 - k));
}
case 3: // PWM
{
const double pw = 0.05 + warpAmount * 0.9;
return (phase < pw) ? phase / (2.0 * pw)
: 0.5 + (phase - pw) / (2.0 * (1.0 - pw));
}
Same constant, same breakpoint, same two branches, same arithmetic. Rename pw to k and the two cases are character for character identical, which is a fact about the source and needs no measurement to settle. Rendering both modes through the oscillator settles it anyway: the two buffers came out bit-identical, maximum sample difference 0.0. (After the rewrite below they differ by up to 1.63, which is the version of that check that passes.)
The dropdown offers four modes and the DSP implements three. Nobody reported it, including me, because the one thing a phase-distortion mode reliably does is sound different from the mode before it, and Bend already does.
The mode that was missing is the interesting one. Pulse-width modulation on a wavetable is not a phase remap at all and cannot be written as one, which is exactly how it ended up as a renamed copy of its neighbour. It is the difference of two reads of the same table a duty cycle apart:
const bool isPWM = (warpMode == 3);
const double pulseW = 0.05 + warpAmount * 0.9;
if (isPWM)
{
double p2 = currentPhase + pulseW;
p2 -= std::floor (p2);
s = 0.5f * (readAt (currentPhase) - readAt (p2));
}
else
{
s = readAt (applyWarp (currentPhase));
}
That needed the single table read to be pulled out into a readAt() lambda (morph blend, mip blend and all), which the non-PWM path now calls once and PWM calls twice. The 0.5 keeps it inside unity: two unit sawtooths a distance w apart differ by at most 2 - 2w, which reaches 1.9 at the narrow end of the range.
Then the part I did not expect. x(p) - x(p + w) is a sum of the harmonics already in the table, with modified amplitudes and phases and nothing new above them. Sync and Bend re-create the slope discontinuities the band-limited tables exist to avoid, so they alias badly. The two-read PWM cannot. Rendered through the shipping oscillator at Amount = 0.75 and measured the same way as the oscillator sweep:
alias-to-signal ratio (dB, lower is better)
f0 (Hz) | none | sync | bend | pwm
----------------------------------------------------
109.7 | -91.3 | -18.4 | -34.9 | -92.0
440.1 | -121.6 | -12.4 | -28.1 | -122.5
1320.3 | -142.3 | -6.2 | -22.9 | -142.3
PWM matches the unwarped oscillator to a fraction of a dB. It is the only warp mode in the plugin that does not throw away the mip-mapping the oscillator rewrite paid for.
One footnote from setting up that measurement, which I ran at Amount = 0.75 rather than the obvious 0.5. Bend's breakpoint is k = 0.05 + amount * 0.9, so at Amount = 0.5 you get k = 0.5, both branches collapse to phase, and the map is the identity. The Bend knob does nothing at its own midpoint. I found that by measuring Bend and getting the unwarped column back, digit for digit.
5. Unison at zero detune is a +9 dB knob
The unison summing, from the same render loop:
const float normGain = 1.0f / std::sqrt (static_cast<float> (numU));
1/sqrt(N) is the right normalisation for incoherent sources. Sum N uncorrelated signals and the power adds, so the amplitude goes as sqrt(N) and dividing it out keeps the level constant. That is what a detuned unison stack becomes after a few tens of milliseconds.
It is not what it is at note-on, because startNote does this to all eight oscillators:
for (int u = 0; u < kMaxUnisonVoices; ++u)
{
unisonOscs[u].setFrequency (baseFreqHz);
...
unisonOscs[u].reset(); // phase = 0
}
Every unison oscillator starts at phase zero, so for the first cycles they are one signal copied N times. Coherent sum, amplitude N, scaled by 1/sqrt(N):
| unison voices | coherent gain | dB |
|---|---|---|
| 2 | 1.414 | +3.01 |
| 4 | 2.000 | +6.02 |
| 8 | 2.828 | +9.03 |
With detune above zero they drift apart and the level settles where the normalisation intends. With detune at exactly zero they never drift, so the voice count stops being a texture control and becomes a +9 dB gain knob that also triples your chance of clipping the FX chain downstream.
The transient version of this is the more musical bug: at any detune setting, the attack of every note is up to 9 dB hotter than its body. I have been hearing that as "the unison has a nice punch to it".
The fix is one line, and the obvious version of it is wrong. Staggering the phases evenly, u / numU, looks like the tidy deterministic choice and is the worst of the three: summing N copies of one waveform at phases k/N cancels every harmonic that is not a multiple of N, so an 8-voice unison at zero detune would come out three octaves up, on 8*f0, with seven eighths of its spectrum deleted. Even spacing is a comb filter wearing a normalisation's clothes.
Random phases are the fix, because random phases are the assumption 1/sqrt(N) was already making:
// Oscillator 0 keeps phase 0 so unison = 1 is bit-identical to before.
unisonOscs[u].resetPhase (u == 0 ? 0.0 : rng.nextDouble());
The generator is seeded from the voice's construction index, so a fresh instance still renders a given note sequence identically twice, which is what you want for an offline bounce. Measured on a 50-harmonic sawtooth over 500 phase draws, as level relative to a single oscillator:
| unison voices | phase 0 (before) | random phase, RMS | random, peak |
|---|---|---|---|
| 2 | +3.01 | -0.32 | -0.08 |
| 4 | +6.02 | -0.27 | +0.65 |
| 8 | +9.03 | -0.31 | +0.68 |
The +9 dB is gone from both the steady state and the attack. What is left is a few tenths of a dB of draw-to-draw variation, which is the phasing that makes an analog unison sound like one.
The small print
Three more things I measured or re-read, kept here because a list of only the dramatic findings is a dishonest list.
The saturator is used outside its documented range. The per-voice drive stage is juce::dsp::FastMathApproximations::tanh, whose header says "You are advised to use input values only between -5 and +5". The drive parameter goes to 10, so the argument does too. Measured against std::tanh:
| x | fast tanh | true tanh | dB over unity |
|---|---|---|---|
| 5.0 | 1.00001 | 0.99991 | 0.000 |
| 10.0 | 1.00917 | 1.00000 | +0.079 |
| 20.0 | 1.13348 | 1.00000 | +1.088 |
The Padé approximant stays monotonic and does not blow up; it just stops saturating and creeps above unity, first crossing 1.0 at x = 4.972. At the maximum drive the "soft clipper" overshoots its ceiling by 0.08 dB. That is inaudible and it is still wrong, because the one property I wanted from that function is a hard ceiling at 1.
This is the one I left alone, on purpose. Swapping in std::tanh fixes 0.08 dB and changes the saturation curve of every preset anyone has saved; clamping the argument at ±5 turns a soft knee into a hard corner at high drive. Neither is worth it for 0.08 dB. Measured, judged, documented, unchanged: that is a legitimate outcome for a finding and I would rather write it down than quietly "fix" the sound of the plugin.
The cutoff clamp assumed 44.1 kHz. juce::jlimit (20.0f, 20000.0f, ...) is a fixed ceiling, but the TPT filter needs cutoff < fs/2. Below a 40 kHz sample rate the clamp let through a cutoff above Nyquist, std::tan(pi * fc / fs) went negative, and JUCE's own jassert fires in a debug build. It never bit me because I have never run the plugin at 32 kHz. The ceiling is now jmin (20000.0, 0.49 * sampleRate), computed in prepareToPlay.
The oscillator takes a double and the voice hands it a float. The header argues, at length and correctly, that setFrequency should take a double because a float32 Hz value leaves the tone slightly non-periodic and shows up as leakage skirts at -86 dBc. Then the caller computes:
const float freq = juce::jlimit (20.0f, 20000.0f,
modBase * std::pow (2.0f, detuneSt / 12.0f));
unisonOscs[u].setFrequency (freq);
The precision was thrown away one line above the call that had been widened to preserve it. The measurement harness passes an exact double, so the rig I built to keep me honest was the one place in the codebase where the argument held. The whole glide and detune path is in double now: a double and an std::pow in double, free, and it makes the header comment true.
And a documentation one, since docs ship too: the repo README still advertised "Four band-limited wavetables (64 harmonics each)", which describes the oscillator I deleted a year ago. CLAUDE.md described the current one. Two files, two different synths, and the one users read was the wrong one. Also fixed, and it is the change I am least proud of needing.
The five, before and after
| before | after | |
|---|---|---|
| LFO at 20 Hz, N = 2048 | 1.50 Hz | 20.00 Hz, at every buffer size |
| Octave portamento in 100 ms | 139 cents/step at N = 512, 279 at N = 1024 | 8.7 cents/step, at every buffer size |
std::tan per second, 16 voices, LFO depth 0 |
705,600 | 0 |
| PWM | bit-identical to Bend | a real pulse, and the only warp mode that does not alias (-92.0 dB against -91.3 unwarped) |
| Unison 8 at zero detune | +9.03 dB | -0.31 dB RMS, +0.68 dB peak |
Everything still builds clean in Release x64, and the oscillator sweep from the previous post comes back digit for digit (-121.6 dB at A4, -148.2 at C8), which is the regression test that mattered: the control-rate work moved nothing in the audio-rate path.
Reproducing this
python analysis/measure_modulation.py
The warp table needs the C++ harness first, because it renders through the real oscillator:
cl /std:c++17 /O2 /EHsc /I analysis\juce_stub /I Source analysis\measure_warp.cpp /Fe:analysis\measure_warp.exe
analysis\measure_warp.exe analysis/data
One honest caveat about method, because it differs from the oscillator work. plot_oscillator.py and measure_warp.cpp measure the shipping header, compiled as-is against a minimal JUCE stub: those numbers come from the code that runs in the product. The LFO, glide and unison paths live in SynthVoice.h and PluginProcessor.cpp, tangled up with APVTS and juce::Synthesiser, so measure_modulation.py replicates them rather than compiling them, four to six lines at a time, quoted beside the original above.
That is a weaker claim and worth stating plainly. The warp findings do not depend on it at all, being rendered audio. Nor does the identity of the two modes, which is a textual fact about the source, or the sqrt(N) unison gain, which is arithmetic on one line of code. The LFO fold and the glide staircase are arithmetic too (N = fs/2f and 1200N/(t*fs)); the replication only draws them.
What I would take from this
-
Write down the control rate the same way you write down Nyquist.
fs/2Nis the ceiling on every modulation source in the plugin, and N belongs to the host, not to you. - A parameter range that the implementation cannot honour is a bug in the range. A 20 Hz LFO in a block-rate modulator is a knob that lies above about a third of its travel.
- Smoothing is not sampling. A 20 ms smoother turns a staircase into a ramp and leaves an alias exactly where it was. It made this bug harder to hear, not smaller.
-
Normalisation laws have assumptions in their names.
1/sqrt(N)is the incoherent law. If you reset every source to phase zero, you have just guaranteed the coherent case for the first cycles, and forever at zero detune. -
Two adjacent switch cases are worth diffing. Not reading, diffing. I have read that function a dozen times and my eyes matched
kagainstpwand moved on. - Measure the parts you feel confident about. I measured the oscillator because I already suspected it. The LFO I never suspected, and it is the worst one on the list.
- The front panel was showing it. The LFO scope had been drawing the folded waveform since the day I wrote it.
- The tidy fix can be the worst one. Evenly spaced unison phases look more principled than random ones and delete most of the spectrum.
- Leaving something unfixed is a result too. The saturator overshoots by 0.08 dB and I am not touching it, because the alternative is silently changing how every saved preset sounds.
SYNTH/1 is MIT licensed and on GitHub, analysis/ folder included: measure_oscillator.cpp and plot_oscillator.py for the audio-rate story, measure_warp.cpp and measure_modulation.py for this one.
I'm an audio DSP student at UPC. I build VST plugins and instruments you play with your hands.





Top comments (0)