Oscillator suite¶
pulp::signal::osc (core/signal/include/pulp/signal/osc/) is a family of
eight headers that compose into four oscillator "flavors" — VA, VCO, DCO, and
WT (in two tiers) — over a phase accumulator and two discontinuity-correction
options: polynomial BLEP/BLAMP kernels and a fixed-capacity minBLEP accumulator.
This is a different, newer class
family than signal::Oscillator (oscillator.hpp, documented in
Signal Processing §3) — that one is a
simpler polyBLEP oscillator with float phase and an integrated triangle;
extend the suite on this page for new work, and see oscillator.hpp's own
header comment for exactly how the two differ.
Each family is a standalone class — pick the one whose character matches
what you're building, not a shared base class. All of them take a per-sample
phase increment (frequency / sample_rate) rather than holding a frequency
internally, so through-zero FM and per-sample pitch modulation compose
without any API change.
#include <pulp/signal/osc/va.hpp>
pulp::signal::osc::VaOscillator osc;
osc.set_shape(pulp::signal::osc::VaShape::saw);
for (int i = 0; i < num_samples; ++i)
output[i] = static_cast<float>(osc.next(440.0 / sample_rate));
Which one do I want?¶
| Family | Reach for it when you want... | Header |
|---|---|---|
| VA | The default bandlimited analog shapes — clean, deterministic, hard sync, through-zero FM | va.hpp |
| VCO | The above plus analog "imperfection": drift, jitter, waveshaping, a bowed ramp | vco.hpp |
| DCO | Crystal-stable pitch with the quantization character of a divider-clocked synth, not drift | dco.hpp |
| WT (modern) | A wavetable set with smooth scan/morph and click-free band-switching | wt.hpp |
| WT (lo-fi) | Deliberately gritty ZOH wavetable playback — pitch-tracking aliasing as the point | wt_lofi.hpp |
DCO and VCO both build on VA's shape stage (VaOscillator); the two
wavetable tiers are independent engines with opposite goals (clean vs. lo-fi)
and don't share code with each other or with VA.
VA — virtual-analog core¶
va.hpp is the shared bandlimited core: sine, saw, square (with pulse
width), and triangle, generated directly from the phase and corrected at
each discontinuity by composing PhaseAccumulator (below) with the
BLEP/BLAMP kernels. It underpins VCO and DCO — neither reimplements shape
generation, they configure and drive this class.
- Anti-aliasing is polyBLEP, not a tabulated minBLEP — an improved,
not alias-free, correction: roughly 11-15 dB better than the trivial
waveform below 20 kHz. A free-running sine needs no correction and is
bit-identical to
std::sin; a synced sine does step and is corrected like any other shape. - Hard sync (
next_synced) and through-zero FM (a negative increment) both compose with the correction structurally — a sample with several coincident discontinuities just sums their corrections, so sync + TZFM need no special-cased combination. Measured benefit is 11-34 dB over the trivial waveform across sync, TZFM, and the two together, but the benefit collapses as the instantaneous frequency approaches Nyquist: a synced sine gains 12 dB at a 5 kHz deviation, 6 dB at 20 kHz, and nothing at 60 kHz. Past that point the carrier itself is unrepresentable and no discontinuity correction can rescue it — oversampling the FM path is a separate concern this doesn't attempt. - Pulse width on the square is clamped to [0, 1]; a width narrower than one sample period can't be represented cleanly (the two edges' corrections overlap), but the output stays finite and bounded rather than blowing up.
osc.set_shape(VaShape::square);
osc.set_pulse_width(0.3);
double sample = osc.next(increment);
// Hard sync at a caller-detected reset point:
double synced = osc.next_synced(increment, sync_frac, 0.0);
VCO — circuit-flavored analog character¶
vco.hpp wraps a VaOscillator core with the deterministic front-to-back
path of an analog voltage-controlled oscillator: a pitch-control front end
(VcoTuning, 1 V/octave with scale error and HF-compression knobs), an
integrator-leak "bow" on the saw ramp, a per-shape lumped waveshaper, a
level-vs-pitch tilt, and an output DC-blocking (AC-coupling) stage — plus
two seeded, deterministic pitch-noise sources layered onto the increment
before it reaches the core:
- Drift — a slow, bandlimited random walk (one-pole-colored white
noise) with a corner around 0.4 Hz by default.
drift_depthis the RMS pitch excursion in cents; the wander evolves over hundreds of milliseconds. - Jitter — fast, near-white cycle-to-cycle frequency noise, independent
per sample.
jitter_depthis the RMS per-sample frequency deviation in cents.
Every stage defaults to its neutral value, so a default-constructed
VcoOscillator is bit-for-bit a VaOscillator — the "analog" behavior is
opt-in, not baked in. The noise source is seed-reproducible (set_seed, no
random_device): the same seed and inputs give bit-identical output.
VcoOscillator vco;
vco.prepare(sample_rate);
vco.set_shape(VaShape::saw);
vco.set_bow(2.0); // charge-curve ramp instead of linear
vco.set_drift_depth(3.0); // 3 cents RMS slow wander
vco.set_jitter_depth(0.5); // 0.5 cents RMS per-sample noise
vco.set_seed(12345);
double sample = vco.next(increment);
Be aware the bow and waveshaper are memoryless maps applied after the core's own bandlimited correction — standard analog-modeling composition, but honest about its limit: a nonlinearity on an already-bandlimited signal reintroduces a little aliasing, same as an analog circuit's own stages do.
DCO — divider-clocked, quantized pitch¶
dco.hpp models a late-1970s/early-1980s divider-clocked oscillator: a
crystal-derived master clock (master_clock_hz, default 8 MHz) is divided
down by a programmable counter, and each terminal-count reset drives the
shared VA shape stage. Unlike VCO, this front-end owns no drift or jitter
parameter at all — that would contradict the architecture. A DCO's
characteristic imperfection is pitch quantization, not drift, because an
integer divider can only realize the discrete set f_clk / N.
Two divider schemes, selected by DcoProfile::divider_scheme:
- Integer-N —
N = round(f_clk / f_note); the reset interval is exactlyNmaster clocks, perfectly periodic in continuous time. No forced sync needed — the natural phase wrap is the divider reset. Quantization error grows with note frequency (doubles per octave up). - Fractional-N — a
B-bit accumulator adds a tuning word each master clock and resets on carry-out, so the average pitch can sit arbitrarily close to the note. That accuracy is bought with deterministic ±1-clock period jitter (the reset interval alternates betweenfloorandceilclocks around the average) — the opposite quantization/jitter tradeoff of integer-N, and largest at low notes rather than high ones.
DcoOscillator dco;
dco.prepare(sample_rate);
DcoProfile profile;
profile.master_clock_hz = 8'000'000.0;
profile.divider_scheme = DcoDivider::integer_n;
dco.set_profile(profile);
dco.set_shape(VaShape::square);
dco.set_note_hz(440.0);
double sample = dco.next();
double cents_off = dco.detune_cents(); // the quantization error, exposed
Reach for DCO instead of VCO when you want the crisp, drift-free character of a divider-clocked synth voice (Juno/Jupiter-era) and the audible "almost-but-not-quite-in-tune" quality of integer division — not a smoothly wandering analog pitch.
WT (modern tier) — wavetable with clean scan/morph¶
wt.hpp is a thin osc-module front-end over the shipped
WavetableBankT/WavetableT engine (signal/wavetable.hpp): it plays a
set of single-cycle tables with band-limited band-switching (each
WavetableT selects the band whose Nyquist budget covers the current
frequency and crossfades across 128 samples on a band change) and a smooth
scan across the table set. This front-end adds only what the bank doesn't
already own:
- The osc-module per-sample frequency contract (
next(increment), matching VA/VCO/DCO). - A one-pole scan slew (
set_scan_time_ms, default 5 ms) so a block-rate scan control doesn't zipper — this is the clean wavetable tier.
WtOscillator wt;
wt.prepare(sample_rate);
wt.set_wavetable_set(std::move(tables)); // off the audio thread
wt.set_position(0.6); // slews toward this target
wt.set_scan_time_ms(8.0);
double sample = wt.next(increment);
Playback is for positive frequencies only — through-zero FM is VA/VCO's
domain, not the wavetable engine's. The very first frequency after
construction or reset() snaps to its band rather than crossfading, so
a fresh voice never plays an aliased fade-in from the default band.
Author a band-limited table from recorded audio¶
pulp/audio/wavetable_authoring.hpp provides the offline side of the modern
engine. compile_wavetable() accepts one finite mono buffer, reuses Pulp's
sample-heritage cycle estimator, chooses an adjacent-period seam, periodically
resamples the cycle, and emits the owned band stack accepted by
Wavetable. Compilation allocates and may perform a bounded exhaustive search;
run it on a control or worker thread, never in the audio callback.
#include <pulp/audio/wavetable_authoring.hpp>
#include <utility>
pulp::audio::WavetableAuthoringRecipe recipe;
recipe.automatic_cycle.minimum_cycle_samples = 40;
recipe.automatic_cycle.maximum_cycle_samples = 2400;
auto compiled = pulp::audio::compile_wavetable(
std::as_const(mono_recording).view(), source_sample_rate, recipe,
{{"take-17", "line-in", "session-42"}, "LicenseRef-17", "cleared"});
if (!compiled.valid()) {
// Handle WavetableCompileStatus explicitly; no partial band stack is kept.
return;
}
pulp::signal::Wavetable table(std::move(compiled.bands)); // off audio thread
table.set_sample_rate(static_cast<float>(host_sample_rate));
table.set_frequency_immediate(440.0f);
The recipe requires a power-of-two table length and applies one normalization
gain to every mip band, avoiding level pumping at band transitions. The result
records the chosen source cycle, seam diagnostics, caller provenance, and
compiler-owned content hashes. The table digest describes the returned band
snapshot and becomes stale if a caller mutates it instead of moving it directly
into Wavetable. This API deliberately does not define file I/O,
stereo mixdown, a content-pack JSON format, multi-cycle morph authoring, or UI.
WT (lo-fi tier) — a dedicated variable-clock ZOH engine¶
wt_lofi.hpp is a separate engine, not a mode of WtOscillator. It
plays a raw short single-cycle table with nearest/zero-order-hold lookup at
a playback clock that tracks pitch (fs_play = f0 · table_length), so its
spectral-image ladder rides at n · L · f0 and moves with the note — that
pitch-tracking image ladder is the lo-fi sound, and it's exactly what a
fixed-rate, band-limited, linearly-interpolated engine like WtOscillator
cannot produce (linear interpolation alone suppresses the first image by
~42 dB). Five mechanisms combine to give it its character:
- Variable-clock ZOH images — the pitch-tracking replication described above.
- Optional bit-depth quantization (default 8 bits) of the stored table, undithered, about zero — odd-harmonic grit, ~49.9 dB aggregate SNR at 8 bits.
- A real reconstruction stage (oversample → lowpass → decimate) that
keeps a supra-Nyquist fold from re-entering the band as an unwanted
artifact, while preserving the sub-Nyquist images that are the point.
set_reconstruction(false)exposes the raw naive path for A/B or for callers who want the rawer grit. - Hard (stepped) wave-scan —
set_scanselects the nearest table with no interpolation and no slew, so crossing a table boundary is an instantaneous step: the classic wavetable "zipper," faithfully reproduced rather than smoothed away. - Short-table harmonic ceiling — a length-
Ltable represents at mostL/2harmonics, so low notes are intrinsically darker and high notes fold, a direct consequence of playing the raw table.
LofiWtOscillator lofi;
lofi.prepare(sample_rate);
lofi.set_tables(std::move(raw_tables), /*bit_depth=*/8);
lofi.set_scan(0.5); // hard select, no slew
double sample = lofi.next(increment);
This engine ships no wavetable data of its own — you supply the raw table(s); it reproduces the playback engine's character, not any particular waveform's content.
Shared primitives¶
Both VA and DCO (and therefore VCO, which builds on VA) are wiring over two lower-level headers most callers won't touch directly, but are worth knowing if you're building a new oscillator family on top of them.
phase.hpp — PhaseAccumulator¶
A phase accumulator over the unit circle [0, 1) that reports every
discontinuity ("event") crossed during an advance() — a phase wrap,
either direction — or a forced advance_synced() reset — with the
event's exact sub-sample position and its phase_before/phase_after
endpoints. Negative increments run the phase backward (through-zero FM);
any magnitude is accepted (multiple wraps per sample), bounded by
max_events_per_sample (8) with a truncated() flag if exceeded — the
phase itself always stays exact even when the event list is capped.
Events compose: two events landing at the same sub-sample position simply
sum, which is what makes combinations like "sync to 0 under a negative
increment" (a sync event plus a backward wrap) come out correct without
being enumerated as a special case.
blep.hpp — polyBLEP/polyBLAMP kernels¶
The correction kernels VA (and everything built on it) uses to bandlimit a
discontinuity: BLEP for a step in the value (a saw wrap, a square
edge, a hard-sync reset), BLAMP for a break in the slope (a triangle
apex — the value stays continuous, only the derivative jumps; BLAMP is
BLEP's integral). Every kernel returns a correction to add to the trivial
signal, split across the sample before and after the discontinuity
(Correction{before, after}) — a generator needs to be able to reach back
one sample to apply the before term. This is a two-point polynomial
approximation of the ideal (infinite-support) BLEP, not a tabulated
minBLEP: it buys tens of dB over the trivial waveform, not the ~100 dB a
deep-floor design reaches.
minblep.hpp — bounded causal minBLEP accumulation¶
MinBlepAccumulator<MaximumEvents> is the higher-quality option for a new
oscillator that can afford a 32-sample correction tail. Call next() once per
output sample and add it to the trivial waveform, then call
insert(position, height) for every discontinuity found while advancing to the
next sample. position is in [0, 1]; height is always after minus before.
The default capacity is eight live events, uses at most 256 bytes of per-voice
state, and always scans exactly eight slots per sample. The shared 32-sample by
64-phase table is about 8 KiB and is not copied into each voice.
MinBlepAccumulator<> correction;
double output = saw(phase.phase()) + correction.next();
phase.advance(increment);
for (const auto& event : phase.events()) {
const double height = saw_limit(event.phase_after)
- saw_limit(event.phase_before);
if (correction.insert(event.frac, height)
== MinBlepInsertResult::capacity_exceeded) {
// The new event was dropped; existing correction tails remain intact.
}
}
The kernel is generated reproducibly by
tools/scripts/generate_minblep_table.py from the windowed-sinc, real-cepstrum
minimum-phase construction described by Eli Brandt in Hard Sync Without
Aliasing (ICMC 2001). The generator and a CTest freshness check are the
provenance; the checked-in constants are not an opaque hand-tuned table.
The measured product fixtures compose this primitive with PhaseAccumulator
for free-running saw and hard sync. Across 1.1–6.3 kHz, the worst in-band saw
alias is 38–47 dB below the existing polyBLEP path; the hard-sync fixture is
35.76 dB lower. The regression floor is 30 dB. Forge's current oscillator
catalog remains unchanged, so existing product identities do not silently
change sound. PulpSynth provides the explicit product proof instead: waveform
choice 4 opts into a public-API MinBlepSaw voice, and its Release test records
a 46.59 dB improvement over the example's legacy saw path.
Capacity overflow is observable and deterministic. Slots are allocated and
summed in ascending order; when all are live, insert returns
capacity_exceeded, drops only the new event, and leaves existing tails alone.
At extreme near-Nyquist event rates, callers can count that result, increase the
compile-time capacity, or accept the documented degradation. reset() clears
all tails immediately, and rendering is invariant to host block partitioning.
RT contract¶
Every family follows the same rule the rest of pulp::signal does:
allocation happens at setup (set_wavetable_set, set_tables,
set_oversample_factor, prepare) and must run off the audio thread;
next()/next_synced(), reset(), and the per-sample setters allocate
nothing, lock nothing, and perform no I/O. All classes compute in double
throughout; a float caller narrows once on store. vco.hpp's exp/tanh
and va.hpp's sin are libcalls but not allocations, locks, or I/O.
Measuring and validating¶
These oscillators are validated reference-free (one render, no A/B pair) by the
opt-in Audio Quality Lab:
render any engine to a WAV with pulp-osc-render-wav (--engine vco|dco|wt, --seed),
then reach for the click / edge-smear detector (unexpected discontinuities), the
overlapping-Allan drift-vs-jitter separation, the synthetic oscillator corpus + ratchet,
and the offline WP-4 profile fitter. See that guide's Oscillator validation section.