Skip to content

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_depth is 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_depth is 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-NN = round(f_clk / f_note); the reset interval is exactly N master 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 between floor and ceil clocks 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:

  1. Variable-clock ZOH images — the pitch-tracking replication described above.
  2. 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.
  3. 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.
  4. Hard (stepped) wave-scanset_scan selects 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.
  5. Short-table harmonic ceiling — a length-L table represents at most L/2 harmonics, 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.hppPhaseAccumulator

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.