Idƴl Documentation

12 — Timing accuracy

← Back to index · Previous: Architecture


Idƴl is a language for describing time, so the first thing it owes its users is to keep time well. This chapter explains what “keeping time well” means, how it can be measured, and what the measurements show for Idƴl and for four other music programming environments running the same program on the same computer.


Why measure timing at all?

A program that schedules events can fail a musician in three ways.

The first failure is audible irregularity. If a pulse that should be perfectly even arrives a little early, then a little late, a steady percussive rhythm starts to sound uneven. With short, sharp sounds, deviations of a few milliseconds can already be heard.

The second failure is slower and more treacherous: falling out of step. Suppose a piece runs alongside a video, a second computer, a drum machine or a musician with a click track. If the program’s idea of a second is very slightly longer than the real second, nothing is audible at first. After ten minutes the gap is noticeable, and after an hour of an installation the two parts no longer belong together. This is drift, and it is what the claim “Idƴl is drift-free” is about.

The third failure concerns the program itself: layers that should coincide. A figure of three notes against a figure of four must land together on every shared beat, for as long as the piece lasts. If each layer is individually drift-free, they stay together by construction; Idƴl’s benchmark also checks this directly (its poly scenario), while the comparison below concentrates on the first two failures.

A claim like “drift-free” is easy to write and hard to trust, so this chapter backs it with numbers. The same measurements also act as a safety net for development: a change to the scheduler that makes timing worse shows up immediately.


Jitter and drift: two very different errors

Imagine a pulse every 10 ms. The ideal onsets are at 0, 10, 20, 30… ms.

A jittery pulse arrives at 0.02, 10.05, 19.98, 30.03 ms. Each onset is a few hundredths of a millisecond off, sometimes early and sometimes late, but the errors do not add up: the pulse is still, on average, exactly where it should be. After an hour it will still be within a few hundredths of a millisecond of the ideal.

A drifting pulse arrives at 0, 10.01, 20.02, 30.03 ms. Every interval is only 0.01 ms too long, which is far too small to hear. But the error grows by that amount on every tick. After 100 ticks (1 s) it is 1 ms behind, and after an hour it is 3.6 seconds behind.

The drifting pulse looks more regular than the jittery one when you only listen to neighbouring notes, yet it is the one that ruins a long performance. That is why the two errors are measured separately.

Drift typically comes from a loop that waits a fixed amount of time after doing its work:

loop:
    play_note()
    sleep(10 ms)

Playing the note takes a little time, and the operating system always wakes the program slightly late. Both delays are added to every following note, forever.


How Idƴl avoids drift

Idƴl never computes the next deadline from the moment a tick finished. It computes it from the previous deadline: next = previous + dt. If one tick is late, the next deadline is unaffected, so lateness cannot accumulate. When a tick is so late that the following deadline has already passed (for example because the computer froze for a moment), Idƴl skips those periods instead of firing a burst of catch-up notes, and the pulse continues on its original grid.

Idƴl offers two ways of waiting for a deadline. The choice trades CPU usage against precision:

flag how it waits consequence
--audio-clock, -ac (default) a kernel timer wakes the scheduler every 32 / 48 000 s ≈ 0.67 ms, and due ticks fire at that moment very little CPU; a tick can be up to one timer period (0.67 ms) late
--system-clock, -sc sleeps until 0.5 ms before the deadline, then watches the clock continuously accurate to a few µs; costs about 5 % of one CPU core at dt = 10ms

What is measured, and what each number means

Every measurement starts from the same raw material: for each tick, the moment it really happened, compared with the moment it should have happened. The numbers below are different ways of summarising that comparison, each answering one of the questions from the beginning of this chapter.

Time error

Question: where is each tick compared with where it should be?

The time error of tick k is its actual time minus its ideal time t₀ + k·dt. With dt = 10ms, if tick number 500 was due at 5 000 ms and arrived at 5 000.3 ms, its time error is +0.3 ms. Plotting the time error of every tick against time gives the most complete picture: a flat, fuzzy band means jitter without drift, and a band that slopes up or down means drift.

When a system is observed from the outside, nobody knows when its tick 0 was supposed to happen, only when it arrived. The observer therefore places t₀ at the earliest ticks it sees. Absolute time errors of different systems can then not be compared with each other, but their slope and their spread can.

Missed ticks

Question: did every event actually happen?

The observer counts the ticks it expected but never received. A scheduler that falls far behind and skips periods, or a lost message, shows up here.

