← 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
}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))
}| 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 |