rill-lang 0.6.0-M2

rill-lang — a Faust-style functional streaming DSL compiled to rill Algorithm<T>
Documentation

rill-lang

A Faust-style functional streaming DSL that compiles to a rill_core::Algorithm. Programs describe the internal mathematical structure of a signal-graph node as a compact block diagram; rill-lang compiles that source at runtime into a value you can drop straight into the rill graph.

The first backend is a safe, allocation-free interpreter. A Cranelift JIT backend is planned behind a future jit feature; both share the same linear IR, so the language front-end is unaffected when the JIT lands.

Example

use rill_lang::compile;
use rill_core::traits::Algorithm;

// A half-gain: y[n] = x[n] * 0.5
let mut prog = compile::<f32>("main = _ * 0.5").unwrap();

let mut out = [0.0f32; 4];
prog.process(Some(&[1.0, 2.0, 4.0, 8.0]), &mut out).unwrap();
assert_eq!(out, [0.5, 1.0, 2.0, 4.0]);

Three entry points:

  • compile(src) — no built-ins, no sample rate. Pure block-diagram math.
  • compile_with(src, &registry, sample_rate) — with a built-in registry for stateful DSP (filters, oscillators, effects).
  • compile_graph(src, &registry, sample_rate) — compiles into a CompiledGraphEngine with actor mailbox support for SetParameter commands.

The language in one screen

A program is a list of definitions ending in ;. One must be named main. The entry point can be SISO (1→1) or multi-IO (N→M) when the router feature is enabled, supporting multi-channel nodes like mixers and EQs.

gain x = x * 0.5;            // a one-argument function (juxtaposed args — no parens)
main    = gain _;             // apply it to the input wire

Application syntax

Function calls use bracket-free juxtaposition — space-separated arguments, no parentheses:

main = _ : lowpass 1000.0 0.7;   // juxtaposed
main = lowpass _ 1000.0 0.7;     // signal as first-class argument

Parenthesized form name(arg, ...) is also supported (for compatibility), but juxtaposed is canonical.

Records

Built-ins are configured with record literals { key: val }:

main = mixer _1 _2 { channels: 3, gain: 0.8 };
main = eq_parametric _ { bands: [{ freq: 1000.0, q: 0.7, gain_db: 3.0 }] };

Records can be nested — the EQ bands field is a list of { freq, q, gain_db, band_type } records.

Actor parameters (?name=default)

Late-binding parameter slots resolved at runtime via the engine mailbox:

main = _ : lowpass ?cutoff=1000.0 ?resonance=0.7;

The ?name=default syntax creates a named parameter slot that receives SetParameter commands from the actor system. Names are stable — servos, LFOs, and MIDI maps target them directly. Within where blocks, dot-notation namespacing applies: ?osc.freq=440.0.

Multi-IO

Multi-channel programs implement MultichannelAlgorithm<T> when compiled with the router feature:

main = mixer _1 _2 { channels: 2, buses: 0 };  // 2→2
main = dry_wet _ _effected { mix: 0.5 };        // 2→1 (interleaved)

The graph engine dispatches to MultichannelAlgorithm::process() for multi-IO nodes; SISO programs (0 or 1 input → 1 output) use the single-channel fast path.

Primitives

Syntax Meaning Arity
_ identity wire 1 → 1
! cut (discard a wire) 1 → 0
3, 3.5 int / float literal 0 → 1
+ - * / % arithmetic (as a block) 2 → 1
sin cos tan sqrt exp ln tanh abs math builtins 1 → 1
min max selection 2 → 1

Complex arithmetic (builtins, always available):

Syntax Meaning Channels
complex(re, im) complex constant generator 0 → 2
conj x conjugate: re + i·im → re − i·im 2 → 2
re x, im x real / imaginary part 2 → 1
norm x magnitude: √(re² + im²) 2 → 1
arg x phase: atan2(im, re) 2 → 1
cmul a b complex multiply 4 → 2
cadd a b complex add 4 → 2

Complex signals are pairs of wires (re + im). Use the parallel combinator , to combine two complex sources into a 4-wire input for cmul/cadd:

main = complex 3.0 4.0 , complex 2.0 0.0 : cmul () : re ();  // → 6.0
main = complex 1.0 2.0 , complex 3.0 4.0 : cadd () : norm (); // → ≈7.21

Combinators (block-diagram algebra)

Operator Name Constraint Result arity
A : B sequential out(A) = in(B) (in A, out B)
A , B parallel (in A + in B, out A + out B)
A <: B split / fan-out in(B) multiple of out(A) (in A, out B)
A :> B merge / fan-in (sums) out(A) multiple of in(B) (in A, out B)
A ~ B feedback (1-sample delay) in(B) ≤ out(A), out(B) ≤ in(A) (in A − out B, out A)
A @ n integer delay (n const) A is _ → 1 same as A

Precedence, loosest → tightest: ~ < : < :> < <: < , < + - < * / % < @ < unary - < atoms.

Idioms

main = + ~ _;              // integrator:         y[n] = x[n] + y[n-1]
main = + ~ (_ * 0.5);      // leaky integrator:    y[n] = x[n] + 0.5·y[n-1]
main = _ @ 1;              // one-sample delay
main = _ <: (_ , _) :> +;  // fan-out then sum = 2·x