Interval error (jitter)

Question: how even does the rhythm sound?

The ear judges a pulse mostly by the distance between consecutive notes (the inter-onset interval, IOI). The interval error is how much each interval differs from dt. With onsets at 0, 10.3 and 20.0 ms, the two intervals are 10.3 and 9.7 ms, so the interval errors are +0.3 and −0.3 ms, even though the third note is exactly on time.

The results report two summaries of all interval errors. The standard deviation σ is the typical size of the irregularity. p1 / p99 give the range that contains 98 % of intervals: “−26 / 27 µs” means only one interval in a hundred was shortened by more than 26 µs, and one in a hundred lengthened by more than 27 µs.

Drift

Question: will the program stay in step over a long piece?

Drift is measured by fitting a straight line through the time errors and taking its slope. The slope is expressed in ppm (parts per million): 1 ppm means the error grows by 1 µs every second. For example, if the time error goes from 0 to −180 µs over a 180-second run, the drift is −1 ppm.

These rules of thumb help read it:

drift error after 1 minute after 1 hour after 1 day
0.01 ppm 0.6 µs 36 µs 0.9 ms
1 ppm 60 µs 3.6 ms 86 ms
10 ppm 0.6 ms 36 ms 0.86 s
1000 ppm (the drifting pulse above) 60 ms 3.6 s 86 s

Each drift value comes with a 95 % confidence interval, e.g. “+0.005 ± 0.003 ppm”. It tells how precisely the slope is known from this run. A large jitter makes the line harder to pin down, so the interval widens. A drift whose interval includes 0 is statistically indistinguishable from no drift.

MTIE — the worst gap in a given time window

Question: what is the largest disagreement I can expect over a passage of a given length?

MTIE (maximum time interval error, a standard measure for network clocks) takes every window of a given length (1 s, 10 s, 100 s…). It finds the largest difference between the earliest and the latest time error inside any such window. If, somewhere in the run, one 10-second passage had a tick 50 µs early and another 70 µs late, the MTIE for 10 s is at least 120 µs.

The interesting part is how MTIE changes as the window grows. For a drift-free clock it quickly stops growing, because jitter has a fixed size no matter how long you look. For a drifting clock it keeps rising, since the longer the window, the further the error has wandered.

Allan deviation — how stable is the tempo?

Question: if I measure the average tempo over τ seconds, how much does that average change from one measurement to the next?

Allan deviation is the classic measure of clock stability. Picture measuring the average interval of the pulse over one second, then over the next second, and so on, and noting how much consecutive averages differ. The Allan deviation for τ = 1 s is the typical relative difference. A value of 10⁻⁶ means consecutive one-second tempos differ by about one part in a million.

Its usefulness comes from repeating this for many window lengths. Pure jitter averages out: the longer the window, the more accurate the average tempo, and the curve falls steadily (by a factor of 10 for every tenfold longer window). When something slowly changes the tempo itself, such as a crystal warming up or a clock being corrected, the averages stop improving and the curve flattens. A steadily falling line is therefore the signature of a clock whose only flaw is short-term jitter.

Allan deviation is blind to one thing: a tempo that is wrong by a constant amount. If every one-second average is 10 ppm too slow, consecutive averages still agree perfectly. A constant tempo error is exactly a steady drift, and that is why drift is measured separately with the slope above. The two measures complement each other: drift says whether the tempo is right, Allan deviation says whether it is steady.


The experiment

To compare systems fairly, each of them runs the same small program. It sends one OSC message /tick <counter> every 10 ms, immediately, without the time-stamped bundles some environments use to mask their jitter. In Idƴl:

ticker(handle, dt=10ms) = cnt |> {
    init: { cnt = 0 }
    res = o::osc_send(handle, "/tick", cnt)
    cnt = cnt + 1
}

A single, separate observer program receives the messages of every system over the computer’s internal network. It uses the arrival time recorded by the operating system kernel, which is taken before the observer program itself is even woken up. Every system is thus judged by the same stopwatch, through the same path, so the comparison does not depend on how each environment measures its own time. This setup measures when events leave each program. That is what matters when a program drives synthesizers, lights, video or another computer, but it says nothing about the quality of audio each environment produces itself.

Each configuration ran for 180 seconds, one after the other, on 14 September 2026, on the same computer, and the first 2 seconds of each run were discarded. No program used real-time priorities, and the CPU was left in its default power-saving mode, as on an ordinary laptop.

