← Back to index · Previous: Functions
Temporal functions are the heart of Idƴl. They carry state across time, driven by a clock or by trigger events. Where a pure function maps input to output once, a temporal function evolves.
|> operatorThe pipe-arrow |> introduces a lambda
block — a stateful body that executes repeatedly on each tick.
The expression before |> is the output
variable — the value the function yields on each tick.
name(params, dt=interval) = output_var |> {
init: { ... }
// update statements
}A temporal function must have at least one time source: - A
dt= parameter (clock-driven), or - A trigger parameter with
! suffix (event-driven), or - Both (hybrid)
The dt parameter sets the tick interval. The function
updates at that rate.
// Counts up by 1 every 10ms
clock_counter(dt=10ms) = n |> {
init: { n = 0 }
n = n + 1
}lfo(freq, amplitude, dt=10ms) = modulation |> {
init: { phase = 0 modulation = 0 }
modulation = amplitude * sin(phase * 2 * pi)
phase = fmod(phase + 1 / (freq * (dt / 1000)), 1)
}init: runs once at instantiation. It sets initial
state.modulation) is what the caller
sees.smooth(input, factor, dt=10ms) = smoothed |> {
init: { state = 0 }
smoothed = state
state = state + (input - state) * factor
}Instead of a clock, a trigger parameter (suffixed with
!) fires the function when the trigger event occurs.
// Increments on each trigger pulse
trigger_counter(spike!) = count |> {
init: { count = 0 }
count = count + 1
}
// Captures input when trigger fires
sample_hold(input, capture!) = held |> {
init: { held = 0 }
held = input
}The function body executes once per trigger event, not on a periodic clock.
A function can accept both dt and trigger parameters. It
updates on both:
// Trigger sets to 1, clock decays over time
decay_on_trigger(trig!, decay_rate, dt=50ms) = level |> {
init: { level = 0 }
level = (0; 1 ? trig) + level * (1 - decay_rate)
}Use the ternary operator to distinguish which event fired.
init blockThe init: block runs once when the
function is instantiated. It sets initial state.
counter(dt=100ms) = n |> {
init: { n = 0 }
n = n + 1
}Rules: - Variables defined in init persist across ticks
(they are the function’s state). - init is optional.
Without it, the first update runs immediately (no dt delay). - With
init, the function’s first output is the init value; the
first update runs after one dt. - Bare expression calls
(without assignment) are valid inside init, useful for
setup side effects:
synth(freq, dt=10ms) = level |> {
init: {
level = 0
print("synth started at freq:", freq) // runs once at instantiation
}
level = level + 0.01
}age variableInside any lambda block, age is a read-only variable
that holds the elapsed time in milliseconds since the instance was
created.
ramp(dt=50ms) = age // output grows by dt every tick: 0, 50, 100, …
oneshot_guard(limit_ms, dt=10ms) = x
|> {
init: { x = 0 }
x = x + 1
age >= limit_ms ? _ ; stop // stop after limit_ms milliseconds
}age starts at 0 and increases by
dt on every tick. It is always in milliseconds, regardless
of the dt unit used in the function signature.
Variables inside the lambda block are local state. They persist across ticks but are not visible outside the function — unless emitted (see Chapter 7).
sawtooth(freq, dt=10ms) = phase |> {
init: { phase = 0 }
phase = fmod(phase + 1 / (freq * (dt / 1000ms)), 1)
}phase is updated every tick. The caller sees only the
output (phase in this case, since it is the output
variable).
Both the init block and the update body accept
bare expression calls — function calls not bound to a
variable. These run for their side effects (logging, calling external
module functions, etc.).
step_logger(dt=200ms) = n |> {
init: {
n = 0
print("starting") // called once at instantiation
}
print("step:", n) // called on every tick
n = n + 1
}The call executes in source order relative to the surrounding
assignments. Bare calls inside init run during
instantiation; bare calls in the update body run on every tick.
Temporal functions are instantiated when called. Each call creates an independent instance with its own state:
process: {
slow = lfo(0.2hz, 1.0, dt=10ms) // instance 1
fast = lfo(1.5hz, 1.0, dt=10ms) // instance 2
combined = slow + fast // two independent LFOs
}The two LFOs have separate phase state — they don’t
interfere.
Inside a process block, statements that follow a temporal binding are reactions — they re-execute on every tick of the temporal source:
process: {
osc = lfo(5hz, 1.0, dt=10ms)
modulated = 440hz * (1 + osc * 0.1) // re-evaluated every 10ms
print("freq:", modulated) // prints every 10ms
}The print call is not a one-shot — it runs every time
osc ticks.
'The prime operator ' introduces a sample
delay: it returns the value of an expression from a previous
tick rather than the current one. This is useful for feedback,
differencing, and basic memory.
process: {
a = counter(dt=300ms)
b = '(a) // one-sample delay: value of a from the previous tick
c = '(a, 3) // three-sample delay: value of a from three ticks ago
print(a, b, c)
}| Form | Meaning |
|---|---|
'(expr) |
One-sample delay — returns the previous tick’s value |
'(expr, N) |
N-sample delay — returns the value from N ticks ago |
0). For most
counters starting at 0 this makes no practical difference,
but for arbitrary expressions the initial output is the expression’s
first value repeated N times.dt=100ms function returns the value
from 200ms ago.velocity(x, dt=50ms) = dx |> {
dx = x - '(x) // rate of change per tick
}The delay operator is legal anywhere an expression is valid — inside lambda blocks, reactions, or the process body itself. When used inside a lambda block, the buffer is tied to that specific expression position.
envelope(attack, dt=10ms) = level |> {
init: { level = 0 target = 1 }
level = level + (target - level) * attack
// detect when movement slows (approaching target)
delta = abs(level - '(level))
}stopA temporal function can end itself from within its update block by
using the stop keyword. When stop executes,
the function:
catch instance::end handlers in the
surrounding process block.After stopping, the binding variable in the process body holds the frozen last output value as a constant.
stopPlace stop alone as an update statement to terminate
unconditionally on the first tick:
oneshot(dt=200ms) = age
|> {
stop // fires once, then the instance dies
}
process: {
v = oneshot()
print("fired at age:", v)
catch v::end: { print("done, final value:", v) }
}Use the ternary operator to stop when a condition is met:
counter(limit, dt=200ms) = x
|> {
init: { x = 0 }
x = x + 1
x >= limit ? _ ; stop // stop when x reaches limit
}
process: {
v = counter(3)
print("count:", v)
catch v::end: { print("stopped at:", v) }
}
// Output:
// count: 0
// count: 1
// count: 2
// stopped: 3
// (v is now frozen at 3)Here, x >= limit ? _ ; stop means: - If
x >= limit is 0 (false) → select _
(rest/passthrough), continue as normal - If x >= limit
is 1 (true) → select stop, self-terminate
In lambda block update statements, avoid wrapping the ternary
condition in parentheses when it follows a statement that ends with a
bare literal or identifier. The parser can interpret the (
as the start of a function call on the preceding expression rather than
a new statement:
// ✗ May be misparse — '(x >= 3)' consumed as function call on preceding '1'
x = x + 1
(x >= 3) ? _ ; stop
// ✓ Correct — condition does not start with '('
x = x + 1
x >= 3 ? _ ; stopWhen the condition is a comparison, logical expression, or identifier without outer parentheses, it always parses correctly.
stop as an expressionstop can appear anywhere an expression is valid,
including inside ternary options as shown above. When evaluated, it
immediately throws an internal stop signal. This means:
x >= 3 ? _ ; stop // conditional stop
stop // unconditional stop (as a statement)Both forms work. The stop expression form is most useful
inside ternary options. For unconditional termination, the bare
statement form is clearest.
These are native temporal functions provided directly by the
evaluator. They do not require import("stdlib").
sync —
phase-locked periodic triggersync(period) fires a trigger on a global beat grid. Two
processes calling sync with the same period will always
fire on the same beat boundaries, regardless of when each process
started.
// Syntax
s = sync(period_ms) // uses main clock grid
s = sync(clk, clk(period)) // uses a specific clock's gridprocess: {
tempo(120bpm)
kick = sync(4b) // fires every 4 beats, phase-locked to main clock
hat = sync(0.5b) // fires every half beat
on kick: print("kick")
on hat: print("hat")
}sync(4b) always align — even if one process
starts later.metro(dt=4b):
metro fires dt after the process starts, so
different start times drift apart. sync always locks to the
global grid.sync(c, c(4b)).phasor —
clock-synchronized rampphasor(period) outputs a rising ramp from 0 to 1,
cycling every period. Unlike free_phasor
(stdlib), it is anchored to the global clock: two
phasors with the same period and clock always produce identical phase
values, regardless of when they were created.
// Syntax
ph = phasor(period_ms) // main clock, updates every 10ms
ph = phasor(period_ms, dt=Xms) // explicit update interval
ph = phasor(clk, period_ms) // specific clockprocess: {
tempo(120bpm)
ph = phasor(4b) // ramp 0→1 over 4 beats, then wraps
print("phase:", ph) // prints ~0.05/tick at 120bpm, dt=10ms
}The phase_in parameter of stdlib oscillators
(sine, lfo, euclid, etc.) accepts
phasor output directly:
import("stdlib")
process: {
tempo(120bpm)
ph = phasor(4b) // shared phase source
s = sine(1hz, amplitude=0.8, phase_in=ph)
e = euclid(3, 8, phase_in=ph) // rhythm locked to same phase
on e: print("hit, sine:", s)
}Named vs inline: you can store the phasor in a
variable (ph = phasor(4b)) or inline it directly into a
function argument (euclid(3, 8, phase_in=phasor(4b))). Both
work; the named form lets you share the same phase across multiple
functions. Hot-reload updates the phasor’s period in both forms without
restarting.
| Parameter | Description |
|---|---|
period_ms |
Full-cycle duration (any time literal: 4b,
2000ms, etc.) |
dt=10ms |
Update interval (optional; default 10ms) |
clk |
Clock handle (optional first arg; default: main clock) |
| Aspect | Clock-driven | Trigger-driven | Hybrid |
|---|---|---|---|
| Time source | dt=interval |
param! |
Both |
| Update rate | Periodic | On event | Both |
| Use case | LFOs, counters, smoothers | Counters, sample-hold | Envelopes, duckers |
| Primitive | Output | Description |
|---|---|---|
sync(period) |
trigger | Phase-locked periodic trigger, grid-snapped |
phasor(period) |
number 0–1 | Clock-synchronized rising ramp |
See also metro, euclid,
free_phasor and the full oscillator suite in the standard
library (import("stdlib")).