Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
pulp::signal::osc Namespace Reference

Classes

struct  Correction
 A discontinuity's correction, split across the two samples it touches. More...
struct  DcoProfile
 OSC-DCO profile — data-driven timing parameters, no waveform or hardware capture. More...
class  DcoOscillator
 A divider-clocked oscillator front-end composing the shared VaOscillator. More...
class  MinBlepAccumulator
 Accumulates causal minBLEP corrections for discontinuities found between adjacent output samples. More...
struct  PhaseEvent
 A single phase discontinuity within one sample period. More...
class  PhaseAccumulator
 Phase accumulator over the unit circle [0, 1), suitable as the clock for through-zero FM, hard sync, and per-sample modulation. More...
class  VaOscillator
 Virtual-analog oscillator over the static shapes. More...
struct  VcoTuning
 The analog exponential pitch converter, as a pure control-voltage → frequency map. More...
struct  WaveshaperParams
 A per-shape lumped memoryless nonlinearity — the "analog character" the output stage imprints. More...
class  VcoOscillator
 Virtual-analog VCO over the static shapes, with a circuit-flavored deterministic path. More...
class  WtOscillator
 Modern wavetable oscillator over a set of band-limited WavetableTs. More...
class  LofiWtOscillator
 Dedicated variable-clock ZOH wavetable oscillator (the lo-fi tier). More...

Enumerations

enum class  DcoDivider { integer_n , fractional_n }
 The divider scheme a profile selects. More...
enum class  MinBlepInsertResult {
  inserted , zero_height , invalid_position , non_finite_height ,
  capacity_exceeded
}
enum class  PhaseEventKind { wrap_forward , wrap_backward , sync }
 What produced a phase discontinuity. More...
enum class  VaShape { sine , saw , square , triangle }
 The static shapes. More...

Functions

double blep_residual (double x) noexcept
 Unit BLEP residual at x, the signed distance from the discontinuity to the sample, in samples: negative before it, positive after it.
double blamp_residual (double x) noexcept
 Unit BLAMP residual at x — the integral of blep_residual, and the correction that turns a trivial ramp max(x, 0) into its bandlimited counterpart.
Correction poly_blep (double d, double height) noexcept
 polyBLEP correction for a step in the VALUE.
Correction poly_blamp (double d, double slope_change) noexcept
 polyBLAMP correction for a break in the SLOPE.
double wrap_position (double phase_after, double increment) noexcept
 Sub-sample position of a period wrap, for a phase accumulator that has just wrapped through its boundary.
int 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.

Variables

constexpr double kNoDiscontinuity = -1.0
 A sub-sample position that means "no discontinuity here".

Enumeration Type Documentation

◆ DcoDivider

enum class pulp::signal::osc::DcoDivider
strong

The divider scheme a profile selects.

Enumerator
integer_n 

Fixed integer divider N; perfectly periodic, quantized pitch.

fractional_n 

B-bit fractional accumulator; accurate average pitch, ±1-clock jitter.

◆ MinBlepInsertResult

Enumerator
inserted 
zero_height 
invalid_position 
non_finite_height 
capacity_exceeded 

◆ PhaseEventKind

What produced a phase discontinuity.

Enumerator
wrap_forward 

Phase reached 1 and wrapped to 0 (increment > 0).

wrap_backward 

Phase left 0 downward and wrapped to 1 (increment < 0).

sync 

Phase was forced by advance_synced().

◆ VaShape

enum class pulp::signal::osc::VaShape
strong

The static shapes.

Enumerator
sine 

Continuous across a wrap; a sync still steps it.

saw 

Value step of -2 at the wrap.

square 

Value steps of +2 at the wrap and -2 at the pulse edge.

triangle 

Slope breaks at the wrap and the apex; value is continuous.

Function Documentation

◆ blamp_residual()

double pulp::signal::osc::blamp_residual ( double x)
inlinenoexcept

Unit BLAMP residual at x — the integral of blep_residual, and the correction that turns a trivial ramp max(x, 0) into its bandlimited counterpart.

Where BLEP is discontinuous at the origin (it cancels a jump in the value), BLAMP is continuous there and only its slope breaks — it cancels a jump in the derivative, leaving the value alone. Support is [−1, 1), and the residual vanishes at both ends, so it perturbs nothing outside the two samples it owns.

◆ blep_residual()

double pulp::signal::osc::blep_residual ( double x)
inlinenoexcept

Unit BLEP residual at x, the signed distance from the discontinuity to the sample, in samples: negative before it, positive after it.

This is the correction that turns a unit step u(x) into its bandlimited counterpart: u(x) + blep_residual(x) rises smoothly from 0 at x = -1 through 0.5 at the discontinuity to 1 at x = 1, where a trivial step instead jumps. Support is exactly [−1, 1); outside it the residual is zero, which is precisely the truncation this kernel trades accuracy for.

x = 0 belongs to the after branch, matching u(0) = 1 — the sample that lands exactly on a discontinuity is the first one that has taken the step.

That single choice is why poly_blep does NOT evaluate both of its taps through this function: a before tap landing exactly on the discontinuity has not taken the step and needs the other limit. This is the mathematical residual, and at its one discontinuity it can only answer for one side; a caller that knows which side its sample is on should say so rather than ask here and hope.

◆ poly_blamp()

Correction pulp::signal::osc::poly_blamp ( double d,
double slope_change )
inlinenoexcept

polyBLAMP correction for a break in the SLOPE.

d follows poly_blep's convention exactly. slope_change is the change in slope measured after minus before, in units of value per sample — not per phase unit, and not per second. A triangle traverses its full range four times per cycle, so at its apex the slope goes from +4·increment to -4·increment and slope_change is -8·increment; converting to per-sample units is where the caller's increment enters, and it is the caller's job.