test machine
computer ASUS ROG Strix G16 (G614PM) laptop
processor AMD Ryzen 9 8940HX with Radeon Graphics: 16 cores / 32 threads, up to 5.39 GHz, 64 MiB L3 cache
memory 32 GB DDR5 SO-DIMM, 2 × 16 GB Micron (MTC8C1084S1SC56BD1), dual channel, rated 5600 MT/s, running at 5200 MT/s
operating system Ubuntu 24.04.4 LTS, Linux 7.0.0-31-generic (PREEMPT_DYNAMIC)
clock source TSC
CPU frequency policy powersave governor, balance_performance energy preference
audio server PipeWire 1.0.5 at 48 kHz, quantum forced to 256 frames
software version and build
Idƴl commit f0a81d9, Release build, GCC 13.3.0
Csound 6 6.19.0 (csound6 branch, commit 0838618a7), compiled from source on this machine with GCC 13.3.0, double-precision samples, installed
Csound 7 7.0.0 beta (develop branch, 7.0.0-beta.17-958-g3012cc60d), compiled from source on this machine with GCC 13.3.0, double-precision samples, run from its build directory with its own library and plugins
SuperCollider 3.13.0, Ubuntu package (sclang only, no server)
Pure Data 0.54.1, Ubuntu package
ChucK 1.5.2.1, Ubuntu package

Two groups of systems

The environments fall into two groups, according to what sets the pace of their events.

The first group follows the computer’s own clock. Idƴl, SuperCollider’s language and Pd without audio wait for their deadlines using the operating system’s clocks and timers. Csound can do the same with its null audio module (-+rtaudio=null). Without a sound card Csound could just as well run as fast as possible, so here is exactly what that module does, in both 6.19 (rtplay_dummy in Top/csound.c) and 7.0 (Top/csound_rtio.c), whose code is identical. After each audio buffer it advances a target time by exactly one buffer duration, reads the current time with gettimeofday(), and sleeps for the difference, rounded to whole milliseconds, with nanosleep(). The target is absolute, so the pacing is drift-free with respect to the system clock. A system-call trace of both versions confirms it: 375 sleeps in 2 seconds, one per 256-frame buffer, and no audio device opened.

The second group follows the sound card. Csound through ALSA, ChucK, and Pd through JACK all hand audio buffers to PipeWire, which asks for the next buffer when the sound card needs it. Their time is counted in samples, and the sound card’s crystal decides how long a sample lasts. ChucK has no real-time mode without a sound card (its --silent mode computes as fast as possible), so it only appears in this group. Antescofo is not included because it is not available as a standalone program.

Matched buffer sizes

Buffer sizes shape the timing of every buffered environment, so they were matched wherever a setting exists. Two different sizes matter.

The software vector is the number of samples computed between two passes of control code. It was 32 frames (0.67 ms) for Csound 6 and 7 (ksmps = 32) and for Idƴl’s audio clock, whose timer wakes up every 32 frames. ChucK has no such setting because it runs its control code sample by sample. Pd’s block is fixed at 64 frames (1.3 ms) in vanilla Pd.

The system buffer is the amount of audio handed to the operating system at once. It was 256 frames (5.3 ms) everywhere: Csound -b 256 (with -B 512, because Csound silently halves -b when -B is not larger), ChucK --bufsize:256, Pd -blocksize 256, and the PipeWire quantum. Idƴl’s system clock scheduler and SuperCollider’s language have neither kind of buffer.

label environment and settings paced by
idyl-sys Idƴl, --system-clock system clock
idyl-audio Idƴl, --audio-clock (default), 32-frame timer kernel timer
sclang SuperCollider, Routine on SystemClock system clock
sclang-tempo SuperCollider, Routine on TempoClock system clock
csound6 Csound 6.19, metro + OSCsend, ksmps=32, -b 256 -B 512, null audio module system clock (gettimeofday), per buffer
csound7 Csound 7.0 beta, same file and options system clock (gettimeofday), per buffer
pd Pd, metro → oscformat → netsend, -nosound system timer, per scheduler wake-up
csound6-audio Csound 6.19, same file, ALSA output sound card via PipeWire
csound7-audio Csound 7.0 beta, same file, ALSA output sound card via PipeWire
chuck ChucK, dt => now, ALSA, --bufsize:256 sound card via PipeWire
pd-audio Pd, same patch, JACK (pw-jack), -blocksize 256, DSP on sound card via PipeWire