Built-in functions

rill-lang supports calling stateful DSP/model built-ins from rill-core-dsp/rill-core-model/rill-fft via compile_with(src, &registry, sample_rate).

Category Builtins Feature
Filters onepole, moog (sample), lowpass, highpass, biquad (block) always
Oscillators sine, saw, square, triangle, noise (block) always
Effects delay, distortion, limiter (block) always
Mixer/EQ mixer, eq_parametric, dry_wet, graphic_eq (block) router
Analog analog_moog, cassettedeck, tape_bridge (block) analog
Spectral spectralgate, spectraldelay, convolver (block) fft
Complex complex, conj, re, im, norm, arg, cmul, cadd always
Sampler sampler (block) sampler
Lofi lofi, ay38910 (block) lofi

Built-ins use unified argument syntax: signals are first-class arguments passed by juxtaposition (e.g. lowpass _ 1000.0 0.7). Some built-ins accept variadic signal inputs — mixer takes any number of signals followed by a record:

main = mixer _ ch2 ch3 ch4 { channels: 4, buses: 2 };
main = dry_wet _ wet { mix: 0.7 };
main = eq_parametric _ { bands: [{ freq: 500.0, q: 2.0, gain_db: -3.0 }] };

Per-sample built-ins (onepole, moog) are feedback-legal; whole-buffer built-ins (lowpass, highpass, etc.) are opaque block steps and cannot appear inside ~. Bindings and registries live in rill-adrift (lang_builtins::full_registry), with per-crate register_lang_builtins() functions for selective registration.

Two parameter models

rill-lang supports two parameter mechanisms:

?name=default — actor parameters (canonical)

Late-binding slots resolved at runtime via the engine mailbox. When compiled with compile_graph(), each ?name=default becomes a named parameter addressable by SetParameter commands:

main = _ : lowpass ?cutoff=1000.0 ?resonance=0.7;

Where-block definitions create namespaced parameters with dot notation:

main = osc : filt where
    osc  = sine ?freq=440.0 0.5 0.0
    filt = _ : lowpass ?cutoff=1200.0 0.7
-- Exposes: "osc.freq", "filt.cutoff"

Parameters are addressed by "anchor.param" format (e.g. "osc.freq") — stable names across sessions, targetable by servos, LFOs, and MIDI maps.

param("name", default) — legacy DSL parameter

The older param() built-in creates an inline parameter slot, useful when compiling directly to RillProgram without the graph engine:

main = _ * param("gain", 0.5);

Both mechanisms coexist. ?name is the canonical form for graph nodes; param() is available for standalone RillProgram use. The native smooth(x, ms) one-pole provides zipper-free interpolation when parameters change at block boundaries.

Type system

Types are inferred with a Hindley-Milner core: scalar types (int, float, type variables) are unified with an occurs check and let-generalized for named functions; wire arities are synthesized bottom-up and checked against the combinator algebra. Any mismatch is a compile error with a source span, and code generation is blocked — so an ill-formed diagram never reaches the runtime.

Serialization

With the serde feature, RillLangDef carries a program as its source string (the canonical, human-editable form) and compile_def turns it back into a runnable program:

use rill_lang::{RillLangDef, compile_def};

let def = RillLangDef::new("gain", "main = _ * 0.5;");
let prog = compile_def::<f32>(&def).unwrap();

Graph integration

The rill-adrift umbrella crate exposes rill-lang behind its lang feature. Three paths to runtime:

  1. compile_graph() — compiles source into a CompiledGraphEngine with actor mailbox support, ready to wire into a graph's processing pipeline.
  2. GraphBuilder::build_ir() (from rill-graph) — builds a multi-node GraphIr from a programmatic topology, then calls the same graph_compiler::compile() to produce a CompiledGraphEngine.
  3. rill/lang factory node — serialized graph nodes of type rill/lang embed their source as a source parameter:
{ "id": 0, "type_name": "rill/lang", "parameters": { "source": "main = _ * 0.5;" } }

Setting the source parameter at runtime recompiles and hot-swaps the program.

Execution model

The interpreter compiles the linear IR into a hybrid schedule via SCC analysis: feedforward regions run whole-buffer through the rill_core::math::vector SIMD eDSL, while feedback (~) and delay (@) recurrences run per-sample. The block path computes in T with zero heap allocation on the hot path. A Cranelift JIT backend is still planned and will reuse the same IR.

Debug infrastructure (debug feature)

When the debug Cargo feature is enabled, the IR gains a ProbePoint instruction for signal-level diagnostics. Each graph node compiled via rill-graph's build_ir() automatically gets a probe at its output:

  • ProbePoint { id, src, dst } — pass-through IR instruction that copies a register value and simultaneously captures it to a lock-free probe slot
  • ProbeSlot — atomic flags (enabled, break_flag, paused_flag) plus an SPSC queue for frame transport to a non-RT collector thread
  • DebugControl — shared atomics (global_pause, global_resume) for pause/resume execution control without syscalls

Probe data flows through rill-telemetry's CollectorThread and can be inspected via rill-analyzer. Zero overhead when the feature is disabled.

Status

MVP. Deferred to follow-on work: the Cranelift jit feature, foreign references to existing rill DSP primitives, and a SIMD-aware IR.

License

Apache-2.0. See the workspace LICENSE.md.