← Back to index · Previous: Emit & catch
Idƴl has a built-in clock hierarchy. A main clock is created at startup, child clocks can be created and bound to parents, and tempo changes propagate through the tree.
When a program starts, a main clock is created at
120 BPM. All beat-relative time literals (4b,
2b) resolve against this clock.
Query the main clock’s tempo:
process: {
print("tempo:", tempo()) // 120
}Set it:
process: {
tempo(140bpm)
print("tempo:", tempo()) // 140
}bpm literalThe bpm suffix converts beats-per-minute to a period in
milliseconds:
period = 120bpm // 500ms (60000 / 120)
period = 60bpm // 1000ms
period = 240bpm // 250msThis is useful as a dt parameter:
beat_counter(dt=120bpm) = n |> {
init: { n = 0 }
n = n + 1
}Use clock(bpm) to create a child clock bound to the main
clock:
process: {
c1 = clock(60bpm)
c2 = clock(90bpm)
print("c1:", tempo(c1), "c2:", tempo(c2))
// c1: 60 c2: 90
}c1 = clock(60bpm)
c3 = clock(30bpm, parent=c1) // c3 is child of c1, not mainA clock with parent=0 is free-running — unaffected by
any tempo changes:
c_free = clock(100bpm, parent=0)When a parent’s BPM changes, children scale proportionally. The ratio is captured at creation time.
process: {
c1 = clock(60bpm) // ratio to main = 60/120 = 0.5
c2 = clock(90bpm) // ratio to main = 90/120 = 0.75
tempo(240bpm) // main → 240
print("c1:", tempo(c1)) // 240 * 0.5 = 120
print("c2:", tempo(c2)) // 240 * 0.75 = 180
}process: {
c1 = clock(60bpm) // child of main, ratio 0.5
c3 = clock(30bpm, parent=c1) // child of c1, ratio 30/60 = 0.5
tempo(240bpm) // main → 240
// c1 → 120 (0.5 × 240)
// c3 → 60 (0.5 × 120)
print("c3:", tempo(c3)) // 60
}tempo(c1, 80bpm) // set c1 to 80, propagate to c1's childrenA clock handle is also callable. Calling it with a beat count returns the duration of that many beats at the clock’s current BPM:
process: {
c1 = clock(60bpm)
// c1(2b) → duration of 2 beats at 60 BPM = 2000ms
m = metro(c1(2b)) // fires every 2 beats of c1
m2 = metro(c1(0.5b)) // fires every half-beat of c1
m3 = metro(c1(500ms)) // plain ms pass-through
m4 = metro(c1()) // 1 beat (default)
print(m, m2)
}This is the primary way to drive temporal functions from a specific
clock: the beat literal 2b is resolved against that
clock’s BPM, not the main clock.
beat() — main clock
beat durationFor user-defined clocks, the handle-callable c(2b) is
the natural way to get a beat duration. For the main
clock, beat() provides the same without an
intermediate variable.
process: {
tempo(120bpm) // 1 beat = 500ms
print(beat()) // 500 — 1 beat
print(beat(2)) // 1000 — 2 beats
print(beat(0.5)) // 250 — half a beat
m = metro(dt=beat(0.25)) // fires every quarter-beat
}The beat() function is especially useful when you need a
beat count as a dt value and don’t want to compute
60000 / tempo() manually.
beat() also accepts a clock handle, making it equivalent
to the handle-callable syntax:
process: {
c = clock(60bpm) // 1 beat at c = 1000ms
print(beat(c)) // 1000 — equivalent to c()
print(beat(c, 2)) // 2000 — equivalent to c(2b)
print(beat(2, c)) // 2000 — alternative argument order
}| Call | Result |
|---|---|
beat() |
1 beat at main clock (ms) |
beat(n) |
n beats at main clock (ms), n is a raw beat count |
beat(clk) |
1 beat at clock clk (ms) |
beat(clk, n) |
n beats at clock clk (ms) |
beat(n, clk) |
Same, alternative arg order |
| Function | Description |
|---|---|
clock(bpm) |
Create clock bound to main |
clock(bpm, parent=h) |
Create clock bound to parent h |
clock(bpm, parent=0) |
Create free-running clock |
clock() |
Return the main clock handle |
tempo() |
Query main clock BPM |
tempo(handle) |
Query a specific clock’s BPM |
tempo(bpm) |
Set main clock BPM (propagates) |
tempo(handle, bpm) |
Set specific clock BPM (propagates) |
bpm() |
Alias for tempo() — query main clock BPM |
bpm(handle) |
Alias for tempo(handle) — query a clock’s BPM |
beat() |
1 beat at main clock (ms) |
beat(n) |
n beats at main clock (ms) |
beat(clk, n) |
n beats at clock clk (ms) |
handle(Nb) |
Duration of N beats at that clock’s BPM |
handle(ms) |
Pass-through: returns the ms value unchanged |
handle() |
Duration of 1 beat at that clock’s BPM |
All clocks return handles — opaque values that identify the clock in the registry.
The clock system is designed for musical contexts where tempo is hierarchical. A conductor (main clock) sets the global pulse, and individual instruments (child clocks) may run at different subdivisions. When the conductor changes tempo, everyone follows proportionally.
Free-running clocks (parent=0) are for things that
should never change speed — metronomes locked to an external source, or
clocks driven by hardware.
All temporal functions — counters, metros, LFOs, process durations — are ultimately fired by a scheduler that tracks when each subscription is due. Idƴl provides two schedulers selectable at runtime.
The default scheduler (sys_clock) uses
std::chrono::steady_clock — the highest-resolution steady
clock the OS exposes. It uses a hybrid sleep + busy-wait strategy: it
sleeps until ~500 µs before the target time, then busy-waits to the
exact deadline.
idyl file.idyl # system-clock scheduler (default)Characteristics: - Typical jitter: ~0.5 ms; ~1–2 ms under heavy system load - No extra resources (no timer device, no audio device) - Each subscription has its own scheduled event in a priority queue, so CPU scales with the number of active temporal functions
Best for: general scripting, OSC control, MIDI, any work where sub-millisecond accuracy is not critical.
The --audio-clock flag activates a polling scheduler
driven by an OS high-resolution timer. It wakes at a fixed period
(buffer_size / sample_rate seconds), checks all active
subscriptions against the current time, and fires any that are due.
idyl file.idyl --audio-clock
idyl file.idyl --audio-clock --audio-buffer-size 32 --audio-sample-rate 48000| Flag | Short | Default | Description |
|---|---|---|---|
--audio-clock |
-ac |
— | Enable the audio-clock scheduler |
--audio-buffer-size <n> |
-abs <n> |
32 |
Frames per tick (lower = more frequent checks) |
--audio-sample-rate <hz> |
-asr <hz> |
48000 |
Sample rate used to derive the tick period |
Important: this scheduler does not open any audio device. It uses the OS timer API directly, so it never conflicts with Csound, Jack, PipeWire, or any other audio engine running in the same process.
The audio-clock scheduler’s timing accuracy has two independent components that should be understood separately.
This is the intrinsic precision of the platform timer. It is
not affected by --audio-buffer-size.
| Platform | Mechanism | Typical wakeup jitter |
|---|---|---|
| Linux | timerfd_create(CLOCK_MONOTONIC) |
~0.1 ms |
| macOS | mach_wait_until |
~0.01–0.1 ms |
| Windows 10 1803+ | CreateWaitableTimerEx (high-res) |
~0.5 ms |
| Windows (older) | timeBeginPeriod(1) + waitable timer |
~1 ms |
| Other | std::this_thread::sleep_until |
~1–5 ms |
The scheduler only checks subscriptions once per timer wakeup. A subscription whose deadline falls between two wakeups is held until the next check. The maximum additional latency per subscription is exactly one scan period:
scan_period = buffer_size / sample_rate
--audio-buffer-size |
Scan period at 48 kHz | Max subscription latency |
|---|---|---|
| 32 (default) | 0.67 ms | 0.67 ms |
| 64 | 1.33 ms | 1.33 ms |
| 128 | 2.67 ms | 2.67 ms |
| 256 | 5.33 ms | 5.33 ms |
| 512 | 10.67 ms | 10.67 ms |
Because now_ms() always reads the actual steady clock at
the moment subscriptions are checked, there is no accumulated
drift — only a one-shot latency bounded by one scan period.
Total worst-case latency = OS timer jitter + scan
granularity.
On Linux at the default buffer_size=32: ~0.1 + 0.67 ≈
0.77 ms.
| Situation | Recommended mode |
|---|---|
| OSC, MIDI, general scripting | sys_clock (default) |
| Csound note scheduling, tight sequencing | --audio-clock |
| Sub-millisecond accuracy required | --audio-clock --audio-buffer-size 32 |
| Lower CPU, accuracy ≥ 3 ms acceptable | --audio-clock --audio-buffer-size 128 |
| Coarse scheduling, accuracy ≥ 10 ms | --audio-clock --audio-buffer-size 512 |
Both modes produce drift-free scheduling: each
subscription’s next deadline is computed from its previous deadline
(next = prev + dt), never from the current time. Over long
runs, the timing does not drift.
Tight audio work (Csound, precise sequencing):
idyl file.idyl --audio-clock --audio-buffer-size 32Default settings. Scan period ≈ 0.67 ms. Works reliably on all supported platforms.
Reduced CPU at the cost of some latency:
idyl file.idyl --audio-clock --audio-buffer-size 128Scan period ≈ 2.67 ms. The scheduler wakes up ~4× less often. Good for systems under load or when battery matters.
Non-standard sample rate:
idyl file.idyl --audio-clock --audio-sample-rate 44100 --audio-buffer-size 32Scan period = 32 / 44100 ≈ 0.73 ms. The rate parameter is used only to compute the scan period — no audio device is opened at any sample rate.
Diagnosing timing issues: run without
--audio-clock first to establish a baseline with
sys_clock. If the two modes differ significantly in tempo
or regularity, check whether another process is contending for the
CPU.