← 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.
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.
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.
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 |
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.
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.
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.
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.
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.
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.
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.
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 |
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.
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 |
| 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.
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.
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.
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.
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.
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.
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.
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