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

OSC-DCO: a divider-clocked oscillator front-end. More...

#include "phase.hpp"
#include "va.hpp"
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>

Go to the source code of this file.

Classes

struct  pulp::signal::osc::DcoProfile
 OSC-DCO profile — data-driven timing parameters, no waveform or hardware capture. More...
class  pulp::signal::osc::DcoOscillator
 A divider-clocked oscillator front-end composing the shared VaOscillator. More...

Namespaces

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

Enumerations

enum class  pulp::signal::osc::DcoDivider { pulp::signal::osc::integer_n , pulp::signal::osc::fractional_n }
 The divider scheme a profile selects. More...

Detailed Description

OSC-DCO: a divider-clocked oscillator front-end.

A DCO of the late-1970s / early-1980s lineage is NOT a free-running analog oscillator whose frequency is a control voltage — it is a timing front-end. A crystal-derived master clock f_clk is divided down by a programmable counter to produce one reset (terminal-count) event per output cycle, and a waveshaper builds the audio from that periodic reset. OSC-DCO owns exactly that timing front-end; the actual waveform is shaped by the SHARED bandlimited stack (VaOscillator — itself PhaseAccumulator + the BLEP/BLAMP kernels), which OSC-DCO composes and does not re-implement.

── What a DCO is, and what makes the model faithful ──────────────────────

Two properties are the point of the architecture, not defects to smooth away:

  1. Pitch is quantized. An integer divider can only realise the discrete set f_clk / N. The achievable pitch nearest an equal-tempered note carries a residual error that is LARGER at high notes (small N, widely spaced) than low ones (large N, closely spaced). A faithful DCO computes its increment from the quantized divider N (i.e. from f_clk / N), not from the commanded note, so the quantization falls out for free. A model that renders every note dead-on-pitch has idealized away the one thing that makes a DCO a DCO. The worst-case error is

    |e|_max <= 865.617 * f_note / f_clk   cents,
    

    inversely proportional to the master clock and directly proportional to the note frequency (it doubles for every octave up).

  2. No drift. The reference is a crystal-stable clock, so a DCO has NO per-voice pitch drift of the kind a VCO exhibits. Its characteristic imperfection is quantization, NOT drift. This is the clean line separating OSC-DCO's timing concerns from OSC-VCO's analog-character concerns: an OSC-DCO profile carries no drift / jitter-depth parameter, and adding one would contradict the architecture. DcoOscillator accordingly exposes no such knob, and its rendered pitch is stationary over any render length.

── The two divider schemes ───────────────────────────────────────────────

Integer-N. N = round(f_clk / f_note); the reset interval is exactly N master clocks, i.e. perfectly periodic in continuous time. This maps to the free-running path: increment = (f_clk / N) / f_s and PhaseAccumulator:: advance(increment) — the natural wrap_forward IS the divider reset, band- limited by the shared BLEP path. No forced sync is required, and this is the honest statement rather than a shortcut: because the increment is built from the quantized f_clk / N, the note-dependent quantization error is reproduced exactly, and the period carries no jitter.

Fractional-N. A later refinement replaces the fixed integer divider with a B-bit accumulator that adds a tuning word Δ = round(f_note · 2^B / f_clk) each master-clock tick and resets on carry-out of the top bit, giving f_osc = f_clk · Δ / 2^B. The average pitch can be placed arbitrarily close to the note (the frequency step is a constant f_clk / 2^B in Hz, so the residual cents error ≈ 865.617 · f_clk / (2^B · f_note) shrinks with B and is LARGEST at low notes — the opposite note-dependence of integer-N). That accuracy is bought with a deterministic period jitter: to realise a non-integer average, the reset interval alternates between floor(f_clk/f_osc) and ceil(f_clk/f_osc) master clocks, and each reset lands on an integer master-clock edge — a timing deviation bounded by one clock. The accumulator is integer arithmetic, so the reset schedule is exact and bit-identical across platforms (reset_intervals() exposes it), and the reset is driven as a PhaseAccumulator::advance_synced(increment, sync_frac, 0.0) at the clock-grid position, re-anchoring the phase every cycle so the ±1-clock jitter is rendered band-limited rather than smeared or idealized.

The per-cycle increment is f_s-clocks / this-cycle's-clock-count, so the ramp reaches the reset exactly at the grid edge and the reset is a single clean step there. This differs from a strictly constant-slope (f_osc/f_s) ramp by the per-cycle slope variation of ±1/N_avg; the constant-slope accumulator-MSB micro-structure is a sub-clock detail safe to idealize, and the per-cycle increment is what lets the shared BLEP path place one clean, correctly-jittered step per cycle without a caller-supplied-slope shaper extraction.

── The seam ──────────────────────────────────────────────────────────────

OSC-DCO produces the phase and PhaseEvents; the shared VaOscillator shapes them. OSC-DCO shapes nothing — shape, pulse width, and band-limited correction are the core's. This is why the front-end is thin.

RT contract: no allocation, no locks, no I/O on the per-sample path. double throughout, matching the core. The reset schedule is exact integer arithmetic plus the core's libcalls (sin on the sine shape); none of it allocates, locks, or blocks.