← Back to index · Previous: Modules & libraries
This chapter describes the internal pipeline of the Idƴl implementation. It is intended for contributors and anyone interested in how the language works under the hood.
Source (.idyl)
│
▼
┌─────────┐
│ Lexer │ Flex — tokenises input
└────┬─────┘
│ tokens
▼
┌─────────┐
│ Parser │ Bison — builds AST
└────┬─────┘
│ AST
▼
┌──────────────────┐
│ Semantic Analyzer │ 2 passes over the AST
└────┬─────────────┘
│ validated AST
▼
┌──────────────────────────────────────────┐
│ Evaluator │
│ │
│ ┌──────────┐ ┌────────────────────┐ │
│ │ Compiler │────▶│ Bytecode VM │ │
│ └──────────┘ │ (pure functions, │ │
│ │ reaction lists) │ │
│ └────────────────────┘ │
│ ┌────────────────────────────────────┐ │
│ │ AST-walking interpreter │ │
│ │ (temporal, flow, control flow, │ │
│ │ fallback for uncompiled fns) │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
│
├── Temporal instances → Scheduler (system clock)
├── Module calls → OSC / Csound / external modules
└── Process blocks → reactive execution
The lexer is defined in src/parser/idyl.l. It
recognises:
process, flow,
init, emit, catch,
import, module, on,
each, in, start,
stop, clock, tempoms,
s, hz, b, bpm),
strings, triggers (!), rest (_)|> (lambda),
:: (namespace/emit access), .. (range),
@ (deferred block), ' (delay/memory),
arithmetic, comparison, bitwise| (repeat
markers)The lexer produces tokens consumed by the Bison parser.
The parser is defined in src/parser/idyl.y. It builds an
Abstract Syntax Tree (AST) from token streams. All node
types are defined in src/parser/ast.hpp under the
node_t enum:
Literals
| Node type | Represents |
|---|---|
number_literal |
Numeric value |
time_literal |
Time value with unit (100ms, 1s,
440hz, 2b, 120bpm) |
string_literal |
Quoted string |
trigger_literal |
! trigger value |
rest_literal |
_ rest value |
Expressions
| Node type | Represents |
|---|---|
identifier |
Variable reference |
binary_op |
Binary operation (+, -, *,
/, %, bitwise, comparison) |
unary_op |
Unary negation or bitwise NOT |
ternary_op |
cond ? a; b — 2-way or multi-way selection |
memory_op |
'(expr) or '(expr, N) — sample-based
circular delay |
flow_literal |
[1, 2, 3] — simple flow literal |
generator_expr |
[x in N : expr] / [x in S..E : expr] /
[x in S..E..ST : expr] — flow generator (count, range,
range+step) |
repetition_marker |
\|N\| — repeat bar; expands the preceding element or
group N times in the physical table |
flow_member |
Named member inside flow { }:
name [on gate]: [...] |
function_call |
f(args) |
flow_access |
flow[index] — trigger, integer, or float indexing |
module_access |
lib::name — namespace access or emit accessor
instance::event |
memory_op_expr |
Wrapper expression node for memory_op |
self_stop_expr |
stop inside a temporal lambda body — terminates the
instance |
block_expr |
Anonymous block { stmt; …; expr } |
Definitions
| Node type | Represents |
|---|---|
function_definition |
f(params) = body — pure or temporal (with
\|> { } lambda) |
flow_definition |
flow name = { member: [...] } — global named-member
flow |
init_block |
init: { … } inside a temporal lambda |
lambda_block |
\|> { init: { } update-stmts } — temporal update
body |
parameter |
Single function parameter (name, optional default, trigger flag) |
argument |
Single call argument (positional or named) |
Statements
| Node type | Represents |
|---|---|
assignment |
x = expr |
expression_stmt |
Bare function call (side-effect without assignment) |
catch_block |
catch instance::event: { … } |
at_block |
@(time): { … } — one-shot deferred execution block |
on_block |
on expr: { … } — trigger-gated reaction block |
each_block |
each n in count [, dt=time]: { … } — counted loop |
stop_statement |
stop [name] — stop current or named process |
start_statement |
start name — start a named process |
process_block |
process [name] [, dur=…]: { … } |
library_import |
import("path") |
module_import |
module("name") |
The semantic analyzer (src/semantic/analyzer.cpp) runs
2 passes over the AST before any code executes.
global_scope_passA single forward scan of all top-level statements. In order:
import("path") calls
are resolved, the file is loaded and parsed, circular imports are
detected, and library symbols are registered (namespaced if the import
is named).module("name")
directives are collected and validated against known built-in module
names.resolveA recursive descent over every node in the AST. For each node type it performs the checks relevant to that construct:
dt= or trigger parameter; stop may only
appear inside a lambda update body; emit names are
recorded.on) reference a member that appears before
them.catch,
each, on, and @ blocks are
validated for correct nesting and consistent emit/catch pairing.Both passes use the scope system
(src/semantic/scope.hpp) and symbol table
(src/semantic/symbol.hpp). All diagnostics are collected
and reported before evaluation begins — no code runs if the program
contains errors.
The evaluator (src/core/evaluator.cpp) is a
tree-walking interpreter. It traverses the AST and
executes nodes directly.
| Component | File | Purpose |
|---|---|---|
Environment |
src/core/environment.hpp |
Runtime scope chain for variable lookup |
function_defs_ |
src/core/evaluator.hpp |
Map of interned name ID → AST function definition |
fn_library_scope_ |
src/core/evaluator.hpp |
Map of qualified name → library-local scope (for namespaced imports) |
instances_ |
src/core/evaluator.hpp |
Map of instance ID → live function_instance (temporal
functions) |
live_processes_ |
src/core/evaluator.hpp |
Map of process name → live_process (segments,
reactions, catch blocks) |
stored_processes_ |
src/core/evaluator.hpp |
Map of process name → AST node (for listen mode) |
flow_cache_ |
src/core/evaluator.hpp |
Cache of (flow name, args) → built flow value (for parametric flows) |
Scheduler |
src/time/scheduler.hpp |
Drift-free timer management |
ClockRegistry |
src/core/core.hpp |
Clock hierarchy and tempo propagation |
ModuleRegistry |
src/include/module.hpp |
Lazy-loaded module catalog and handles |
vm_ |
src/vm/vm.hpp |
Bytecode VM for compiled pure functions and reaction lists |
At runtime, the environment holds a stack of scope
frames (std::vector<scope_frame>). Each
frame is a flat name → value map. Lookup walks the stack from innermost
(top) to outermost (bottom).
┌─────────────────────────────┐ ← innermost (current function / tick)
│ phase = 0.0 │
│ out = 0.309 │
├─────────────────────────────┤
│ [library-local scope] │ ← pushed for namespaced library functions
│ sine_shape → std::sine_shape│
│ lfo → std::lfo │
│ ... │
├─────────────────────────────┤
│ [process block scope] │
│ l = <temporal instance> │
├─────────────────────────────┤
│ [global scope] │ ← outermost, always present
│ pi, tau, euler, ... │
│ std::sine, std::lfo, ... │
└─────────────────────────────┘
Built-in math functions and module functions are not in the scope
stack — they are looked up via separate indices
(builtin_index_ and
module_registry_->lookup()) only after the scope chain
is exhausted.
| Event | Scope pushed | Scope popped |
|---|---|---|
| Process block execution | once at entry | when block ends (or never, if temporal) |
| Pure function call | once for parameters | when call returns |
| Temporal init block | once for parameters | after init statements run |
| Temporal first-pass | once for params + init state | after output expression |
Scheduler tick (tick_instance) |
once for params + current state | after output expression |
| Namespaced library function (any of the above) | also one extra scope for library-local names | paired with the one above |
When a library is loaded under a namespace
(e.g. std = import("stdlib")), two things happen:
Global registration: every function is stored in
function_defs_ under its qualified name
("std::lfo", "std::sine", …). Bare names are
never added to the global scope.
Library-local scope construction: a shared
lib_scope map is built, containing every original
(unqualified) name in the library mapped to a function_ref
pointing at its qualified counterpart:
lib_scope = {
"lfo" → function_ref("std::lfo"),
"sine_shape" → function_ref("std::sine_shape"),
"tri_shape" → function_ref("std::tri_shape"),
...
}
This scope is stored in fn_library_scope_ keyed by the
qualified name, and attached to each function_instance when
a temporal function from that library is instantiated.
Injection at call time: before evaluating a
namespaced function’s body or tick, the library-local scope is pushed as
an extra frame. The function body (e.g. lfo’s update block
calling sine_shape) finds "sine_shape" in that
frame, gets back function_ref("std::sine_shape"), and the
call resolves correctly. The frame is popped immediately after.
This design means: - Internal library cross-calls work without qualification. - Bare names never appear in the caller’s global scope. - Two libraries loaded under different namespaces can have overlapping internal names without conflict.
function_defs_ and the global scope.dt interval.When the evaluator encounters a temporal function call
(e.g. std::lfo(1hz, dt=50ms)):
function_definition and its
qualified key in function_defs_.function_instance. Set def_name_
to the qualified key (e.g. "std::lfo") so the process-block
subscription lookup finds the right definition.library_scope_ from
fn_library_scope_["std::lfo"] (if present).dt_ms.init block in a temporary scope (with
library-local scope if present) — initialise current_
state.init. This seeds the output
(e.g. out = sin(0) = 0) without overwriting init
values.instances_. Return its initial
output.The process block then subscribes the instance to the scheduler. On
each tick, tick_instance is called, which:
next_.next_ → current_ (double-buffer
swap).The scheduler is drift-free: it tracks elapsed time against the system clock rather than accumulating intervals. This prevents timing drift over long runs.
Each tick:
tick_instance, re-evaluate the bound
variable expression, then run any downstream reactions.dt.The scheduler runs in a tight loop with a configurable sleep granularity to balance CPU usage against timing precision.
The bytecode VM (src/vm/) accelerates execution of pure
functions and process-block reaction lists. It sits alongside the
AST-walking evaluator: the evaluator attempts to compile eligible code
at definition time and dispatches to the VM at call time, falling back
to AST-walking for anything the compiler does not support.
| File | Purpose |
|---|---|
src/vm/instruction.hpp |
opcode enum and instruction struct |
src/vm/chunk.hpp |
bytecode_fn — one compiled unit (instruction array +
constant pool) |
src/vm/compiler.hpp/cpp |
AST → bytecode compiler |
src/vm/vm.hpp/cpp |
Stack-based VM execution engine |
Each instruction is a fixed-width 8-byte struct:
┌────────┬────────┬────────┬──────────────────────┐
│ op │ a │ b │ c │
│ uint8 │ uint16 │ uint16 │ int32 │
└────────┴────────┴────────┴──────────────────────┘
opcode slot / builtin fn_id (CALL) /
argc / idx jump offset (JUMP) /
const- reserved
idx
| Category | Opcodes |
|---|---|
| Loads / stores | LOAD_CONST, LOAD_LOCAL,
STORE_LOCAL, LOAD_NIL, POP,
LOAD_GLOBAL, STORE_GLOBAL |
| Arithmetic | ADD, SUB, MUL,
DIV, MOD |
| Unary | NEG, NOT_OP |
| Comparison | LT, LE, GT, GE,
EQ, NE |
| Logical | AND, OR |
| String | CONCAT |
| Flow | FLOW_INDEX — pop index and flow, push the element at
the wrapped physical slot |
| Control | JUMP (unconditional, relative offset),
JUMP_IF_FALSY (pop condition, branch) |
| Calls | CALL (compiled user function by fn_id),
CALL_NATIVE (built-in by index) |
| Return | RETURN |
What gets compiled:
function_definition with no lambda block, no named
arguments, supported expression types) — compiled at the moment the
function is registered, before any process block runs.on blocks that fire on every temporal tick. Variables are
accessed via LOAD_GLOBAL/STORE_GLOBAL because
reactions operate on the live process environment.What falls back to AST-walking:
init, state
update)each loops, @ deferred blocks,
catch blocks, start/stopevaluator::register_function(def)
└── try_compile(def, fn_id) ← compiler returns bytecode_fn or nullptr
└── vm_.store(fn_id, chunk)
evaluator::eval_user_function(key, args)
├── if vm_.has_compiled(fn_id)
│ └── vm_.run(fn_id, args) ← fast path
└── else
└── ast_walk(def, args) ← fallback
scheduler tick → reaction fires
├── if rxn.compiled_reactions
│ └── vm_.run_reactions(chunk) ← fast path
└── else
└── ast_walk(reactions) ← fallback
On hot-reload, affected function chunks and reaction chunks are recompiled automatically before the next tick.
The --trace flag enables diagnostic output during
evaluation:
idyl program.idyl --traceThis prints: - Each scheduler tick with timestamps - Temporal function evaluations and their outputs - OSC messages sent - Clock tempo changes
Useful for debugging timing issues and understanding program behavior.
Idƴl draws inspiration from several domains:
| System | Shared concept |
|---|---|
| Csound | The most fully featured musical programming language |
| Faust | Functional signal processing |
| Cordelia | Live coding tenderness |
| Lua | Flexible and elegant general purpose language |
| Antescofo | Timed, reactive programming language |
| ChucK | Strongly-timed audio programming |