|
Operator SDK
Build MIDI processing and UI modes for the Operator device
|
Modes that play or sequence time-stamped material (SMF playback, LFOs, smooth visuals) need a clock position. The firmware exposes that position as an integer 24 PPQ counter, get_pulse_count. This section explains why a naive read of that counter chunks, and how the ClockInterpolator helper smooths it.
Most modes don't. A transform or a filter reacts to the messages that arrive, not to time, so it never touches the clock at all. You only need the clock when your mode sequences or plays time-stamped material: an arpeggiator, a step sequencer, an LFO, an SMF player. If that is not your mode, you can skip the rest of this page.
The rest covers the three things a clock-aware mode does: read a smooth position, react to the transport, and generate clock itself.
get_pulse_count() advances 24 times per quarter-note. At 120 BPM that is only ~48 increments per second, one step every ~21 ms. A mode that derives its position as get_pulse_count() / 24.0f gets a value that jumps in 24-PPQ steps. Any mode playing or sequencing at a resolution finer than 24 PPQ (an SMF file's division is typically 96, 192, or 480 ticks/quarter) bunches its events onto those pulse edges. The audible result is a ~48 Hz "chunky" or "swingy" feel: events that should be evenly spread instead arrive in clumps locked to the pulse grid.
op::sdk::ClockInterpolator (header-only, in <operator_sdk/timing.h>) turns the integer counter into a smooth fractional position. It is opt-in. timing.h is deliberately NOT pulled in by operator_sdk.h, so you must #include <operator_sdk/timing.h> explicitly.
Internally it measures the recent inter-pulse interval (a median of the last three intervals, in get_tick microseconds) and reports a fraction of the way toward the next pulse. The fraction is hard re-synced at every real pulse edge, a derived prediction between edges, snapped back to truth whenever the counter actually advances.
The interpolator is freestanding-safe by construction: no double, no libc, no 64-bit divide (see The Mode Mindset, because timing.h is #included into your mode, it is mode code for the build check).
Hold a ClockInterpolator as a per-instance member of your mode's own state, never as static or shared file-scope state, which would violate per-instance isolation (each loaded mode instance must have its own interpolation history). Call now() exactly once per process() tick: that single call is what feeds the interpolator's interval history.
now() returns a Position:
The Status field has four values:
| Status | Meaning |
|---|---|
| Live | Clock active, and the fraction is sweeping normally within a pulse. |
| HeldOverdue | Clock active but the next pulse is late, so the fraction is clamped just shy of unity and held until the pulse arrives. |
| HeldStale | Clock was active but no pulse has arrived for ~4x the expected period, so the position is frozen at last-known (a stalled clock). |
| NoClock | get_clock_state reports no clock (clock idle, or Clock Source = Mode with no clock-gen mode loaded), and the position is frozen. |
The interpolator never resets to zero on clock loss: a stalled or absent clock freezes the last-known position rather than snapping back.
pulse_period_us() reports the smoothed microseconds between consecutive 24 PPQ pulse edges (median of the last three intervals), or 0 until three intervals have been observed. One quarter note spans 24 pulses, so pulse_period_us() * 24 is microseconds-per-quarter-note and 60'000'000 / (pulse_period_us() * 24) is BPM, useful when a mode needs the tempo of the external clock and not just the position, such as scaling an elapsed-time and total-time readout to whatever tempo is arriving. It reflects whatever now() last sampled, so the once-per-tick now() contract keeps it current.
The interpolator is purely free-running. It has no reset() and no anchor. Deltas such as "pulses since the user pressed PLAY" are the consuming mode's responsibility: snapshot now().pulse into one of your own mode variables at the anchor point and do integer subtraction against later samples.
get_pulse_count() restarts at zero once it reaches 4,294,965,600, which is about 41 days of continuous clock at 24 PPQ. The restart value is a multiple of 7200, so % 24 and % 96 step math stays musically aligned straight through it. A plain subtraction taken across the restart still returns a meaningless delta, so a mode that emits per pulse needs to bound what it does with one (see "Generating clock" below).
Be clear about what interpolation does and does not give you. A 24 PPQ clock fundamentally reports position only 24 times per quarter-note. The interpolator yields a smooth + predicted position. It is not true sub-pulse cycle-accuracy, which simply is not recoverable from a 24 PPQ source. Sustained external-clock jitter still produces mild between-pulse wobble as the derived period chases the real one. This is a feature, not a defect.
Corollary: modes that only need a coarse integer step index (an arpeggiator firing on grid edges, a step sequencer advancing one cell per pulse) do not need the interpolator at all. Read get_pulse_count() directly and take an integer modulus. Interpolation only helps modes that consume a continuous fractional position.
When the device follows a clock, you consume it through the position API. get_pulse_count is the 24 PPQ position, and get_clock_state tells you whether a clock is running and where it comes from.
Transport re-anchors the beat grid. In the MIDI standard there is no true pause: both Start and Stop reset the beat position, and Continue is ignored. Do not assume the counter is zero at the downbeat. If your mode tracks something like "steps since the user pressed play," snapshot get_pulse_count at your own anchor point and subtract it from later samples.
A clock-generating mode drives the device's tempo. Build it as a global mode with FLAGS GENERATES_CLOCK in op_add_mode() (see Build Reference). It becomes eligible when the user sets Clock Source to Mode, and only one clock-generating mode can be loaded at a time.
set_clock_period_us takes the number of microseconds between 24 PPQ pulses and arms a device interrupt that advances the 24 PPQ pulse counter that get_pulse_count reads. It does not call your process(), and it does not emit any MIDI. Sending the 0xF8 clock messages is your mode's own job.
The model has two halves: the interrupt produces pulses at exactly the period you programmed, and your process(), called on every pass of the real-time loop (Core 1 cycle), polls the counter and does the work for however many pulses have elapsed since its previous call. The two halves run independently, and they do not need to line up.
Keep the last pulse count you acted on in one of your own per-instance variables, and work from pulse_now - last_seen_pulse. Do not drive musical work off the process() call cadence, and do not accumulate get_tick microseconds into a tempo of your own.
The accuracy lives in the interrupt. Jitter in when process() runs changes only when a pulse gets noticed, never how many pulses elapsed, so a late pass borrows nothing from the next one.
Almost every call sees a delta of 0 or 1. The loop runs far faster than the pulse period, so a pulse lands on one pass and the next few passes find nothing new to act on. A delta above 1 means a pass ran late and pulses queued up behind it, and the mode emits that backlog to catch up.
Always cap the backlog. The counter restarts at zero roughly every 41 days of continuous clock (see "Application-level deltas" above), and a subtraction taken across that restart does not return the small true delta. It returns a large meaningless one, which an uncapped emit loop turns into a flood of clock messages on every output. The cap is the only thing standing between that restart and the flood, and it costs a single comparison. Cap at 24, one quarter note, which is the largest backlog worth emitting.
Two separate checks decide whether a mode's clock has any effect. A mode that misses either one goes silently ineffective rather than getting an error back, so it is worth knowing both.
If a clock is not coming out of the device, check the flag and the Clock Source setting before looking anywhere else, then check that the output is set to pass clock.
Call set_clock_period_us(0) in destroy. The interrupt belongs to the device and outlives your slot, so a mode that leaves it armed leaves the device producing pulses after the mode unloads.
get_pulse_count is a single counter. The interrupt advances it when Clock Source is Mode, and an incoming external clock advances it otherwise. Consumers read the same counter either way, which is why a clock-following mode behaves identically whether the tempo comes from inside the device or from outside it. A clock-generating mode polls that same counter, so what it emits from and what every other mode follows are one and the same.
process() is called on every pass of the real-time loop, and the time budget applies to a clock-generating mode exactly as it does to any other. Do close to nothing in the common case. Read the delta, and if it is zero, return. A mode that repeatedly overruns its per-call time budget is disabled at runtime.