Idƴl Documentation

4 — Time & temporal functions

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


The |> operator

The 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)


Clock-driven temporal functions

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 example

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

Smooth control

smooth(input, factor, dt=10ms) = smoothed |> {
    init: { state = 0 }
    smoothed = state
    state = state + (input - state) * factor
}

Trigger-driven temporal functions

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.


Hybrid: trigger + 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.


The init block

The 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
}

The age variable

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


State variables

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

Bare expression calls in lambda blocks

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.


Instantiation

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.


Reactive chains

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.


Delay operator — '

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

Syntax

Form Meaning
'(expr) One-sample delay — returns the previous tick’s value
'(expr, N) N-sample delay — returns the value from N ticks ago

Semantics

Example: differentiate a signal

velocity(x, dt=50ms) = dx |> {
    dx = x - '(x)    // rate of change per tick
}

Inside temporal functions

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

Self-termination with stop

A temporal function can end itself from within its update block by using the stop keyword. When stop executes, the function:

  1. Stops evaluating further update statements for this tick.
  2. Evaluates its output expression one final time (freezing the output value).
  3. Deactivates the instance — no further ticks will fire.
  4. Fires any catch instance::end handlers in the surrounding process block.
  5. Unsubscribes from the scheduler.

After stopping, the binding variable in the process body holds the frozen last output value as a constant.

Bare stop

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

Conditional stop using ternary

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

Parser note: ternary condition and parentheses

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 ? _ ; stop

When the condition is a comparison, logical expression, or identifier without outer parentheses, it always parses correctly.

stop as an expression

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



Built-in temporal primitives

These are native temporal functions provided directly by the evaluator. They do not require import("stdlib").


sync — phase-locked periodic trigger

sync(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 grid
process: {
    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")
}

phasor — clock-synchronized ramp

phasor(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 clock
process: {
    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)

Summary

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

Built-in temporal primitives at a glance

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")).


Next: Flows →