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. | |
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:
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.
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().
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:
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.
|
inline |
Advance one sample by increment cycles. Returns the number of events.
|
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.
|
inline |
Events from the last advance, in chronological order.
|
inline |
Current phase, always in [0, 1).
|
inline |
Set the phase, wrapped into [0, 1). Clears any reported events.
|
inline |
Whether the last advance dropped events past max_events_per_sample.
|
staticconstexpr |
Event capacity of a single advance. See the truncation note above.