Results

system ticks missed interval error σ (µs) interval error p1 / p99 (µs) drift (ppm) MTIE, 164 s window (µs)
idyl-sys 18 002 0 5.7 −14 / 15 +0.002 ± 0.001 232
idyl-audio 18 003 0 6.2 −17 / 19 −0.997 ± 0.002 261
sclang 17 800 0 9.2 −16 / 16 +0.002 ± 0.002 408
sclang-tempo 17 800 0 7.7 −15 / 16 −0.006 ± 0.002 127
csound6 17 799 0 1 824 −4 934 / 1 167 −0.062 ± 0.444 5 686
csound7 17 799 0 1 835 −4 934 / 1 169 −0.023 ± 0.445 5 681
pd 17 799 0 1 112 −4 971 / 5 061 +0.025 ± 0.432 6 418
csound6-audio 17 796 0 1 792 −5 016 / 1 042 −2.597 ± 0.443 5 918
csound7-audio 17 797 0 1 793 −5 013 / 1 038 −2.536 ± 0.442 5 876
chuck 17 799 0 1 761 −4 677 / 710 −16.579 ± 0.436 7 573
pd-audio 17 802 0 2 373 −4 976 / 5 099 −15.608 ± 0.602 12 192

No system missed a single tick. All numbers include a few microseconds added by the internal network and the observer. The small differences in tick counts come from how long each program ran, not from lost events.

Staying in step

Time error of each system over three minutes, averaged per second and centred on zero

The time-error plot answers the “falling out of step” question directly. Each line is one system’s time error, averaged over every second so that short-term jitter does not hide the trend. A horizontal line means the system keeps pace with real time; a sloped line means it is gradually leaving it.

Every system of the first group stays flat. Idƴl with --system-clock and both SuperCollider clocks drift by less than 0.01 ppm, about 1 µs over the three minutes, at most 36 µs if extrapolated to an hour. Csound 6, Csound 7 and Pd paced by the system clock show no trend either. Their millisecond jitter makes the confidence intervals about two hundred times wider (± 0.44 ppm), and Pd oscillates visibly around zero.

The second group slopes downward. ChucK and Pd on the sound card drift by −16.6 and −15.6 ppm. This is not a defect of their schedulers. They count samples, and the sound card’s crystal, not the computer’s clock, decides how long a sample lasts. Here that crystal runs about 16 parts per million fast compared with the system clock, so their events arrive slightly earlier every second. Over an hour that amounts to 60 ms, which is enough to be noticed if such a program must follow a video or another machine.

Csound 6 and Csound 7 on the sound card show a different pattern, and the two versions behave identically. Between jumps, their error slides downward at about −11 ppm, in the same direction as the other sound-card systems though somewhat less steeply. Roughly every 38 seconds, in both versions, the error then jumps up by 0.2 to 0.4 ms: events suddenly arrive a little later. These corrections pull the overall slope to −2.6 ppm. The pattern suggests that something between Csound’s ALSA module and PipeWire periodically resynchronises the stream, but its cause has not been identified here. The two Csound lines on the sound card should therefore not be read as a smaller drift. They show the same sound-card clock, corrected in small jumps.

Drift slope of each system, with 95 % confidence interval

Idƴl’s default --audio-clock shows a small but very precisely measured slope of −0.997 ppm, and it deserves an explanation. The scheduler’s deadlines are exact, but the kernel timer that wakes the scheduler is programmed with a period rounded down to a whole number of nanoseconds: 666 666 ns instead of 666 666.67 ns. That timer therefore ticks one part in a million faster than intended. Since a tick fires at the first timer wake-up after its deadline, the waiting time slowly shrinks, by 10 ns per 10-ms tick. When it reaches zero, it jumps back to a full timer period (0.67 ms). The error therefore never leaves the range 0–0.67 ms; it traces a sawtooth that repeats about every 11 minutes. A three-minute run only sees one downhill stretch of that sawtooth, which looks like drift. Programming the timer with the exact period, or with absolute deadlines, removes the effect.

Rhythmic regularity

Distribution of interval errors per system

Each box contains the middle half of all interval errors and the whiskers span 98 % of them. Note the axis: it is linear near zero and logarithmic beyond ±10 µs, otherwise the Idƴl and SuperCollider boxes would be invisible next to the others.

