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

Phase accumulator over the unit circle [0, 1), suitable as the clock for through-zero FM, hard sync, and per-sample modulation. More...

#include <phase.hpp>

Public Member Functions

void reset (double phase=0.0)
 Set the phase, wrapped into [0, 1). Clears any reported events.
int advance (double increment)
 Advance one sample by increment cycles. Returns the number of events.
int advance_synced (double increment, double sync_frac, double sync_phase=0.0)
 Advance one sample by increment cycles, forcing the phase to sync_phase at sub-sample position sync_frac (clamped to [0, 1]).
double phase () const
 Current phase, always in [0, 1).
std::span< const PhaseEvent > events () const
 Events from the last advance, in chronological order.
bool truncated () const
 Whether the last advance dropped events past max_events_per_sample.

Static Public Attributes

static constexpr int max_events_per_sample = 8
 Event capacity of a single advance. See the truncation note above.

Detailed Description

Phase accumulator over the unit circle [0, 1), suitable as the clock for through-zero FM, hard sync, and per-sample modulation.

The phase is double. At a 20 Hz fundamental and 96 kHz the increment is ~2e-4; float carries ~1e-7 of relative error per accumulation there, which integrates into audible pitch error over a held note. double keeps the accumulated error below 1.2e-16 per sample (see the drift bound below).

The increment is passed per sample rather than held as state, because both FM and per-sample modulation change it every sample. Its sign and magnitude are unrestricted:

  • Negative increments run the phase backward and wrap through 0 to 1. Through-zero FM requires this: when the modulator drives the instantaneous frequency below zero, the phase must actually reverse. An accumulator that only wraps at the top silently clamps the through-zero region away.
  • **|increment| > 1** produces several wraps in one sample, which happens when instantaneous frequency exceeds the sample rate.

Wrapping

phase += increment followed by a single -= 1.0 is wrong for both cases above: it does not wrap at 0 at all, and it wraps at most once. A while loop fixes correctness but is unbounded — its trip count is |increment|, which a modulator controls, so a runaway modulator stalls the audio thread.

This uses n = floor(phase + increment) instead: it is exact, handles both signs, handles any magnitude, and is branch-free and O(1). On x86-64 (SSE4.1 roundsd) and AArch64 (frintm) floor is a single instruction, not a libcall. std::fmod would also be correct but is a libcall on the same targets and computes a remainder this already has in hand.

Event positions

frac is measured from the start of the sample being advanced. Its range is (0, 1] for a forward wrap and [0, 1) for a backward one. The asymmetry is forced, not a convention: with the domain half-open at the top, phase leaves through the top at 1 and through the bottom just past 0. Closing the range on the other side in either direction drops the wrap that lands exactly on a sample boundary — and a dropped wrap is an uncorrected discontinuity. sync events land anywhere in [0, 1], wherever the caller asked.

Events are reported in chronological order and are valid until the next advance(), advance_synced(), or reset().

Events compose

The events of a sample, applied in order, describe the wrapped phase trajectory exactly: each one's phase_before is where the previous left off. Two events may share a frac, and correction stages must sum them rather than treat that as a contradiction — coincident steps at one position add to a single step of the summed magnitude, which is the right answer in both cases that produce them:

  • Phase rising to exactly 1 at a sample boundary and then reversing reports a forward wrap (1 -> 0) and a backward wrap (0 -> 1). They sum to zero, which is correct: the phase touched the boundary and turned around without ever being discontinuous. Suppressing either one would leave a corrector cancelling a step that never happened.
  • Syncing to 0 under a negative increment reports the sync (p -> 0) and a backward wrap (0 -> 1) at the same position, summing to p -> 1. The phase is 0 only at that instant; immediately after, it is just below 1.

Bounds and RT contract

At most max_events_per_sample events are reported per advance; past that truncated() is set and the surplus is dropped. Truncation needs |increment| > 8, i.e. an instantaneous frequency above eight times the sample rate, where the output is not a signal any correction stage could rescue. The phase itself stays exact when truncated — only the event list is capped.

A non-finite increment — NaN or either infinity — is absorbed rather than propagated: the phase resets to 0 and no events are reported. Without this a single NaN would poison the phase for the lifetime of the voice.

The two are absorbed by one explicit test rather than by the arithmetic, because they fail differently and only one fails safely. NaN loses every comparison below and falls out with no events on its own; an infinity does not, since floor(inf) is inf, which reads as a wrap count past the budget and would report a full event list at positions where nothing crossed.

Every path allocates nothing, locks nothing, and performs no I/O.

const int n = phase.advance(freq_hz / sample_rate);
for (const auto& e : phase.events())
blep.add_step(e.frac, shape(e.phase_after) - shape(e.phase_before));
out[i] = shape(phase.phase());
Phase accumulator over the unit circle [0, 1), suitable as the clock for through-zero FM,...
Definition phase.hpp:129
double phase() const
Current phase, always in [0, 1).
Definition phase.hpp:176
@ i
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20

Member Function Documentation

◆ advance()

int pulp::signal::osc::PhaseAccumulator::advance ( double increment)
inline

Advance one sample by increment cycles. Returns the number of events.

◆ advance_synced()

int pulp::signal::osc::PhaseAccumulator::advance_synced ( double increment,
double sync_frac,
double sync_phase = 0.0 )
inline

Advance one sample by increment cycles, forcing the phase to sync_phase at sub-sample position sync_frac (clamped to [0, 1]).

Wraps occurring before and after the reset are reported alongside the sync event, in chronological order — a sync-driven oscillator can wrap naturally within the same sample that resets it, and both discontinuities need correcting. Returns the number of events.

The sync event is reported even when sync_phase equals the phase it replaces; that is a zero-magnitude jump, which a corrector handles as a no-op without a special case here.

◆ events()

std::span< const PhaseEvent > pulp::signal::osc::PhaseAccumulator::events ( ) const
inline

Events from the last advance, in chronological order.

◆ phase()

double pulp::signal::osc::PhaseAccumulator::phase ( ) const
inline

Current phase, always in [0, 1).

◆ reset()

void pulp::signal::osc::PhaseAccumulator::reset ( double phase = 0.0)
inline

Set the phase, wrapped into [0, 1). Clears any reported events.

◆ truncated()

bool pulp::signal::osc::PhaseAccumulator::truncated ( ) const
inline

Whether the last advance dropped events past max_events_per_sample.

Member Data Documentation

◆ max_events_per_sample

int pulp::signal::osc::PhaseAccumulator::max_events_per_sample = 8
staticconstexpr

Event capacity of a single advance. See the truncation note above.


The documentation for this class was generated from the following file:
  • /home/runner/work/pulp/pulp/core/signal/include/pulp/signal/osc/phase.hpp