Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
va.hpp File Reference

Virtual-analog oscillator: the classic static shapes, bandlimited by composing PhaseAccumulator (the clock) with the BLEP/BLAMP kernels (the correction). More...

#include "blep.hpp"
#include "phase.hpp"
#include <cmath>
#include <cstddef>
#include <numbers>
#include <span>

Go to the source code of this file.

Classes

class  pulp::signal::osc::VaOscillator
 Virtual-analog oscillator over the static shapes. More...

Namespaces

namespace  pulp
namespace  pulp::signal
namespace  pulp::signal::osc

Enumerations

enum class  pulp::signal::osc::VaShape { pulp::signal::osc::sine , pulp::signal::osc::saw , pulp::signal::osc::square , pulp::signal::osc::triangle }
 The static shapes. More...

Functions

int pulp::signal::osc::threshold_crossings (double phase_before_advance, double increment, double threshold, std::span< double > out) noexcept
 Sub-sample positions at which the phase crosses threshold (mod 1) during one sample, written to out in chronological order.

Detailed Description

Virtual-analog oscillator: the classic static shapes, bandlimited by composing PhaseAccumulator (the clock) with the BLEP/BLAMP kernels (the correction).

This supersedes signal::OscillatorT (../oscillator.hpp), which is also a polyBLEP oscillator with the same four shape names — extend this one. That one keeps float phase, wraps only at the top, and derives its triangle by integration; the differences are spelled out in its header.

── What this is ──────────────────────────────────────────────────────────

Four shapes — sine, saw, square (with pulse width), triangle — generated trivially from the phase and then corrected at each discontinuity. The oscillator itself owns almost no maths: the accumulator locates discontinuities in sub-sample time, the kernels say what to add, and this class is the wiring plus each shape's analytic description of its own breaks.

── How good it is, in numbers ────────────────────────────────────────────

polyBLEP is a two-point approximation, so these shapes are improved, not alias-free: roughly 11-15 dB better than the trivial shape below 20 kHz. That is what the kernels can buy and no more; a deep-floor claim needs a tabulated minBLEP, not this. test_osc_va.cpp measures the figure per shape rather than asserting an aspiration.

A free-running sine is exact and bit-identical to std::sin: it is continuous across a wrap, so every step the corrector computes for it is exactly zero and is skipped. That is a property of the WRAP, not a property of the shape — a sync jumps a sine to an unrelated phase and genuinely steps, and is corrected like any other break. Deciding "sine, nothing to do" once up front would be wrong for exactly that case.

── Per-sample frequency ──────────────────────────────────────────────────

next() takes the phase increment per call rather than holding a frequency, because FM and per-sample modulation change it every sample. Nothing here caches a value derived from the increment across samples.

── Discontinuities, and where they come from ─────────────────────────────

A shape breaks in two places, and they are found two different ways:

  • At the phase wrap, which PhaseAccumulator already reports as an event with an exact sub-sample frac. The event's phase_before / phase_after are used to derive the break generically, which is why a sync event — whose jump is arbitrary rather than 1 -> 0 — would flow through the same path without a special case.
  • At a threshold inside the period — the square's pulse edge at pulse_width, the triangle's apex at 0.5. These are NOT wraps, so the accumulator does not report them and this file locates them itself, with threshold_crossings below. Missing one is not a subtle degradation: an uncorrected edge aliases at full strength, which is most of what the correction was for.

Both feed the same corrector. A value step gets BLEP; a slope break gets BLAMP; a break with both (which a sync on a triangle would produce) gets both, summed.

── Latency and the two-sample reach ──────────────────────────────────────

The kernels span the two samples straddling a discontinuity, so a correction found while generating sample i also owes a term to sample i+1. That term is carried in a single scalar and added on the next call, which costs one double of state and no latency — the alternative, a one-sample delay line, would delay the whole signal to deliver a correction the carry delivers on time. Several discontinuities in one sample simply sum into the same carry, which is the correct composition rule.

RT contract: no allocation, no locks, no I/O on the per-sample path. sine calls std::sin, which is a libcall but none of those things; a table-driven sine is a separate concern from correcting discontinuities.

Precision: double throughout, matching the accumulator and the kernels. A float caller narrows once on store.

── Hard sync and through-zero FM ─────────────────────────────────────────

Through-zero FM needs no API of its own: frequency arrives as a per-sample increment, so a modulator driving the instantaneous frequency negative is just a caller passing a negative increment, and the accumulator wraps in both directions already. Hard sync adds one entry point, next_synced.

Both compose with everything above, and they do so structurally rather than by enumerating combinations: each event carries the endpoints of its own jump, so a sample's discontinuities are corrected by summing their steps, and coincident events telescope. Measured benefit over the trivial waveform is 11-34 dB across sync, TZFM, and the two together.

The wall is the carrier, not the corrector. Once the instantaneous frequency approaches Nyquist the aliasing stops coming from discontinuities and starts coming from the waveform itself being unrepresentable, and no discontinuity correction addresses that — a perfectly corrected step does not bandlimit a carrier running past half the sample rate. Past that point the correction's benefit falls to nothing (measured: a synced sine gains 12 dB at a 5 kHz deviation, 6 dB at 20 kHz, and nothing at all at 60 kHz, where the aliases exceed the fundamental outright). Fixing that needs oversampling on the FM path and is not attempted here.