Both returned terms are ADDED to the trivial signal.

Out-of-window positions return nothing, exactly as in poly_blep. What this does NOT need is that function's per-tap side selection: BLAMP is continuous at the origin — it corrects a break in the slope and leaves the value alone — so both sides agree there (at 1/6) and reading either is the same answer. That continuity is also why the boundary bug poly_blep guards against never showed up on a triangle: only a kernel that steps can be read off the wrong side of its own step.

◆ poly_blep()

Correction pulp::signal::osc::poly_blep ( double d,
double height )
inlinenoexcept

polyBLEP correction for a step in the VALUE.

d is the discontinuity's sub-sample position: it occurs d samples after the before sample, equivalently 1 - d samples before the after sample. Outside [0, 1] there is no correction and both terms are zero — a discontinuity always lands inside the sample it was found in, so a position outside that window is a caller error, and none is the only answer that cannot make the output worse than leaving it alone. kNoDiscontinuity is outside the window by construction and so is guaranteed, not merely likely, to produce nothing. A non-finite d lands here too.

height is the size of the jump measured after minus before — negative for a saw wrapping down, positive for a square's rising edge. Both returned terms are ADDED to the trivial signal (see the file-level sign convention).

── Why this does not route both taps through blep_residual ─────────────

The taps sit on OPPOSITE sides of the discontinuity — that is what before and after mean — and the residual STEPS across it, so each tap has to read its own side. The distinction is invisible in the interior and decisive at the two boundaries, which are ordinary operating points rather than corner cases:

  • d = 0 puts the discontinuity exactly on the before sample, which still carries its pre-step value — a phase sitting at 0 and driven backward wraps out from under it, and the sample was taken before it left. That tap needs the residual's LEFT limit, +1/2.
  • d = 1 puts it on the after sample, which carries its post-step value. That tap needs the RIGHT limit, -1/2.

blep_residual resolves x = 0 to the right limit — correct for the after tap, and exactly backwards for the before one. Reading both through it gives the d = 0 tap a correction of the wrong SIGN, which does not merely under-correct: it moves the sample a full height the wrong way, landing it outside the waveform's own range and leaving the boundary wrap worse corrected than if it had been ignored. Selecting the side per tap is what makes both boundaries land on the step's midpoint, which is what a bandlimited step does there.

With d confined to [0, 1] each tap's side is known outright, so both are branch-free evaluations of their own branch rather than a lookup that has to re-decide which side it is on.

◆ threshold_crossings()

int pulp::signal::osc::threshold_crossings ( double phase_before_advance,
double increment,
double threshold,
std::span< double > out )
inlinenoexcept

Sub-sample positions at which the phase crosses threshold (mod 1) during one sample, written to out in chronological order.

Returns the count.

This is PhaseAccumulator's wrap-finding inversion generalized from integer levels to a level offset by threshold: the phase is linear in sub-sample time, so the unwrapped phase reaches level m + threshold at exactly t = (m + threshold - phase_before) / increment. Shifting the phase by -threshold turns "cross the threshold" back into "cross an integer", which is a problem the accumulator has already solved exactly.

It is that shared derivation, not a re-implementation, that makes an apex or a pulse edge land with the same accuracy as a wrap — and at threshold = 0 this reproduces the accumulator's own wrap positions, which is asserted rather than assumed (test_osc_va.cpp).

Ranges match the accumulator's for the same reason they do there: (0, 1] going forward, [0, 1) going backward. Closing the other side would drop a crossing landing exactly on a sample boundary, and a dropped crossing is an uncorrected discontinuity.

Returns 0 for a zero or non-finite increment: a phase that is not advancing crosses nothing, and absorbing the non-finite case here keeps it from reaching the kernels. At most out.size() crossings are reported; the surplus is dropped, which needs an instantaneous frequency above out.size() times the sample rate.

◆ wrap_position()

double pulp::signal::osc::wrap_position ( double phase_after,
double increment )
inlinenoexcept

Sub-sample position of a period wrap, for a phase accumulator that has just wrapped through its boundary.

phase_after is the phase remaining past the boundary — the accumulator's value once the period has been subtracted — and increment is the per-sample phase step, both in the same units (a [0, 1) phase and a normalized increment, typically). The wrap happened phase_after / increment samples before the current sample, so the position relative to the preceding sample is one minus that.

The result feeds poly_blep/poly_blamp as d, with the before term landing on the sample that preceded the wrap and after on the current one. This is a convenience for the predictable case only: a hard-sync reset or a through-zero-FM direction change is not derivable from the increment, and those callers compute d from the event that caused the discontinuity.

Returns kNoDiscontinuity when increment is not positive or not finite: a phase that is not advancing has no wrap to place, so the correction must be exactly zero.

The guard is not decoration. A frozen phase makes this 0.0 / 0.0, and NaN survives both kernels' range checks to land in the output buffer. Nor can the guard simply return 0: d = 0 is a legitimate operating point meaning "the discontinuity sits on the `before` sample", and it yields a half-step correction — so returning it would inject a step on every sample of a stopped oscillator, which is the opposite of the intent.

Variable Documentation

◆ kNoDiscontinuity

double pulp::signal::osc::kNoDiscontinuity = -1.0
inlineconstexpr

A sub-sample position that means "no discontinuity here".

It sits outside [0, 1], which poly_blep and poly_blamp reject outright, so it produces exactly zero from both. No in-range value could serve: 0 and 1 are the two ENDS of the window, not spare sentinels, and each is a real operating point carrying a half-height correction.