Idƴl and SuperCollider keep 98 % of their intervals within about ±14 to ±19 µs of the intended 10 ms, around a hundred times smaller than the few milliseconds a listener can notice. The buffered environments are between one and two hundred times less regular, with typical deviations of 1.1 to 2.4 ms and extremes near ±5 ms, whether they follow the system clock or the sound card. The reason is architectural. Csound computes its 32-sample control passes in advance, a whole 256-frame buffer (5.3 ms) at a time, and all OSC messages of a buffer leave the program together when that buffer is computed. ChucK behaves the same way with its 256-frame buffer, even though its control code runs sample by sample. Pd alternates short sleeps and bursts of 64-sample blocks. Inside their own audio output these environments place events precisely. What this measurement shows is how they behave as event schedulers for OSC, MIDI or external synthesizers.

Csound 7 reproduces Csound 6.19 almost exactly: interval errors of 1.84 and 1.82 ms σ, identical extremes, and no drift on the system clock. In this configuration, the new version neither improves nor degrades event timing.

Every time scale at once

Maximum time interval error versus window length

The MTIE plot extends the time-error picture to every passage length, from 10 ms to almost three minutes. For a drift-free system the curve is flat, and all the first-group systems are. For idyl-sys it stays at about 230 µs from the shortest to the longest window. That level is set by a single late tick (the largest time error of the run), not by accumulation. sclang-tempo stays near 125 µs and sclang near 400 µs, again because of one isolated late event. Csound 6, Csound 7 and Pd on the system clock are flat too, at 5.7 and 6.4 ms, the size of their jitter. idyl-audio starts rising for windows longer than about 20 s: that is the slow sawtooth described above, which would level off at one timer period in longer windows. The sound-card systems rise at long windows as their drift adds up, most visibly ChucK and Pd.

Allan deviation versus observation time

The Allan deviation gives the tempo-stability view. For Idƴl and SuperCollider, the curve falls steadily by a factor of ten for every tenfold longer window, over four decades. For Idƴl with --system-clock, the average tempo over 10 seconds is stable to 0.85 parts per million, and over 80 seconds to 0.12 parts per million. No slow wandering appears on top of the jitter. The buffered environments start from a much higher level because of their millisecond jitter, then fall in the same way. ChucK stops improving at the longest window, which hints that the sound card’s rate is not only offset from the system clock but also slowly varies. Remember that the steady offset itself, the drift, is invisible here and was measured above.


What these numbers do not say

This is one run of three minutes per system, on one laptop, in power-saving mode, without real-time priorities, so it is a representative snapshot rather than a best case. Longer runs, repetitions, a performance CPU governor and real-time scheduling (idyl_drift --rt) tighten the numbers. Buffer sizes were matched as described above; other buffer sizes change the jitter of the buffered environments, not their long-term drift. The measurement concerns the timing of control messages leaving each program, not audio quality. It also includes a few microseconds for the internal network, which Idƴl’s in-process benchmarks avoid. One Idƴl detail: when a temporal function is created, the interpreter evaluates it once immediately, so the first message is sent off the grid; it falls inside the discarded warm-up.

Beyond this comparison, the benchmark program idyl_drift in tests/cpp examines Idƴl in situations a performance may meet: heavy system load, thousands of simultaneous temporal functions, callbacks that take too long, tempo changes, polyrhythms, pause and resume, long runs, and complete programs going through the interpreter. Its README describes each scenario.


Reproducing

cmake -S . -B build_bench -DCMAKE_BUILD_TYPE=Release -DIDYL_BUILD_TESTS=ON
cmake --build build_bench -j --target idyl idyl_drift

# systems: Csound 6 as `csound`, Csound 7 as `csound7` (or -6 / -7 BIN),
#          sudo apt install chuck supercollider-language puredata
# PipeWire quantum matched to the 256-frame system buffer:
pw-metadata -n settings 0 clock.force-quantum 256
tests/cpp/bench/compare/run_comparison.sh -d 10 -t 180 -k 32 -b 256 -o drift-results/compare

python3 -m venv tests/cpp/bench/plot/.venv
tests/cpp/bench/plot/.venv/bin/pip install -r tests/cpp/bench/plot/requirements.txt
tests/cpp/bench/plot/plot_drift.py drift-results/compare/*/ -o figures \
    --bin 1 --center --no-band --title "Cross-system comparison"

# Idƴl's own scenarios
build_bench/tests/cpp/idyl_drift all
build_bench/tests/cpp/idyl_drift long --duration 1h

← Back to index