Hi @gitnob , @nemoy and others!
Our friend claude code has implemented a nice PLL algorithm that solves the issue:
It’s been my first test with Claude Code and i must say i’m deeply impressed. My expectations have been exceeded by far. Congrats to Anthropic guys!
For the curious, here the details by Claude:
How it works
There is a new function, onExtClock(), which executes with every external clock pulse:
Pulse filtering: A second-order loop smooths out the arrival times of the pulses. This estimates the actual period of the external clock and predicts when the next pulse will arrive. The bandwidth is 2 Hz during the first 2 beats (or following a significant error) to ensure rapid synchronization, and 0.5 Hz once locked, in order to reject jitter.
Phase locking: Each pulse corresponds to a fixed position within the internal ticks (80 ticks per pulse at 24 PPQN). The speed of the internal clock is adjusted so that it reaches exactly that position at the predicted time of the next pulse. Ticks are neither skipped nor duplicated; the phase does not drift, and pulse jitter does not affect this trajectory.
Interleaved ticks: Within each JACK period, internal ticks are generated up to the point of each pulse—before that pulse is processed—rather than generating all of them at the end based on the final tempo.
Stable tempo: The BPM value displayed and published to JACK is derived from the filtered period, rather than from the interval between any two individual pulses.
Other changes
Beats and measures following MIDI START: The first pulse received after a START command via the clock port is aligned with the next internal tick; this tick then becomes the start of a measure (beat 1, with measure synchronization).
Large tempo jumps: If three consecutive pulses arrive, all showing a significant error in the same direction, the period is re-estimated based on those pulses. The pulse count is maintained, ensuring that the beats do not shift.
Loss of clock signal: If 4 consecutive pulses are missed, the lock is released, and the internal clock continues to run freely at the most recent tempo. Tempo locked: while synchronized (locked), setTempo() has no effect; therefore, tap tempo, UI-based changes, and sequenced tempo changes are ignored.
PPQN change: setExtClockPPQN() resets the PLL.
I have removed the previous adjustment of nNextBeatTime using nTickDelta.
Simulation results
The simulation uses the actual function extracted from the file, at 48 kHz with 256-frame periods:
┌────────────────────────────────────────────┬──────────────
│ Scenario │ Displayed Tempo │ Phase Error (RMS / Max)
├────────────────────────────────────────────┼──────────────
│ 120 BPM, 2 ms jitter │ 119.7 – 120.2 │ 0.45 / 1.3 ms
├────────────────────────────────────────────┼──────────────
│ 120 BPM, timestamps quantized to the period │ 119.9 – 120.1 │ 0.35 / 0.7 ms
├────────────────────────────────────────────┼──────────────
│ Jump 120 → 90 BPM │ Stabilizes at 90 │ 0.53 / 1.8 ms
├────────────────────────────────────────────┼──────────────
│ Jump 174 → 60 and 60 → 174 BPM │ Stabilizes │ < 0.5 / 1.5 ms
├────────────────────────────────────────────┼──────────────
│ Jump 200 → 80, 3 ms jitter + quantization │ Stabilizes │ 1.9 / 3.9 ms
└────────────────────────────────────────────┴──────────────
For reference, with the previous algorithm, a 2 ms jitter at 120 BPM causes the beat-by-beat calculated tempo to vary by approximately ±14% (roughly 104–136 BPM). This is a manual estimate, not derived from the simulation.
Amazing!
The fix is available in tomita branch, FYI, the new development branch.
Enjoy!