rill-adrift

rill-adrift is the umbrella crate for the Rill ecosystem — a modular signal processing framework for Rust.

One dependency brings in the entire ecosystem:

[dependencies]
rill-adrift = "0.6.0-M2"
#![allow(unused)]
fn main() {
use rill_adrift::prelude::*;
use rill_adrift::rill_core_dsp::generators::SineOscillator;
}

What is Rill?

Rill is not a monolith. It is a collection of specialized crates, each solving one problem well:

LayerCrates
Corerill-core — traits, math, buffers, queues, time, macros
Actorrill-core-actor — lock-free actor model (ActorRef, ActorSystem)
DSPrill-core-dsp — algorithms, filters, generators, delay, vector ops
Graphrill-graph — static DAG signal graph, GraphBuilder; build_ir() produces GraphIr compiled by rill-lang into a CompiledGraphEngine
Effectsrill-digital-filters, rill-digital-effects, rill-router
FFTrill-fft — radix-2 FFT, frequency-domain convolution, spectral effects
Automationrill-patchbay — LFO, envelopes, sensors, servos, mappings
Languagerill-lang — Faust-style functional signal DSL, compiles to Algorithm<T> or MultichannelAlgorithm<T>, or to CompiledGraphEngine for whole-graph compilation
Analogrill-core-model, rill-analog-filters, rill-analog-effects — WDF circuit modeling
I/Orill-io — ALSA, PortAudio, PipeWire, JACK backends (pure I/O, no engine)
Networkrill-osc — OSC server and networking; powers rill-patchbay OSC sensors for graph control
Monitoringrill-telemetry — probes, collectors
Samplerrill-sampler — sample playback, time-series reader, WAV loading
Lo-Firill-lofi — vintage DAC, tape effects, chip emulation (NES, AY-3-8910, Akai S900)
Umbrellarill-adrift — re-exports all workspace crates
Devtoolsrill-analyzer — interactive gdb-style debugger for signal graph inspection

Domain-Agnostic

Only rill-io is tied to audio hardware. The rest work anywhere — IoT, embedded, robotics, control systems, signal processing.

The foundation (rill-core) provides lock-free queues, no_std-compatible math traits, and real-time safe abstractions that apply to any signal domain.

Project Status

Active development — 20 crates, version 0.6.0-M2, 600+ tests.

Manifesto

The Rill Manifesto

Drifting along the stream of signals

We are building Rill — an infrastructure for distributed intelligence, where the periphery (Graph) meets the mind (Patchbay), and the protocol between them is a nervous system connecting the fast world of sensors and actuators with the slow world of thinking and memory.

Rill was not born as an architecture. It grew from a simple desire: to build a software analog of the Bastl Instruments Thyme+ pedal. But the deeper I dived into the code, the clearer I saw: behind this lie principles that work everywhere — from audio effects to industrial automation, from robotics to distributed AI.


Three principles of Rill

1. Separation of worlds

  • Hard real-time world (Graph) — fast, deterministic, bounded. Here live sensors (sound, CAN bus, temperature) and actuators (speakers, motors, relays). No allocations, no locks, no doubts. Pure data flow.

  • Control world (Patchbay) — slow, complex, unbounded. Here live automatons (LFOs, envelopes, logic), here they communicate with the user (GUI, MIDI, OSC), here they store history and make decisions. Here you can think.

  • Protocol between them — asynchronous, fault-tolerant, scalable. Command queues (Soft RT → Hard RT) and telemetry (Hard RT → Soft RT). This is the nervous system connecting reflexes with intelligence.

2. Block coherence

Graph parameters do not change within a block. They are fixed at its boundary and applied uniformly to all samples.

This gives:

  • Predictability (no clicks or glitches)
  • Performance (SIMD-friendly)
  • Simplicity of reasoning about the system

3. Protocol as foundation

Graph and Patchbay do not have to live in the same process — or even on the same node.

Locally — lock-free actor mailboxes (fast). Globally — TCP, UDP, WebSocket, LoRa (reliable, far, cheap).

By designing the protocol, we design the future. Internal Internet-Drafts today — potential RFCs tomorrow.


What we don't do

We do not chase perfect form. We don't write code for code's sake. We don't document for documentation's sake.

Every line of Rill answers the question: "Does this solve a real problem in real time?"

If not — it shouldn't exist.


Why Rill?

Rill is a stream. Not a river (too powerful), not a flow (too technological), but a stream. It flows where there is a slope. It doesn't choose its bed — it goes around obstacles. It doesn't fight stones — it washes over them. It doesn't promise an ocean — but it gets there.

Rill Adrift — a drifting stream. It takes the temperature of the world, compensates for its chaos, and simply flows. Because data flows. Signals flow. Life flows.


Join us

Rill is an open technology. Its code is on GitHub and SourceCraft, its documentation is in Obsidian, its spirit — in this manifesto.

Commercialization? Perhaps. Standardization? When the time comes. The main thing is infrastructure on which you can build anything, from an effects pedal to cloud AI.

I just wanted to create a software analog of the Bastl Instruments Thyme+, but got a little carried away.

Contributing

Rill is open for contributions. Areas where help is especially valued:

  • Audio backends: PortAudio, ALSA, CoreAudio, WASAPI, JACK, PipeWire
  • DSP algorithms: new effects, optimization of existing ones
  • Documentation: examples, tutorials, translations
  • Testing: on different platforms and hardware

How to start

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-effect)
  3. Run tests (cargo test --workspace)
  4. Submit a pull request

Git Flow

The project uses Git Flow:

  • main — stable releases
  • develop — integration branch
  • feature/* — new features
  • release/* — release preparation
  • hotfix/* — urgent fixes

Commit messages follow Conventional Commits:

<type>(<scope>): <description>

Types: feat, fix, docs, style, refactor, test, chore.

Architecture Overview

Rill is a modular signal-processing ecosystem built around a minimal core with traits. Each crate has a clear responsibility and can be used independently.

Layer diagram

┌─────────────────────────────────────────────────────────────┐
│  rill-osc  │  rill-graph  │  rill-patchbay  │  rill-sampler │
├─────────────────────────────────────────────────────────────┤
│  rill-core-dsp  (Algorithm trait, filters, generators, FX)  │
│  rill-digital-filters  │  rill-digital  │
│  -effects  │  rill-router  │  rill-lofi                     │
│  rill-core-model  │  rill-analog-filters  │  rill-analog      │
│  -effects  │  rill-lang  │  rill-fft                         │
├─────────────────────────────────────────────────────────────┤
│  rill-io (PortAudio / ALSA / PipeWire / JACK)               │
├─────────────────────────────────────────────────────────────┤
│  rill-telemetry                                            │
├─────────────────────────────────────────────────────────────┤
│  rill-core (traits, math, buffers, queues, time, macros)   │
│  rill-core-actor  (ActorRef, ActorSystem)       │
└─────────────────────────────────────────────────────────────┘

Key concepts

Signal graph (DAG)

Rill's processing model is a static directed acyclic graph (DAG):

  • Nodes — processing units added by type name (a flat add_node API, no separate source/processor/sink distinction at builder level)
  • Edge kindsSignal (forward flow, topologically sorted), Control (modulation), Clock (timing), Feedback (excluded from sort)
  • Connections — wired as (from_node, from_port, to_node, to_port) tuples

Graph topology is fixed at construction time via GraphBuilder::build_ir(). This produces a GraphIr (rill-lang's multi-node intermediate representation), which rill_lang::graph_compiler::compile() transforms into a CompiledGraphEngine. Processing is driven by CompiledGraphEngine — a flat vector of compiled closures over a FixedBuffer pool, executed in topological order with zero heap allocation on the signal path.

Two-thread architecture

  • Signal thread (hard or soft RT) — runs the process callback: CompiledGraphEngine::process(). Zero heap allocs, no locks, no syscalls.
  • Control thread (tokio green threads) — runs Patchbay with automatons (LFO, envelopes, sequencers). Communicates with the signal thread via the graph actor mailbox (ActorRef<CommandEnum>).

See Signal graph (rill-graph) for details.

Processing models

DirectionActive sideNode type
OutputPlaybackEngine writes output buffers from MultichannelAlgorithm::process()
InputCaptureEngine reads input buffers into CompiledGraphEngine::process()

Execution model

The signal graph has no external engine loop. CompiledGraphEngine::process_tick() drives execution:

  1. Drain the actor mailbox — apply queued SetParameter commands
  2. Execute nodes in topological order via NodeClosure::execute()
  3. Each node reads from its input buffers in the pool, runs its algorithm, writes to its output buffers
  4. CompiledGraphEngine implements both Algorithm<T> (SISO) and MultichannelAlgorithm<T> (MIMO)

Automation (The World of Automatons)

rill-patchbay provides generative control signals through automatons — LFOs, envelopes, sequencers that run on the control thread. Sensors (MIDI, OSC) decode external input into ControlEvents and feed them into the automaton world through mapping-only servos. Automatons connect to graph node parameters through servos with configurable mapping strategies (linear, exponential, logarithmic).

See The World of Automatons for details.

Design principles

  1. Domain-agnostic coreScalar, Vector, lock-free queues work in any signal domain (embedded, IoT, robotics)
  2. Minimal dependencies — each crate depends only on what it uses
  3. Zero-cost abstractions — static dispatch, const generics, SIMD-ready vectors
  4. Real-time safety — no allocation, no locks, no syscalls on the signal path
  5. Single-threaded DAG — the signal graph is a single-owner tree, no atomics or mutexes in the hot path

rill-core architecture

The rill-core crate is the foundation of the Rill ecosystem — traits, math, buffers, queues, time, and error types.

Core traits

Node

Base trait for all signal graph nodes. No Send or Sync bounds — nodes live on the signal thread exclusively.

#![allow(unused)]
fn main() {
pub trait Node<T: Transcendental, const BUF_SIZE: usize> {
    fn metadata(&self) -> NodeMetadata;
    fn init(&mut self, sample_rate: f32);
    fn reset(&mut self);
    fn id(&self) -> NodeId;
    fn set_id(&mut self, id: NodeId);
    fn get_parameter(&self, id: &ParameterId) -> Option<ParamValue>;
    fn set_parameter(&mut self, id: &ParameterId, value: ParamValue) -> ProcessResult<()>;
}
}

ParameterWrite

ParameterWrite is the polymorphic control interface for DSP engines. It decouples parameter dispatch from the concrete engine type:

#![allow(unused)]
fn main() {
pub trait ParameterWrite {
    fn write_parameter(&mut self, name: &str, value: ParamValue) -> ProcessResult<()>;
    fn read_parameter(&self, name: &str) -> Option<ParamValue> { None }
}
}

Implemented by BasicOscillator<f32>, Ay38910Chip, and any engine that accepts named parameter writes.

MultichannelAlgorithm

Multi-IO signal processing trait (N inputs, M outputs). Unlike Algorithm<T> which is strictly single-input/single-output (SISO), this trait supports N-to-M channel processing in a single call. Used by mixer, EQ, and multi-IO graph engines.

#![allow(unused)]
fn main() {
pub trait MultichannelAlgorithm<T: Transcendental>: Send {
    fn num_inputs(&self) -> usize;
    fn num_outputs(&self) -> usize;
    fn process(&mut self, inputs: &[&[T]], outputs: &mut [&mut [T]]) -> ProcessResult<()>;
    fn reset(&mut self);
}
}

Implemented by CompiledGraphEngine<T, BUF> (with router feature) and by SisoAdapter<A, T> (wraps any Algorithm<T> as a 1-in/1-out MultichannelAlgorithm).

File: rill-core/src/traits/multichannel_algorithm.rs.

BridgeAlgorithm

Bridge backend trait for duplex execution boundaries. A bridge node splits the signal graph into left (recording) and right (playback) sub-graphs.

#![allow(unused)]
fn main() {
pub trait BridgeAlgorithm<T: Transcendental>: Send + Sync {
    fn num_inputs(&self) -> usize;
    fn num_outputs(&self) -> usize;
    fn process_left(&mut self, inputs: &[&[T]]) -> ProcessResult<()>;
    fn process_right(&mut self, outputs: &mut [&mut [T]]) -> ProcessResult<()>;
    fn reset(&mut self);
}
}

The engine runs a 5-phase tick: ReadFeedback, process_left (left sub-graph + bridge.process_left), process_right (bridge.process_right + right sub-graph), WriteFeedback, shadow copy.

File: rill-core/src/traits/bridge.rs.

Source, Processor, Sink

#![allow(unused)]
fn main() {
pub trait Source<T: Transcendental, const BUF_SIZE: usize>: Node<T, BUF_SIZE> {
    fn generate(&mut self, clock: &ClockTick, ctrl: &[T], clk: &[ClockTick]) -> ProcessResult<()>;
}

pub trait Processor<T: Transcendental, const BUF_SIZE: usize>: Node<T, BUF_SIZE> {
    fn process(&mut self, clock: &ClockTick, signal: &[&[T; BUF_SIZE]], ...) -> ProcessResult<()>;
}

pub trait Sink<T: Transcendental, const BUF_SIZE: usize>: Node<T, BUF_SIZE> {
    fn consume(&mut self, clock: &ClockTick, signal: &[&[T; BUF_SIZE]], ...) -> ProcessResult<()>;
}
}

IoDriver, IoCapture, IoPlayback

Backends are created externally by the orchestrator. Three orthogonal traits separate concerns:

#![allow(unused)]
fn main() {
pub trait IoDriver: Send + Sync {
    fn set_process_callback(&self, cb: Box<dyn FnMut(&ClockTick)>);
    fn run(&self, running: Arc<AtomicBool>) -> IoResult<()>;
    fn stop(&self) -> IoResult<()>;
}

pub trait IoCapture: Send + Sync {
    fn read_input(&self, channel: usize, dst: &mut [f32]) -> usize;
    fn num_input_channels(&self) -> usize;
}

pub trait IoPlayback: Send + Sync {
    fn write_output(&self, channel: usize, src: &[f32]) -> usize;
    fn num_output_channels(&self) -> usize;
}
}

A single backend struct (e.g. PipewireBackend) implements IoDriver and optionally IoCapture / IoPlayback. The driver owns the timing loop; capture and playback provide data access.

IoBackend exists as a backward-compatible alias: pub trait IoBackend: IoDriver {}.

ProcessingState

Extracted from Graph via into_processing_state(), this struct is the runtime engine that drives the signal graph inside the I/O callback:

#![allow(unused)]
fn main() {
pub struct ProcessingState<T, const BUF_SIZE: usize> { /* ... */ }

impl ProcessingState<T, BUF_SIZE> {
    pub fn process_block(&mut self, tick: &ClockTick) -> ProcessResult<()>;
    pub fn wire_backends(
        &mut self,
        capture: Option<Arc<dyn IoCapture>>,
        playback: Option<Arc<dyn IoPlayback>>,
    );
    pub fn run_with_driver(
        &mut self,
        driver: Box<dyn IoDriver>,
        running: Arc<AtomicBool>,
    ) -> IoResult<()>;
}
}

process_block() is the per-block entry point called from the I/O callback. It first adopts the tick's sample rate (re-initialising nodes if the backend's hardware rate differs from the built rate — the graph has no clock of its own), drains the actor mailbox, applies any sample-accurate parameter changes due for this block, runs sources/processors/sinks, and triggers port propagation.

ParamValue

#![allow(unused)]
fn main() {
pub enum ParamValue {
    Float(f32),
    Int(i32),
    Bool(bool),
    String(String),
    Choice(String),
    Bytes(Vec<u8>),  // for IoControl::write_data()
}
}

Queues

Non-blocking SPSC queue for dual-thread communication:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use rill_core::queues::{MpscQueue, SetParameter, SignalOrigin};
use rill_core::traits::{ParamValue, ParameterId};

let cmd_queue = Arc::new(MpscQueue::<SetParameter>::with_capacity(64));

// Control thread
cmd_queue.push(SetParameter::new(
    "osc_freq".to_string(),
    ParameterId::new("frequency").unwrap(),
    ParamValue::Float(440.0),
    SignalOrigin::Manual,
));

// Signal thread (in tick closure)
while let Some(cmd) = cmd_queue.pop() {
    if let Some(node) = nodes.get_mut(&cmd.port) {
        node.set_parameter(&cmd.parameter, cmd.value);
    }
}
}

ClockTick

Per-block timing sent from the driver into the graph and to control modules. Carries only timing metadata — I/O access is through IoCapture/IoPlayback traits held by graph nodes.

#![allow(unused)]
fn main() {
pub struct ClockTick {
    pub sample_pos: u64,
    pub samples_since_last: u32,
    pub is_new_block: bool,
    pub sample_rate: f32,
    pub tempo: Option<f32>,
    pub source: String,
    pub speed_ratio: f64,
    pub is_final: bool,
    pub io_quantum: u32,   // frames the backend processes per I/O callback
}
}

io_quantum lets asynchronous control producers schedule sample-accurate parameter changes correctly under backends that batch many block_size chunks into one callback. It defaults to samples_since_last (one chunk per callback) and is set to the full callback size by chunking backends (PipeWire, JACK).

Sample-accurate parameter changes

SetParameter carries an optional sample_pos: Option<u64> and an anchor: String for routing to the correct program in multi-node graphs:

#![allow(unused)]
fn main() {
pub struct SetParameter {
    pub port: String,              // target port name
    pub anchor: String,            // node anchor name for lang-based graphs
    pub parameter: ParameterId,
    pub value: ParamValue,
    pub source: SignalOrigin,
    pub timestamp: u64,            // wall-clock, for ordering/telemetry
    pub sample_pos: Option<u64>,   // absolute sample to apply at; None = ASAP
}
}

When anchor is non-empty, the engine uses the schedule's program_names map for O(1) lookup into the correct RillProgram. When empty, the engine scans all program param maps (backward compat).

  • None — applied immediately when the graph actor drains it (legacy; used by live UI/MIDI writes so there is no added latency).
  • Some(pos) — queued and applied by the graph during the 256-sample block whose range [block_start, block_start + block) contains pos.

Because an async control module reacting to a tick in I/O callback N is only rendered in callback N+1, producers look ahead by one quantum: SetParameter::new(..).with_sample_pos(tick.sample_pos + tick.io_quantum as u64).

Builtin registry

rill-core/src/builtin.rs provides the foreign-function registry for DSP/model built-ins callable from rill-lang:

#![allow(unused)]
fn main() {
pub struct Registry<T: Transcendental> { /* ... */ }
impl<T: Transcendental> Registry<T> {
    pub fn register_sample(&mut self, sig: BuiltinSig, factory: ...);
    pub fn register_block(&mut self, sig: BuiltinSig, factory: ...);
    pub fn get(&self, name: &str) -> Option<&Entry<T>>;
}
}

Key types:

TypePurpose
Registry<T>HashMap-backed collection of built-in definitions
BuiltinSigType-checker-facing signature: name, params (list of ParamType), signal_outs, BuiltinKind
ParamTypeSignal, Float, Int, String, Bool, Record(RecordSchema), Enum(...), Variadic(Box<ParamType>)
RecordSchemaNamed fields with type and optional default
BlockBuiltin<T>Whole-buffer built-in extending Algorithm<T>
SampleBuiltin<T>Per-sample built-in (feedback-legal)
SignatureSourceT-independent trait for type-checker/lowering
#![allow(unused)]
fn main() {
use rill_core::builtin::{Registry, BuiltinSig, BuiltinKind, ParamType};

let mut reg = Registry::<f32>::new();
reg.register_sample(
    BuiltinSig {
        name: "gain",
        params: vec![ParamType::Signal, ParamType::Float],
        signal_outs: 1,
        kind: BuiltinKind::Sample,
    },
    |params, _sr| {
        Box::new(Gain { k: params[0] as f32 }) as Box<dyn SampleBuiltin<f32>>
    },
);
}

Module tree

rill-core/
├── traits/   — Node, Source, Processor, Sink, ParamValue, Port, Algorithm,
│              MultichannelAlgorithm, BridgeAlgorithm, ParameterWrite
├── builtin/  — Registry<T>, BuiltinSig, ParamType, BlockBuiltin<T>, SampleBuiltin<T>
├── math/     — Scalar, Transcendental, Vector, glam re-export (Mat2/3/4, Vec2/3/4)
├── buffer/   — PipeBuffer, FanOutBuffer, FanInBuffer, DelayLine, RingBuffer, TapeLoop, FixedBuffer, ResourceRegistry
├── queues/   — MpscQueue, SetParameter, CommandEnum, Telemetry
├── time/     — ClockTick, RenderContext, SystemClock
├── io/       — IoDriver, IoCapture, IoPlayback, IoControl, IoResult
└── macros/   — source_node!, processor_node!, sink_node!

Signal graph (rill-graph)

rill-graph provides a static DAG signal graph builder and a serializable graph format. Processing is handled by rill-lang's CompiledGraphEnginerill-graph itself is a pure topology description, not an execution engine.

Architecture

┌──────────────────────────────────────────────────────────────┐
│                      GraphBuilder<T, BUF_SIZE>               │
│  add_node(type, params) → idx                               │
│  add_node_with_name(type, params, id, name) → idx            │
│  connect_signal(from_n, from_p, to_n, to_p)                  │
│  connect_control(from_n, from_p, to_n, to_p)                 │
│  connect_feedback(from_n, from_p, to_n, to_p)                │
│  add_resource(GraphResource)                                 │
│  add_routing_entry(idx, from, to, gain)                      │
│                                                              │
│  build_ir(registry) → GraphIr                                │
│      │                                                       │
│      ▼                                                       │
│  graph_compiler::compile() → CompiledGraph                   │
│      │                                                       │
│      ▼                                                       │
│  CompiledGraphEngine<T, BUF_SIZE>  (in rill-lang)            │
└──────────────────────────────────────────────────────────────┘

Nodes

All nodes are added through a unified add_node API — there are no separate add_source/add_processor/add_sink methods. The type_name string determines the node kind (matched against the built-in registry at build_ir time).

#![allow(unused)]
fn main() {
use rill_graph::GraphBuilder;

const BUF_SIZE: usize = 256;
let mut builder = GraphBuilder::<f32, BUF_SIZE>::new();

// Add nodes by their registry type name
let osc = builder.add_node("rill/sinosc", &[("freq", 440.0)].into());
let lpf = builder.add_node_with_name("rill/lpf", &[("cutoff", 800.0)].into(), 1, "filter");
let out = builder.add_node("rill/output", &[].into());
}

Connections

Edges connect (node_idx, port_idx) pairs. Four edge kinds are supported:

KindPurpose
connect_signalForward signal flow — included in topological sort
connect_controlModulation values (e.g. LFO → filter cutoff)
connect_clockTiming signals (MIDI clock, transport)
connect_feedbackFeedback loops — excluded from topological sort, implicit 1-sample delay
#![allow(unused)]
fn main() {
builder.connect_signal(osc, 0, lpf, 0);    // osc output → lpf input
builder.connect_signal(lpf, 0, out, 0);    // lpf output → output
}

Resources

Named resources (tape loops, shared buffers) can be registered and referenced by node parameters:

#![allow(unused)]
fn main() {
builder.add_resource(GraphResource {
    name: "tape_0".into(),
    kind: "tape".into(),
    capacity: 48000,
});
}

Compilation pipeline

build_ir(registry) converts the builder's internal representation into a GraphIr (rill-lang's multi-node intermediate representation):

  1. Node lookup — each recipe's type_name is resolved in the Registry
  2. Topological sort — Kahn's algorithm on signal edges; cycles are rejected
  3. Built-in compilation — each node's built-in is compiled to an Ir (single-node program)
  4. Optimization — dead-edge elimination, constant inlining, parallel node merging (rill-lang/src/graph_optimize.rs)
  5. Compilergraph_compiler::compile() flattens GraphIr into a CompiledGraph with a fixed-size FixedBuffer pool and ordered NodeClosure vector

The resulting CompiledGraphEngine implements both Algorithm<T> (SISO) and MultichannelAlgorithm<T> (MIMO). It runs nodes in topological order with zero heap allocation on the signal path.

Per-crate registration

Each DSP crate provides a register_lang_builtins<T>(&mut Registry<T>) function that registers all its built-in node types:

#![allow(unused)]
fn main() {
use rill_core::builtin::Registry;
use rill_adrift::lang_builtins::full_registry;

let mut reg: Registry<f32> = full_registry();
// reg now contains all DSP, router, effects, FFT, analog, sampler builtins
}

Node types are registered by their type-name string (e.g. "rill/lpf", "rill/gain") with typed parameter signatures and factory closures.

Actor interface

CompiledGraphEngine::handle() returns an ActorRef<CommandEnum>. Control-side code sends CommandEnum::SetParameter through this handle:

#![allow(unused)]
fn main() {
use rill_core::queues::CommandEnum;
use rill_core::traits::ParamValue;

engine.handle().send(CommandEnum::SetParameter(SetParameter {
    anchor: "filter".into(),
    parameter: "cutoff".into(),
    value: ParamValue::Float(2000.0),
    port: String::new(),
    source: SignalOrigin::Manual,
    timestamp: 0,
    sample_pos: None,
})).unwrap();
}

The engine drains its mailbox at the start of each process() call, applying parameter changes before processing the current block.

Serialized graphs (GraphDef)

Graph topology can be serialized to JSON or CBOR via the serialization feature:

#![allow(unused)]
fn main() {
use rill_graph::serialization::{GraphDef, NodeDef, SourceDef, ConnectionDef, SignalKind};

let def = GraphDef {
    format_version: "rill/1".into(),
    sample_rate: 44100.0,
    block_size: 256,
    resources: vec![],
    description: None,
    nodes: vec![
        NodeDef::Source(SourceDef {
            id: 0,
            name: Some("osc".into()),
            type_name: "rill/sinosc".into(),
            parameters: [("freq", ParamValue::Float(440.0))].into(),
        }),
        NodeDef::Processor(ProcessorDef {
            id: 1,
            name: Some("filter".into()),
            type_name: "rill/lpf".into(),
            parameters: [("cutoff", ParamValue::Float(800.0))].into(),
        }),
        NodeDef::Sink(SinkDef {
            id: 2,
            name: Some("out".into()),
            type_name: "rill/output".into(),
            parameters: [].into(),
        }),
    ],
    connections: vec![
        ConnectionDef {
            from_node: 0, from_port: 0,
            to_node: 1, to_port: 0,
            kind: SignalKind::Signal,
        },
        ConnectionDef {
            from_node: 1, from_port: 0,
            to_node: 2, to_port: 0,
            kind: SignalKind::Signal,
        },
    ],
};
def.populate(&mut builder)?;
let engine = builder.build_ir(&reg, sample_rate)?;
}

NodeDef is an enum with four variants: Source(SourceDef), Processor(ProcessorDef), Router(RouterDef), Sink(SinkDef).

Bridge and feedback

Graph nodes carry optional bridge and feedback annotations (is_bridge, feedback_read, feedback_write on GraphNode). A bridge node splits the graph into left (recording) and right (playback) sub-graphs, connected through named feedback buffers.

Feedback edges in GraphIr (marked EdgeKind::Feedback) are excluded from topological sort and carry implicit 1-sample delay — they connect the current tick's output back as the next tick's input.

Key components

ComponentLocationPurpose
GraphBuilder<T, BUF_SIZE>rill-graphMutable builder: adds nodes, connections, resources; build_ir() produces GraphIr
GraphResourcerill-graphNamed shared resource (tape loop, buffer)
BuildErrorrill-graphError type for graph construction
GraphDefrill-graph::serializationSerializable graph topology (format_version, nodes, connections)
NodeDefrill-graph::serializationEnum: Source(SourceDef), Processor(ProcessorDef), Router(RouterDef), Sink(SinkDef)
ConnectionDefrill-graph::serializationSerializable connection: from_node/port → to_node/port + SignalKind
GraphIrrill-lang::graph_irMulti-node IR — bridges GraphBuilder to rill-lang compilation
GraphNoderill-lang::graph_irOne graph node: arity, IR, params, bridge/feedback annotations
GraphEdgerill-lang::graph_irDirected edge: node names + ports + EdgeKind
EdgeKindrill-lang::graph_irSignal, Control, Clock, or Feedback
CompiledGraphEngine<T, BUF_SIZE>rill-lang::graph_engineExecution engine: flat NodeClosure vector + FixedBuffer pool; implements Algorithm<T> and MultichannelAlgorithm<T>

Integration

  • rill-coreBuiltinSig, Registry, Algorithm, MultichannelAlgorithm, ParamValue
  • rill-core-actorActorRef<CommandEnum> / Mailbox (parameter control)
  • rill-langGraphIr, GraphNode, GraphEdge, CompiledGraphEngine, graph_compiler::compile(), graph_optimize::optimize()
  • rill-patchbay — automation via CommandEnum::SetParameter through engine.handle()
  • rill-io — input/output backends connect to graph through compiled engine

Actor Model (rill-core-actor)

Rill implements a lightweight actor model for lock-free message passing between threads. The actor API is minimal: Actor<M>, ActorRef<M>, ActorSystem, and three spawn strategies.

Core types

Actor<M>

Handler closure + mailbox. Drained in-place by the caller — no separate thread.

#![allow(unused)]
fn main() {
use rill_core_actor::{ActorSystem, ActorRef};

let system = ActorSystem::new();
let mut actor = system.spawn("echo", |msg: String| {
    println!("got: {msg}");
});
let ref_a = actor.actor_ref();
ref_a.send("hello".into());
actor.drain(); // processes "hello"
}
  • Handler is Box<dyn FnMut(M)> — created and drained on the same thread, no Send requirement.
  • Used by Graph (handler captures Rc<UnsafeCell<...>>!Send, drained inline in I/O callback).
  • Used by Rack actor — drained in a dedicated OS thread.

ActorRef<M>

Thread-safe send-only handle (Arc<Mailbox<M>>). Lock-free send(), bounded queue (capacity 64). Silently drops messages when queue is full.

ActorSystem

Registry of named actors. Three spawn methods:

MethodHandler locationDrainReturnsUse case
spawn(name, handler)Caller's threadCaller (actor.drain())Actor<M>Graph, inline drain
spawn_detached(name, make_handler, ms)Inside new OS threadAuto (std::thread::spawn + sleep)ActorRef<M>Rack, Servo (handler !Send)
spawn_detached_tokio(name, make_handler, ms)Inside new tokio taskAuto (tokio::spawn + interval)ActorRef<M>Servo (handler Send, many actors)

Key design rule: The handler closure is always created on the thread where it will be drained. For spawn, the caller creates the handler and drains it. For spawn_detached*, make_handler() is called inside the spawned thread/task → handler never crosses thread boundary → Send not required.

RT boundary

Soft-RT (control thread)                    Hard-RT (signal thread)
┌──────────────────────────┐               ┌──────────────────────────┐
│ Servo actor (tokio)      │   send()      │ Graph actor              │
│   automaton.step()       │  ──────────►  │   drain() in callback    │
│   mapping.apply()        │   mailbox     │   → set_parameter()      │
│   conflict strategies    │               │   → generate()           │
│ OSC dispatch             │               │   → propagate()          │
└──────────────────────────┘               └──────────────────────────┘
DirectionMessageMailbox ownerSender
Control → SignalSetParameterGraph actorServo actors via graph.handle()
Signal → ControlClockTickRack actorGraph via parent_ref.send()
MethodRT-safe?Notes
ActorRef::send()✅ Hard RTLock-free, bounded queue
Actor::drain()⚠️ Depends on caller's threadIn I/O callback = hard RT, in control = soft RT
ActorSystem::route()❌ Soft RT onlyHeap iteration
ActorSystem::broadcast()❌ Soft RT onlyHeap iteration + clone

Patchbay Rack

The Patchbay is the control rack — an independent subsystem that hosts modulation generators (automatons), event dispatch (MidiHub, OSC), and the mapping layer that translates external events into graph parameter commands.

┌─ Control Rack (Patchbay, soft‑RT) ─────────────────────────────────────┐
│                                                                         │
│  Modules:                                                               │
│  ┌──────────┐  ┌──────────┐  ┌──────────────┐                          │
│  │Automatons│  │  Midi    │  │  OSC Sensor  │                          │
│  │ (LFO,ENV)│  │  Input   │  │  (UDP)       │                          │
│  └────┬─────┘  └────┬─────┘  └──────┬───────┘                          │
│       │             │               │                                   │
│       ▼             ▼               ▼                                   │
│  ┌─────────────────────────────────────────────┐                       │
│  │                Servo                        │                       │
│  │  automaton.step() + mapping.apply()         │                       │
│  │  strategies: ControlStrategy (Absolute /    │                       │
│  │    Modulation) + ConflictStrategy           │                       │
│  │    (TouchOverride / BasePlusModulation /    │                       │
│  │     LastWriteWins)                          │                       │
│  └───────────────────┬─────────────────────────┘                       │
│                      │ ActorRef<SetParameter>                          │
│                      ▼ MpscQueue (lock‑free)                           │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─ Signal Rack (Graph, hard‑RT) ────────────────────────────────────┐ │
│  │  drain queue → set_parameter → process_block → propagate          │ │
│  │  Input → [processors] → Output                                    │ │
│  └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘

Domain model

The Patchbay is a rack — a container for modules. Each module is optional and configured through a single document (PatchbayDef):

ModuleRoleConfigured via
AutomatonsModulation generators (LFO, envelope)automatons + servos
MidiInputExternal MIDI event sourceSensorDef::Midi
OscSensorExternal OSC event source (UDP)SensorDef::Osc
SequencerStep sequencer driven by signal clockattach_sequencer()
OscSurfaceOSC → EventPattern bridgeosc_surface

All modules produce ControlEvents that flow through mappingsSetParameter commands → graph's lock‑free queue.

PatchbayDef — single configuration document

#![allow(unused)]
fn main() {
pub struct PatchbayDef {
    /// Modulation generators (LFO, envelope, named functions)
    pub automatons: Vec<AutomatonDef>,

    /// Generator → graph parameter wiring
    pub servos: Vec<ServoDef>,

    /// Event → graph parameter wiring (MIDI CC, OSC address, etc.)
    pub mappings: Vec<MappingDef>,

    /// OSC address → EventPattern bridge
    pub osc_surface: OscSurface,

    /// Unified modules — servos and sensors
    pub modules: Vec<ModuleDef>,

    /// Human‑readable description
    pub description: Option<String>,
}
}

Sensors are configured through ModuleDef::Sensor:

#![allow(unused)]
fn main() {
// MIDI sensor
ModuleDef::Sensor(SensorDef::Midi {
    backend: "midir".into(),
    port_name: "rill-midi".into(),
    mappings: vec![...],
})

// OSC sensor
ModuleDef::Sensor(SensorDef::Osc {
    port: 9000,
    mappings: vec![
        MappingDef {
            event_pattern: EventPattern::OscAddress("/fader/1".into()),
            target_node: 1,
            target_param: "gain".into(),
            transform: TransformDef::Linear,
            min: 0.0,
            max: 1.0,
            enabled: true,
        },
    ],
})
}

Behaviour: ModularSystem::launch() dispatches each ModuleDef::Sensor to the appropriate constructor (MidiConstructor or OscConstructor), which spawns a sensor + mapping-only servo pair.

MidiInputDef (legacy, superseded by SensorDef)

Currently, MidiHub is created programmatically — PatchbayDef has no midi field. Adding it makes the MidiHub a first‑class rack module, configurable from JSON:

#![allow(unused)]
fn main() {
pub struct MidiInputDef {
    /// Backend name: "midir" or "alsa_seq"
    pub backend: String,

    /// Virtual port name (e.g. "drift-midi" for aconnect)
    pub port_name: String,
}
}

Behaviour: apply_to_async() creates the MidiInput, starts the MidiHub, and stores the handle. stop_all() stops it.

#![allow(unused)]
fn main() {
#[cfg(feature = "midi")]
pub fn apply_to_async(&self, control: &mut Patchbay, registry: &FunctionRegistry) -> Result<(), String> {
    // ... existing automatons/servos/mappings setup ...

    if let Some(ref midi_def) = self.midi {
        let backend: Box<dyn MidiInput> = match midi_def.backend.as_str() {
            "midir" => Box::new(MidirBackend::new(&midi_def.port_name)?),
            "alsa_seq" => Box::new(AlsaSeqBackend::new(&midi_def.port_name)?),
            _ => return Err(format!("unknown midi backend: {}", midi_def.backend)),
        };
        let shared = Arc::new(Mutex::new(control.as_shared()));
        control.set_midi_actor(MidiHub::start(backend, shared));
    }

    Ok(())
}
}

This makes MIDI input purely a configuration concern — no extra code in Runtime or drift/main.rs.

One instance or two? — analysis

The current Runtime::load_patchbay() creates two Patchbay instances:

InstancePurposeFields populated
controlOwns automaton handles (port_combiners, automaton_handles)All
control_shared (Arc<Mutex<>>)Receives events from OSC/MIDImappings only

This split exists because automatons run as tokio green threads (no Mutex needed — channels do the work) while event dispatch needs &mut self (protected by Mutex). The shared instance is a stripped copy with only mappings.

Option A: single Arc<Mutex<Patchbay>> (simpler)

#![allow(unused)]
fn main() {
let pb = Arc::new(Mutex::new(Patchbay::new(graph_handle)));
}
  • Event dispatch (MidiHub): pb.lock().handle_event(event) — brief lock
  • Automaton setup: pb.lock().add_automaton_task(...) — done once at init
  • Shutdown: pb.lock().stop_all() — done once

The Mutex is not contended during runtime because automatons communicate via channels, not by locking Patchbay. Only the MidiHub's OS thread locks (briefly, to run handle_event). One instance is sufficient.

Option B: actor model via spawn_detached (cleaner, long‑term)

Patchbay runs its handler inside an actor spawned with ActorSystem::spawn_detached (handler + Arc<Mailbox<ControlEvent>>), and MidiHub sends events via ActorRef<ControlEvent>::send() — lock‑free.

MidiHub (OS thread)                Patchbay (detached actor)
     │                                      │
     │  ActorRef<ControlEvent>::send()     │
     ├──────── lock‑free push ────────────→│
     │                                      ├─ drain loop: while let Some(event) = mailbox.pop()
     │                                      │    handle_event(event)
     │                                      └─ → ActorRef<CommandEnum>.send()

This eliminates the Mutex entirely and aligns with rill-core-actor (this is the pattern already used by spawn_midi_sensor / servos). However, it requires adding a drain loop to Patchbay and changes the lifecycle (Patchbay becomes a detached actor, not a synchronous object).

Recommendation

For the Moonlight demo: Option A — single Arc<Mutex<Patchbay>>. It works with the existing codebase, requires no restructuring, and the Mutex is uncontended in practice. The actor model (Option B) is the right long‑term direction and should be documented as a future evolution.

Runtime::launch() — two racks, one command

#![allow(unused)]
fn main() {
pub fn launch(config: LaunchConfig) -> Result<Runtime, Error> {
    // ── Create tokio runtime for control rack ──
    let tokio_rt = tokio::runtime::Runtime::new()?;
    let _guard = tokio_rt.enter();

    // ── Rack 2: Signal Graph ──
    let mut builder = self.create_builder();
    config.graph_def.populate(&mut builder)?;
    let mut graph = builder.build()?;
    let graph_handle = graph.handle().expect("no active node");

    // ── Rack 1: Control Patchbay ──
    let registry = FunctionRegistry::builtin();
    let mut control = Patchbay::new(graph_handle);
    config.patchbay_def
        .apply_to_async(&mut control, &registry)?;
    // ↑ One call: automatons started, MIDI port opened,
    //   mappings loaded, servos running.

    let running = Arc::new(AtomicBool::new(true));
    let r = running.clone();
    let signal_thread = std::thread::spawn(move || {
        graph.run(r).ok();
    });

    Ok(Runtime {
        control: Arc::new(Mutex::new(control)),
        signal_thread,
        running,
        _tokio: tokio_rt,
    })
}
}

Runtime::stop() — single exit point:

#![allow(unused)]
fn main() {
pub fn stop(&mut self) {
    self.running.store(false, Ordering::Release);

    // Stop control rack: automatons, sensors, servos.
    if let Ok(mut pb) = self.control.lock() {
        pb.stop_all();
    }

    // Signal thread exits when graph.run() sees running=false.
    // Drop tokio runtime → remaining tasks cancelled.
}
}

Summary of changes

CrateFileChange
rill-patchbayserialization/mod.rsAdd MidiInputDef, midi field in PatchbayDef
rill-patchbayengine.rsAdd set_midi_actor(), as_shared(), extend stop_all()
rill-adriftruntime/mod.rsLaunchConfig, Runtime::launch(), rewrite stop()
rill-adriftruntime/config.rsLaunchConfig struct

The goal: PatchbayDef describes the entire control rack. Runtime::launch() builds both racks and wires them together in one call.

Future: feature-gated modules (Eurorack model)

Currently rill-patchbay is monolithic — all automaton types and modules are compiled unconditionally. With feature gates, each module becomes a slot in the rack: you install only what you need.

[features]
default = []
lfo       = []           # LfoAutomaton + ServoDef::Lfo variant
envelope  = []           # EnvelopeAutomaton + ServoDef::Envelope variant
sequencer = []           # SnapshotSequencer + attach_sequencer
midi      = ["rill-io"]  # MidiHub + MidiInputDef (already behind "midi")
osc       = ["rill-osc"] # OscSurface dispatch (deferred)

Usage in downstream crates:

# Drift — tape delay demo: LFO modulation + MIDI control
rill-patchbay = { features = ["lfo", "midi"] }

# Minimal setup — no automation, just MIDI CC mapping
rill-patchbay = { features = ["midi"] }

# Sequencer-only — clock-driven pattern changes, no LFO
rill-patchbay = { features = ["sequencer", "midi"] }

Implementation pattern (follows rill-io backend model):

#![allow(unused)]
fn main() {
#[cfg(feature = "lfo")]
impl AutomatonDef {
    pub fn apply_to(&self, control: &mut Patchbay, ...) { ... }
}
#[cfg(not(feature = "lfo"))]
impl AutomatonDef {
    pub fn apply_to(&self, _: &mut Patchbay, ...) {
        compile_error!("LFO module not installed in this rack");
    }
}
}

This makes rill-patchbay a literal Eurorack — each feature is a module you snap into the control rack. The Cargo.toml of the consuming crate defines which modules populate the rack at compile time.

Status: deferred. The current monolithic build is sufficient for the Moonlight demo. Feature gates add build-time modularity without runtime cost and should be introduced when the module set grows beyond two automaton types.

MIDI support (rill-io + rill-patchbay)

MIDI is handled through a layered architecture with separate input and output paths, both flowing through the actor model:

  • Input: hardware → MidiInput::poll()parse_midi()ControlEventActorRef<CommandEnum> → Servo → SetParameter → Graph.
  • Output: ClockTick (Rack broadcast) → MidiClockGeneratorControlEventserialize_to_midi()MidiOutput::send() → hardware.

Architecture

┌── INPUT PATH (Dedicated OS thread, non‑RT) ────────────────────────────┐
│                                                                         │
│  MidiInput::poll()  ──→  parse_midi()  ──→  ControlEvent               │
│       │                        │                    │                   │
│  raw [u8; 3]             bytes → event       events.send(event)         │
│                                                    │                   │
└────────────────────────────────────────────────────┼───────────────────┘
                                                     │
                                        ActorRef<CommandEnum>
                                                     │
                                                     ▼
                                              Servo (mappings)
                                                     │
                                                SetParameter
                                                     │
                                                     ▼
                                              Graph command queue

┌── OUTPUT PATH (Rack actor thread, non‑RT) ─────────────────────────────┐
│                                                                         │
│  Rack broadcast ClockTick  ──→  MidiOutputActor                        │
│                                       │                                 │
│                                 MidiClockGenerator                      │
│                                       │                                 │
│                                 Vec<ControlEvent>                       │
│                                       │                                 │
│                                 serialize_to_midi()                     │
│                                       │                                 │
│                                 MidiMessage(0xF8, …)                    │
│                                       │                                 │
│                                 MidiOutput::send()                      │
│                                       │                                 │
│                                       ▼                                 │
│                                 Hardware / JACK / ALSA                  │
└─────────────────────────────────────────────────────────────────────────┘
  • MIDI threads are NOT the signal RT thread — blocking I/O is allowed
  • All communication uses ActorRef<CommandEnum> — lock‑free, no Arc<Mutex>
  • Input: sensors produce ControlEvent, dispatched through actor mailbox
  • Output: ClockTick arrives via Rack broadcast, clock generator produces events, serialized and sent through the backend

MidiMessage — raw MIDI bytes

#![allow(unused)]
fn main() {
pub struct MidiMessage(pub [u8; 3]);
}

A lightweight container for three MIDI bytes. Single-byte system messages (Clock: 0xF8, Start: 0xFA, Stop: 0xFC, Continue: 0xFB) have data bytes set to zero. No MIDI semantics — interpretation happens in parse_midi() (input) or serialize_to_midi() (output).

MidiInput trait

#![allow(unused)]
fn main() {
pub trait MidiInput: Send + 'static {
    fn poll(&mut self) -> IoResult<Vec<MidiMessage>>;
}
}

Backends implement hardware-specific MIDI input. poll() may block briefly (typically 1–10 ms) waiting for events.

MidiOutput trait

#![allow(unused)]
fn main() {
pub trait MidiOutput: Send + 'static {
    fn send(&mut self, message: &MidiMessage) -> IoResult<()>;
}
}

Sends a single MIDI message to an output port. All three backends deliver messages immediately — no internal buffering, no flush() needed.

The MidiInput/MidiOutput pair mirrors the audio-side IoCapture/IoPlayback separation — input and output are distinct traits, each backend implements the direction(s) it supports.

Built-in backends

BackendFeaturePlatformMidiInputMidiOutput
MidirBackendmidir (default)Allnew(), new_by_port(), new_by_name()new_output(), new_output_by_name()
AlsaSeqBackendalsaLinuxnew()Direction::Capture portnew_output()Direction::Playback port
JackMidiBackendjackAllnew() + connect()MidiIn portnew_output() + connect_output()MidiOut port

Choosing a backend (input)

#![allow(unused)]
fn main() {
// Cross-platform default — connects to hardware MIDI port
use rill_io::backends::MidirBackend;
use rill_io::midi_input::MidiInput;
let backend: Box<dyn MidiInput> = Box::new(MidirBackend::new("rill-midi").unwrap());
}

Choosing a backend (output)

#![allow(unused)]
fn main() {
use rill_io::backends::MidirBackend;
use rill_io::midi_output::MidiOutput;
let backend: Box<dyn MidiOutput> = Box::new(
    MidirBackend::new_output_by_name("rill-clock", "My Synth").unwrap()
);
}

Input path: parse_midi() — bytes → ControlEvent

The parse_midi() function in rill-patchbay::midi converts a raw [MidiMessage] into a [ControlEvent]:

Status byteControlEvent variant
0x80 Note OffMidiNote { on: false, velocity: 0 }
0x90 Note On (vel > 0)MidiNote { on: true, velocity }
0x90 Note On (vel = 0)MidiNote { on: false }
0xA0 Poly AftertouchMidiNote { on: true, velocity }
0xB0 Control ChangeMidiControl { controller, value, normalized: value / 127 }
0xE0 Pitch BendMidiControl { controller: 128, normalized }
0xF8 ClockMidiClock
0xFA / 0xFB / 0xFCMidiTransport { kind: Start/Stop/Continue }

Output path: serialize_to_midi()ControlEvent → bytes

The reverse of parse_midi(). Converts output-bound ControlEvent variants back to [MidiMessage] bytes:

ControlEventStatus byteData1Data2
MidiClock0xF800
MidiTransport { kind: Start }0xFA00
MidiTransport { kind: Stop }0xFC00
MidiTransport { kind: Continue }0xFB00
MidiNote { note, on: true }0x90notevelocity
MidiNote { note, on: false }0x80note0

Only Clock, Transport, and Note events are serialized. Other event types (MidiControl, Button, Knob, etc.) return None.

MidiClockGenerator — 24ppqn clock pulse generator

Lives in rill-patchbay::midi_clock. Converts timing information from [ClockTick] into MIDI clock pulses (24 pulses per quarter note = 24ppqn).

#![allow(unused)]
fn main() {
pub struct MidiClockGenerator {
    next_tick_at: f64,     // absolute sample position of next tick
    samples_per_tick: f64, // sample_rate × 60 / (bpm × 24)
    bpm: f64,
    playing: bool,
}
}

Algorithm: On each tick(&ClockTick) call:

  1. If BPM changed, recalculate samples_per_tick from clock.tempo
  2. While next_tick_at < clock.sample_pos + block_size: emit ControlEvent::MidiClock, advance next_tick_at by samples_per_tick
  3. Return accumulated events (0, 1, or several per block)

Transport state machine:

  • Start → sets playing = true, resets next_tick_at to current sample position
  • Stop → sets playing = false, no ticks produced
  • Continue → sets playing = true, continues from current phase
  • Start while playing → no-op

Uses absolute sample position from ClockTick for tick scheduling — no cumulative drift even at non-integer sample-per-tick ratios.

spawn_midi_clock_output() — output actor

Combines MidiClockGenerator + MidiOutput into a single actor:

#![allow(unused)]
fn main() {
use rill_core_actor::ActorSystem;
use rill_io::backends::MidirBackend;
use rill_io::midi_output::MidiOutput;
use rill_patchbay::midi_clock::spawn_midi_clock_output;

let system = ActorSystem::new();
let backend: Box<dyn MidiOutput> = Box::new(
    MidirBackend::new_output_by_name("rill-clock", "My Synth").unwrap()
);
let clock_ref = spawn_midi_clock_output(&system, backend);
}

The actor receives:

  • CommandEnum::ClockTick — via Rack broadcast (automatic, no wiring)
  • CommandEnum::Control(MidiTransport { .. }) — for transport control from API or user code
#![allow(unused)]
fn main() {
use rill_core::queues::CommandEnum;
use rill_core::queues::control_event::{ControlEvent, MidiTransportKind};

// Start clock
clock_ref.send(CommandEnum::Control(ControlEvent::MidiTransport {
    kind: MidiTransportKind::Start,
}));

// Stop clock
clock_ref.send(CommandEnum::Control(ControlEvent::MidiTransport {
    kind: MidiTransportKind::Stop,
}));
}

MidiClockTracker — input-side BPM derivation

The input-side counterpart of MidiClockGenerator. Counts incoming 24ppqn clock pulses (0xF8), derives BPM from pulse intervals via running average, and writes atomically into a shared [SystemClock]. Integrated into MidiHub via MidiHub::with_clock_tracker().

Three pluggable [MidiClockStrategy] implementations:

  • FreeRunning — BPM only, ignores transport
  • ResetOnStart — resets clock position on Start
  • SongPosition — position reset + is_playing() flag

EventPattern matching

Both input and output use the same [EventPattern] matching infrastructure:

#![allow(unused)]
fn main() {
pub enum EventPattern {
    // ... existing ...
    AnyMidi,
    MidiControl { channel: Option<u8>, controller: u8 },
    MidiNote { channel: Option<u8>, note: Option<u8>, kind: MidiNoteKind },
    MidiClock,
    MidiTransport { kind: Option<MidiTransportKind> },
}

pub enum MidiTransportKind { Start, Stop, Continue }

pub enum MidiNoteKind { Frequency, Amplitude, Gate }

pub enum ControlEvent {
    // ... existing ...
    MidiControl { channel, controller, value, normalized },
    MidiNote { channel, note, velocity, on },
    MidiClock,
    MidiTransport { kind: MidiTransportKind },
}
}
  • EventPattern::AnyMidi matches all four MIDI event types
  • EventPattern::MidiTransport { kind: None } matches any transport event

Declarative config: ClockDef + SensorDef::Midi

MIDI input and clock output can be declared in ModularSystemDef JSON documents without writing Rust code.

SensorDef::Midi (input)

{
  "type": "Sensor",
  "Midi": {
    "backend": "midir",
    "port_name": "rill-midi-synth",
    "mappings": [
      {
        "event_pattern": { "MidiControl": { "channel": null, "controller": 7 } },
        "target_node": 1,
        "target_param": "volume",
        "transform": "Linear",
        "min": 0.0,
        "max": 1.0,
        "enabled": true
      }
    ]
  }
}

ClockDef (output)

{
  "type": "Clock",
  "backend": "midir",
  "port_name": "rill-clock",
  "auto_start": true
}

auto_start — when true, sends MidiTransport::Start automatically when the system launches.

Both variants use the existing ModuleFactory infrastructure: MidiConstructor (registered as "midi") and ClockConstructor (registered as "clock").

Programmatic API summary

Input path

#![allow(unused)]
fn main() {
use rill_core_actor::ActorSystem;
use rill_io::midi_input::MidiInput;
use rill_io::backends::MidirBackend;
use rill_patchbay::midi::spawn_midi_sensor;

// Create sensor: backend → polling thread → servo → graph
let backend: Box<dyn MidiInput> = Box::new(MidirBackend::new("rill-midi").unwrap());
let sensor_ref = spawn_midi_sensor("my_midi", backend, &system, servo_ref);
}

Output path

#![allow(unused)]
fn main() {
use rill_io::midi_output::MidiOutput;
use rill_patchbay::midi_clock::spawn_midi_clock_output;

let backend: Box<dyn MidiOutput> = Box::new(
    MidirBackend::new_output_by_name("rill-clock", "My Synth").unwrap()
);
let clock_ref = spawn_midi_clock_output(&system, backend);

// Transport control
use rill_core::queues::control_event::{ControlEvent, MidiTransportKind};
clock_ref.send(CommandEnum::Control(ControlEvent::MidiTransport {
    kind: MidiTransportKind::Start,
}));
}

Output path (declarative)

#![allow(unused)]
fn main() {
use rill_adrift::modular::{ModularSystem, ModularConfig};
use rill_adrift::modular::serialization::{
    ModularSystemDef, RackDef, ModuleDef,
};
use rill_graph::serialization::GraphDef;
use rill_patchbay::module_def::ClockDef;

let def = ModularSystemDef {
    format_version: "rill/1".into(),
    sample_rate: 48000.0,
    block_size: 256,
    racks: vec![RackDef {
        name: "main".into(),
        graph: GraphDef { /* ... */ },
        modules: vec![
            ModuleDef::Clock(ClockDef {
                backend: "midir".into(),
                port_name: "rill-clock".into(),
                auto_start: true,
            }),
        ],
        automatons: vec![],
        mappings: vec![],
        description: None,
    }],
    description: None,
};

let mut system = ModularSystem::<256>::new(ModularConfig::default());
system.launch(&def).unwrap();
// Clock ticks flow automatically via Rack broadcast
}

Feature flags

FeatureCrateEnables
midir (default)rill-ioMidirBackend — cross‑platform MIDI input + output
alsarill-ioAlsaSeqBackend — ALSA sequencer input + output
jackrill-ioJackMidiBackend — JACK MIDI input + output
midirill-patchbayMidiHub, MidiClockTracker, MidiClockGenerator, spawn_midi_sensor(), spawn_midi_clock_output(), serialize_to_midi() — pulls rill-io dependency
midirill-adriftMidiConstructor, ClockConstructor — forward to rill-patchbay/midi

Sensor trait

The [Sensor] trait provides a unified interface for external input sources. All sensors send ControlEvent to a shared ActorRef, drained by Patchbay::drain_events().

#![allow(unused)]
fn main() {
pub trait Sensor: Send + 'static {
    fn attach(&mut self, events: ActorRef<ControlEvent>);
    fn start(&mut self);
    fn stop(&mut self);
}
}

MidiHub implements Sensor. OSC sensors (OscSensor, spawn_osc_sensor), hardware knobs, and acoustic analysis via [Hearing] follow the same pattern — multiple sensors feed one event mailbox with no locking.

MIDI output is NOT a Sensor — it does not produce ControlEvent into the system. Instead, it consumes ClockTick (via Rack broadcast) and sends ControlEvent out to hardware. The MidiOutputActor is an output endpoint, not a sensor.

Hearing — signal analysis for acoustic sensors

The [hearing] module provides signal analysis algorithms for acoustic sensors that react to graph signal output:

AlgorithmWhat it detects
PitchDetectorPitch via autocorrelation
EnvelopeFollowerAmplitude envelope with attack/release
ZeroCrossingFrequency via zero-crossing rate

Each implements Hearing: process(&mut self, audio: &[f32]) -> f32. An AcousticSensor (future) wraps a Hearing implementation, subscribes to graph telemetry, and produces ControlEvents from signal features.

Commands

# Build with MIDIR support (input + output)
cargo check -p rill-io --features "midir"

# Build with ALSA sequencer support (input + output)
cargo check -p rill-io --features "alsa"

# Build patchbay with full MIDI (input + output + clock tracker + clock generator)
cargo check -p rill-patchbay --features midi

# Build drift with MIDI (all features)
cargo check -p drift --all-features

Getting Started

Add rill-adrift to your Cargo.toml:

[dependencies]
rill-adrift = "0.6.0-M2"

For individual crates (if you don't need the full ecosystem):

[dependencies]
rill-core-dsp = "0.6.0-M2"

Example: Signal graph with sine oscillator

This example builds a signal graph with a sine oscillator using the GraphBuilder API and the built-in registry:

use rill_graph::GraphBuilder;
use rill_core::builtin::Registry;

const BUF_SIZE: usize = 256;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut reg = Registry::<f32>::new();
    rill_core_dsp::lang::register::register_lang_builtins(&mut reg);
    rill_lang::register::register_core_builtins(&mut reg);

    let mut builder = GraphBuilder::<f32, BUF_SIZE>::new();
    let osc = builder.add_node("rill/sinosc", &[("freq", 440.0)].into());
    let out = builder.add_node("rill/output", &[].into());
    builder.connect_signal(osc, 0, out, 0);

    // build_ir() produces a GraphIr, then internally calls
    // rill_lang::graph_compiler::compile() to produce a CompiledGraphEngine:
    let engine = builder.build_ir(&reg, 44100.0)?;
    let mut buf = [0.0f32; BUF_SIZE];
    engine.process(Some(&[0.0f32; BUF_SIZE]), &mut buf)?;

    Ok(())
}

Note: For real I/O, use Output / Input from rill-io (feature-gated behind io). The orchestration layer creates the backend and drives the CompiledGraphEngine via process().

Using rill-lang instead of programmatic graphs

rill-lang provides a Faust-style functional DSL for signal processing. Use it to define algorithms declaratively instead of wiring Rust node types:

#![allow(unused)]
fn main() {
use rill_lang::{compile, compile_with, compile_graph};
use rill_core::builtin::Registry;
use rill_core::traits::ParamValue;

// Simple compilation to an Algorithm<T>
let mut prog = compile::<f32>("main = _ * 0.5").unwrap();
let mut out = [0.0f32; 64];
prog.process(Some(&[1.0f32; 64]), &mut out).unwrap();

// Compile with a built-in registry (for DSP primitives)
let registry = rill_adrift::lang_builtins::full_registry::<f32>();
let mut prog2 = compile_with::<f32>(
    "main = sine(?freq) * ?gain",
    &registry,
    44100.0,
).unwrap();
let freq_idx = prog2.param_index("freq").unwrap();
prog2.set_param(freq_idx, ParamValue::Float(440.0));
let gain_idx = prog2.param_index("gain").unwrap();
prog2.set_param(gain_idx, ParamValue::Float(0.5));

// Whole-graph compilation with runtime ?name parameters
const BUF_SIZE: usize = 256;
let mut engine = compile_graph::<f32, BUF_SIZE>(
    "main = sine ?freq=440.0 * ?gain=0.5",
    &registry,
    44100.0,
).unwrap();
engine.handle().send(rill_core::queues::CommandEnum::SetParameter(
    rill_core::queues::SetParameter {
        anchor: "main".into(),
        parameter: "freq".into(),
        value: ParamValue::Float(440.0),
        port: String::new(),
        source: rill_core::queues::SignalOrigin::Manual,
        timestamp: 0,
        sample_pos: None,
    }
)).unwrap();
}

Per-crate registration without rill-adrift

If you depend on individual crates instead of the umbrella rill-adrift, each crate provides its own register_lang_builtins() function to populate a rill_core::builtin::Registry:

#![allow(unused)]
fn main() {
use rill_core::builtin::Registry;

let mut reg = Registry::<f32>::new();
rill_core_dsp::lang::register::register_lang_builtins(&mut reg);
rill_router::register::register_lang_builtins(&mut reg);
rill_digital_effects::register::register_lang_builtins(&mut reg);
// Feature-gated crates follow the same pattern:
// rill_fft::register::register_lang_builtins(&mut reg);
// rill_sampler::register::register_lang_builtins(&mut reg);

let mut prog = compile_with::<f32>("main = sine(440) * gain(0.5)", &reg, 44100.0).unwrap();
}

The rill-adrift crate provides full_registry() as a convenience that aggregates all available per-crate registries in one call.

Using individual DSP algorithms

For algorithm-level processing without the graph infrastructure:

#![allow(unused)]
fn main() {
use rill_core_dsp::generators::basic::SineOsc;
use rill_core_dsp::delay::Delay;
use rill_core::traits::Algorithm;

let sample_rate = 44100.0;
let mut osc = SineOsc::<f32>::new(440.0, sample_rate);
osc.set_amplitude(0.5);

let mut delay = Delay::<f32>::new(0.3, sample_rate);
delay.set_feedback(0.4);

let mut input = [0.0f32; 64];
let mut output = [0.0f32; 64];
Algorithm::process(&mut osc, None, &mut input)?;
Algorithm::process(&mut delay, Some(&input), &mut output)?;
}

Signal I/O

Enable the io feature on rill-adrift (default):

rill-adrift = { version = "0.6.0-M2", features = ["io", "alsa"] }

Available backends: portaudio (default/minimal), alsa (Linux), pipewire (Linux), jack (Linux).

The Input node (push model) drives the graph from the source side. Output (pull model) drives the graph from the sink side. The orchestrator creates the backend and drives the CompiledGraphEngine from the I/O callback.

Two-Thread Architecture

  • Signal thread (hard or soft RT) — runs CompiledGraphEngine::process(). No heap allocs, no locks, no syscalls.
  • Control thread (tokio green threads) — runs Patchbay with automatons (LFO, envelopes, sequencers). Communicates via the actor mailbox (ActorRef<CommandEnum>) returned by engine.handle().

Next steps

Two-Thread Architecture

Rill separates processing into two independent threads communicating through lock-free SPSC queues:

Signal Thread (hard or soft RT)

Runs the process callback — generate()process()consume()Port::propagate(). No heap allocs, no locks, no syscalls. The graph is a single-threaded static DAG — nodes are never added or removed after construction, and topology never changes.

Inside the I/O callback tick:

  1. actor.drain() — applies queued CommandEnum::SetParameter commands from the actor mailbox
  2. Builds a RenderContext with sample clock, transport state, and hardware clock correction
  3. Source::generate() / Processor::process() / Sink::consume() via process_block(&ctx)
  4. Port::propagate() — recursive DAG traversal through direct port pointers
  5. Sends CommandEnum::ClockTick to the parent Patchbay actor

All rill-core::buffer types (DelayLine, TapeLoop, PipeBuffer, RingBuffer, FanOutBuffer, FanInBuffer) are used exclusively inside this path. No atomics, no locks — the graph is a single-threaded static DAG.

Control Thread (tokio green threads)

Runs Patchbay with automatons (LFO, envelopes, sequencers). Communicates with the signal thread via the graph actor mailbox — messages are CommandEnum variants, sent via ActorRef<CommandEnum> and drained inline inside the callback tick. No separate queue types are needed.

Servo — the primary automaton-to-parameter bridge:

  1. Receives CommandEnum::ClockTick from the graph
  2. Advances time and calls automaton.step()
  3. Applies ControlStrategy and ConflictStrategy
  4. Sends CommandEnum::SetParameter to the graph's ActorRef<CommandEnum>
  5. The SetParameter lands in the graph's actor mailbox; next I/O callback tick, actor.drain() applies it

Communication channels

I/O callback tick:                     Actor mailbox (CommandEnum):
  actor.drain()  ◄──────────  SetParameter (servo → graph)
  generate() / process() / consume()
  port.propagate()                    Control path:
  ── ClockTick ──→ Servo ──→ automaton.step()
                             ── SetParameter ──→ graph_ref (next tick drain)

Rule of thumb

If data crosses threads, send CommandEnum variants through ActorRef<CommandEnum>. Everything else is single-threaded within the signal graph running inside the I/O callback.

FFT and Convolution in Rill

The rill-fft crate provides Fast Fourier Transform, frequency-domain convolution, and spectral processing — all RT‑safe, allocation‑free in the process path.

Crate overview

rill-fft is an optional workspace crate, feature‑gated behind fft in rill-adrift (enabled by default). Dependencies: rill-core + rill-core-dsp.

ModuleKey typesPurpose
complex_fftComplexFft<T>Radix‑2 DIT complex FFT (forward + inverse)
real_fftRealFft<T>Real‑valued FFT via two‑for‑one packing
overlap_addOverlapAddConvolver<T, BUF>Frequency‑domain convolution (medium IRs)
partitioned_convPartitionedConvolver<T, BUF>Partitioned convolution (long IRs)
spectrumFftSpectrumAnalyzer<T>FFT‑based spectrum analyser
effectsSpectralGate, SpectralDelayFrequency‑domain effects
nodesConvolverNodeGraph‑node wrappers

All types are generic over T: Transcendental, supporting both f32 and f64.

RT safety

Every scratch buffer — twiddle tables, overlap accumulators, spectrum arrays, delay‑line ring buffers — is pre‑allocated in the constructor. The process() methods perform zero heap allocations, verified by a custom panic‑on‑alloc allocator in tests/rt_safety.rs.

#![allow(unused)]
fn main() {
use rill_fft::complex_fft::ComplexFft;

// All buffers pre-allocated here:
let fft = ComplexFft::<f32>::new(1024);

// In the signal thread — zero allocations:
fft.forward(&mut data);
fft.inverse(&mut data);
}

No unsafe code — #![deny(unsafe_code)] is enforced.

FFT

Complex FFT

ComplexFft<T> implements the Cooley–Tukey radix‑2 Decimation‑In‑Time algorithm. Twiddle factors and bit‑reversal tables are pre‑computed on construction. Sizes must be powers of two, ≥ 2.

#![allow(unused)]
fn main() {
use rill_fft::complex_fft::ComplexFft;
use num_complex::Complex;

let fft = ComplexFft::<f32>::new(1024);

// Fill data buffer
let mut data: Vec<Complex<f32>> = (0..1024)
    .map(|i| Complex::new((i as f32 * 0.1).sin(), 0.0))
    .collect();

// Forward transform (in-place)
fft.forward(&mut data);

// Manipulate spectrum here...

// Inverse transform (in-place, scaled by 1/N)
fft.inverse(&mut data);
}

Real FFT

RealFft<T> uses the half‑size complex FFT with real packing/unpacking (two‑for‑one method). Transforms N real samples into N/2 + 1 complex bins:

#![allow(unused)]
fn main() {
use rill_fft::real_fft::RealFft;
use num_complex::Complex;

let mut fft = RealFft::<f32>::new(1024);

let input: Vec<f32> = (0..1024).map(|i| (i as f32 * 0.1).sin()).collect();
let mut spectrum = vec![Complex::new(0.0, 0.0); 513];

// Forward: N real → N/2+1 complex
fft.forward(&input, &mut spectrum);

// Inverse: N/2+1 complex → N real
let mut reconstructed = vec![0.0f32; 1024];
fft.inverse(&spectrum, &mut reconstructed);
}

Only the non‑redundant half of the spectrum is stored — bins 0 (DC) and N/2 (Nyquist) are purely real.

Convolution

Rill provides three convolution methods, each optimal for a different impulse response length.

DirectConvolver — short IRs (up to ~128 samples)

Time‑domain convolution in rill-core-dsp. Stack‑allocated via const generics, no heap allocations at all. Best for short filtering, EQ emulation, cabinet simulation with short IRs.

#![allow(unused)]
fn main() {
use rill_core_dsp::DirectConvolver;

let mut conv = DirectConvolver::<f32, 64, 128>::new();
conv.set_ir(&[0.3, 0.5, 0.2, 0.1, 0.0, /* ... */]);

let input = vec![0.5f32; 128];
let mut output = vec![0.0f32; 128];
conv.process(Some(&input), &mut output).unwrap();
}

Implements Algorithm<T> — usable as a port‑level algorithm in any graph node.

OverlapAddConvolver — medium IRs (256…16384 samples)

Frequency‑domain convolution via real FFT. The IR is FFT‑transformed once on set_ir(). Each input block is FFT‑transformed, multiplied by the IR spectrum, inverse‑transformed, and overlap‑added to produce the output.

#![allow(unused)]
fn main() {
use rill_fft::overlap_add::OverlapAddConvolver;

let mut conv = OverlapAddConvolver::<f32, 128>::new(2048);

let ir: Vec<f32> = load_wav("reverb.wav");
conv.set_ir(&ir);

let input = [0.5f32; 128];
let mut output = [0.0f32; 128];
conv.process(&input, &mut output);
}

PartitionedConvolver — long IRs (up to hundreds of thousands of samples)

Uniform partitioned convolution. The IR is split into partitions of BUF_SIZE samples; each partition is FFT‑transformed once. Input blocks are FFT‑transformed and stored in a circular buffer. Scales to IRs of hundreds of thousands of samples with predictable per‑block cost.

#![allow(unused)]
fn main() {
use rill_fft::partitioned_conv::PartitionedConvolver;

// 5-second reverb at 44.1 kHz ≈ 220 500 samples
let mut conv = PartitionedConvolver::<f32, 128>::new(220_500);

let ir: Vec<f32> = load_wav("cathedral.wav");
conv.set_ir(&ir);

let input = [0.5f32; 128];
let mut output = [0.0f32; 128];
conv.process(&input, &mut output);
}

Choosing a convolution method

IR lengthMethodPer‑block cost (128‑sample block)
≤ 128DirectConvolver~10 µs
256…16384OverlapAddConvolver~60 µs (IR 2048)
> 16384PartitionedConvolver~104 µs (IR 65536)

At 44.1 kHz, the per‑block budget is ~2.9 ms. Even the partitioned convolver with a 65536‑sample IR uses only ~3.6 % of the budget.

Spectrum analysis

FftSpectrumAnalyzer<T> implements the SpectrumAnalyzer trait from rill-core-dsp. It applies a Hann window, runs the real FFT, and computes per‑bin magnitudes:

#![allow(unused)]
fn main() {
use rill_fft::spectrum::FftSpectrumAnalyzer;
use rill_core_dsp::analyzer::SpectrumAnalyzer;

let mut analyzer = FftSpectrumAnalyzer::<f32>::new(256);

// Feed blocks of signal data
analyzer.process(Some(&signal_block), &mut output).unwrap();

// Query the magnitude spectrum
let spectrum = analyzer.spectrum();        // &[f32] — N/2+1 bins
let amp_440 = analyzer.amplitude_at(440.0, 44100.0);
}

Implements Algorithm<T> directly — can be used as a port‑level analyser in any graph node.

Frequency‑domain effects

SpectralGate

A frequency‑domain noise gate. Transforms the signal block via overlap‑add FFT, silences bins whose magnitude falls below a threshold, then transforms back. Useful for noise reduction and creative spectral gating:

#![allow(unused)]
fn main() {
use rill_fft::effects::spectral_gate::SpectralGate;

let mut gate = SpectralGate::<f32, 128>::new();
gate.set_threshold(0.01);
gate.set_ratio(0.0);  // 0.0 = hard gate, 1.0 = passthrough

gate.process(&input, &mut output);
}

SpectralDelay

Applies different delay times to different frequency bins. Lower frequencies receive longer delays, creating metallic resonances, comb‑filter sweeps, and spectral shimmer effects. Stores a circular buffer of past FFT frames:

#![allow(unused)]
fn main() {
use rill_fft::effects::spectral_delay::SpectralDelay;

// MAX_DELAY = 16 frames (~2048 samples at BUF=128)
let mut delay = SpectralDelay::<f32, 128, 16>::new();
delay.set_mix(0.5);
delay.set_feedback(0.3);

delay.process(&input, &mut output);
}

Graph integration

ConvolverNode wraps PartitionedConvolver as a Processor graph node, registered as "rill/convolver" when the fft feature is enabled:

[Source] → [ConvolverNode] → [Sink]

Parameters: ir_gain (0.0–4.0), mix (0.0–1.0), ir_loaded (bool).

To load an impulse response at runtime, obtain a reference to the node and call set_ir():

#![allow(unused)]
fn main() {
use rill_fft::nodes::convolver_node::ConvolverNode;

// After graph construction
let node: &mut ConvolverNode<f32, 128> = graph.get_node_mut(node_id).unwrap();
node.set_ir(&ir_samples);
}

Performance (f32, x86_64, release profile)

OperationSizeTimeThroughput
ComplexFft::forward10246.7 µs153 Melem/s
RealFft::forward10246.2 µs165 Melem/s
ComplexFft::forward16384177 µs92 Melem/s
OverlapAddConvolverIR 2048, BUF 12861 µs/block~2100 blocks/s
PartitionedConvolverIR 65536, BUF 128104 µs/block~9600 blocks/s
DirectConvolver128 taps, BUF 12810 µs/block12.7 Melem/s

The 16384‑point FFT shows O(N log N) scaling: 5.7 × larger than 1024, 11.6 × slower (5.7 log₂ 5.7 ≈ 14.3). Efficiency drops from 153 Melem/s at 1024 to 92 Melem/s at 16384 — mainly due to L2/L3 cache effects on the twiddle table.

DIY FFT design note

rill-fft implements a home‑grown Cooley–Tukey FFT rather than wrapping rustfft or realfft. The decision was driven by:

  1. RT control — scratch buffers and twiddle tables are fully owned by the FFT struct, allocated exactly once in the constructor. Third‑party libraries often use internal scratch allocation during process().

  2. Safe Rust#![deny(unsafe_code)] guarantees no UB in the FFT path. The wide crate provides safe SIMD wrappers when the simd feature is enabled.

  3. Minimal dependencies — only num-complex (already in rill-core-dsp for filter design) and num-traits (workspace dep).

  4. Predictable shapes — sizes are always powers of two, matching the radix‑2 algorithm perfectly.

Benchmarks show the implementation is competitive with established FFT libraries for the target sizes (64…16384), and the RT‑safety guarantees are verified at the allocator level.

Complex number support

Complex matrix helpers (rill-core-dsp)

ComplexMat2<T> and ComplexMat3<T> provide closed‑form 2×2 and 3×3 complex matrix operations for filter analysis and RT signal processing:

#![allow(unused)]
fn main() {
use rill_core_dsp::complex_mat::ComplexMat2;
use num_complex::Complex;

let m = ComplexMat2::<f32>::new(
    Complex::new(2.0, 0.0), Complex::new(1.0, 0.0),
    Complex::new(1.0, 0.0), Complex::new(3.0, 0.0),
);

let det = m.det();
let inv = m.inv().unwrap();
let ev = m.eigenvalues().unwrap();
let [re, _im] = m.mul_vec(Complex::new(1.0, 0.0), Complex::new(0.0, 0.0));
}

Free functions provide canonical complex multiplication:

#![allow(unused)]
fn main() {
use rill_core_dsp::complex_mat::{mul_complex, mul_complex_add};
use num_complex::Complex;

let a = Complex::new(1.0f32, 2.0);
let b = Complex::new(3.0, 4.0);
let c = mul_complex(a, b);  // a * b

let mut acc = Complex::new(0.0, 0.0);
mul_complex_add(&mut acc, a, b);  // acc += a * b
}

Real‑valued matrix types (glam)

glam is re‑exported from rill-core — zero dependencies, stack‑only, SIMD‑accelerated Mat2/Mat3/Mat4 and Vec2/Vec3/Vec4:

#![allow(unused)]
fn main() {
use rill_core::glam::{Mat2, Vec2, mat2, vec2};

let rot = mat2([0.866, 0.5], [-0.5, 0.866]);  // 60° rotation
let v = vec2(1.0, 0.0);
let r = rot * v;  // ≈ [0.866, 0.5]
}

Complex numbers in rill‑lang

Eight built‑ins provide complex arithmetic in the DSL:

BuiltinI/O channelsDescription
complex(re, im)0 → 2Generator
conj(x)2 → 2Conjugate
re(x), im(x)2 → 1Real / imaginary part
norm(x)2 → 1Magnitude
arg(x)2 → 1Phase (atan2)
cmul(a, b)4 → 2Complex multiply
cadd(a, b)4 → 2Complex add
// (3+4i) × (2+0i) = 6+8i → extract real part
main = complex(3.0, 4.0), complex(2.0, 0.0) : cmul() : re();  // → 6.0

// norm of 3+4i
main = complex(3.0, 4.0) : norm();  // → 5.0

Spectral effects are also available as DSL builtins behind the fft feature:

main = _ : spectralgate(0.01, 0.0);             // spectral noise gate
main = _ : spectraldelay(0.5, 0.3);             // shimmer
main = _ : spectralgate(0.01, 0.0) : spectraldelay(0.5, 0.3);  // chain

Examples

All examples live in rill-adrift/examples/:

cargo run --example convolver        --features fft
cargo run --example spectral_effects --features fft
cargo run --example complex_dsl      --features lang
cargo run --example dsl_spectral     --features "lang,fft"

Dependencies and features

Add to your Cargo.toml:

[dependencies]
rill-adrift = { version = "0.6.0-M2", features = ["fft"] }
# Or directly:
rill-fft = "0.6.0-M2"

The simd feature forwards to rill-core/simd, enabling wide‑based SIMD acceleration for FFT butterflies.

Chip Emulators

Rill provides vintage sound chip emulation through a three-layer architecture: ChipBackendLofiInput. This guide covers the AY-3-8910 emulator in rill-lofi.

Architecture

Every chip emulator follows the same model:

┌──────────────┐    ┌────────────────────┐    ┌───────────────────┐
│  Ay38910Chip │    │  LofiChipSource    │    │  LofiInput<f32,N>  │
│  Algorithm   │───►│  wraps chip,       │───►│  Source node       │
│  + ChipEmul. │    │  drives generation │    │  lofi processing   │
└──────────────┘    └────────────────────┘    └───────────────────┘

1. Chip (Ay38910Chip) — pure logic

Contains only the chip's digital model — registers, tone generators, noise LFSR, envelope. No signal I/O, no graph integration, no lofi processing. Directly testable.

AY-3-8910 register map (16 × 8-bit):

R#NameBitsDescription
R0–R1Tone A period12f = 1.75 MHz / (16 × TP)
R2–R3Tone B period12
R4–R5Tone C period12
R6Noise period5f = 1.75 MHz / (16 × NP)
R7Mixer8Bits 0–2: tone A/B/C, 3–5: noise A/B/C (0=ON)
R8–R10Volume A/B/C5Bit 4: envelope mode, bits 0–3: 0–15
R11–R12Envelope period16f = 1.75 MHz / (256 × EP)
R13Envelope shape4Continue, Attack, Alternate, Hold
R14–R15I/O port A/B8Not implemented (audio only)
#![allow(unused)]
fn main() {
use rill_adrift::lofi::Ay38910Chip;

let mut chip = Ay38910Chip::new(1_750_000.0); // 1.75 MHz clock
chip.write_register(0, 0x17); // tone period low
chip.write_register(1, 0x01); // tone period high → 279 → ~392 Hz
chip.write_register(8, 0x0A); // volume 10 (fixed)
chip.write_register(7, 0x38); // mixer: Ch A tone+noise ON, B/C tone ON

let sample = chip.generate_sample(44100.0);
}

2. LofiChipSource — wraps chip as Algorithm

LofiChipSource wraps Ay38910Chip (which implements Algorithm<f32> + ChipEmulator) and drives sample generation. Register writes go through set_parameter("register_write", bytes). Signal generation via chip.process(None, &mut out).

#![allow(unused)]
fn main() {
use rill_adrift::lofi::{Ay38910Chip, LofiChipSource};

let mut chip = Ay38910Chip::new(1_750_000.0, 44100.0);
let regs = [0x17, 0x01, 0, 0, 0, 0, 0, 0x38, 0x0A, 0, 0, 0, 0, 0, 0, 0];
chip.set_parameter("register_write", ParamValue::Bytes(regs.into()))?;

let mut buf = [0.0f32; 256];
chip.process(None, &mut buf)?;
}

3. LofiInput — Source node in the graph

LofiInput wraps a LofiChipSource and applies vintage degradation: bitcrushing, noise floor, DAC nonlinearity, delay. Configured at construction and runtime-tunable via set_parameter.

In a typical graph (e.g., chiptune.rs): the sequencer (via Servo + Automaton) sends register bytes to LofiInput.set_parameter("register_write", ParamValue::Bytes(regs)), which forwards to the chip.

[SequencerAutomaton] → [Servo] → SetParameter("register_write", regs)
                                       │
┌─────────────────────────────────────┘
▼
Graph tick: actor.drain()
  → LofiInput.set_parameter("register_write", regs)  // writes registers
  → LofiInput.generate()                             // reads chip, lofi processing
  → propagate → Output                               // signal to device

Full example: AY-3-8910 chiptune player

See rill-adrift/examples/chiptune.rs — uses ModularSystemDef with SequencerAutomaton, table-based Servo, and LofiInput + Ay38910Backend.

See rill-adrift/examples/chiptune_stc.rs — loads .stc tracker files, demonstrates ModuleFactory for custom rack modules.

Lofi processing

LofiInput processes each sample through this chain:

input → bitcrush → sample-rate reduction → noise → DAC emulation → delay → dry/wet mix → output_gain

Configurable via set_parameter:

ParameterTypeDefaultDescription
enable_bitcrushBooltrueQuantization to bit_depth bits
enable_noiseBooltrueVintage noise floor (dB → linear)
enable_sr_reductionBooltrueSample-rate decimation
dry_wetFloat1.0Wet/dry mix (0.0 = dry, 1.0 = fully processed)
output_gainFloat1.0Output gain (0.0–4.0)

For ClassicSystem::Custom, three parameters are set at construction via LofiConfig:

ParameterExampleDescription
bit_depth8Quantization bit depth
nonlinearfalseNon-linear encoding (dead code for Custom)
noise_floor-48.0Noise floor in dB

Important: The lofi chain with default settings (bitcrush=8, noise=-48dB, DAC emulation) aggressively degrades the signal. For a clean chiptune tone, tune the parameters — higher bit_depth, lower noise_floor, or bypass via enable_bitcrush=false + enable_noise=false.

Known limitations

Emulator accuracy

The AY-3-8910 emulator is a functional model, not a cycle-accurate replica. It produces recognisable AY-like audio suitable for music playback, but differs from real hardware in these ways:

AspectCurrent behaviourReal AY-3-8910
Output sampling1 sample per generate_sample() callContinuous analog output with infinite bandwidth
Anti-aliasingNoneImplicit in analog stage (amplifier bandpass)
Noise LFSR17-bit, output = bit 0, polynomial x^17+x^14+1Same LFSR, but output filtered by analog stage
Envelope4-bit mode, 16-bit period, linear rampSame, but real chip has minor non-linearities
Register changesApplied at start of next generate_sample() block (up to 1/sample_rate delay)Applied at next tone period boundary
I/O ports (R14–R15)Not implementedBidirectional 8-bit GPIO
YM2149 compatibilityNot implementedYM variant has /2 clock divider, minor differences

Timing accuracy

  • Tone frequency: Formula correct (f_clock / (16 × TP)), phase accumulator preserves fractional remainder → no long-term drift. Frequency accuracy ≈ 0.05% at 44100 Hz.
  • Envelope timing: Formula correct (f_clock / (256 × EP), fixed in 0.6.0-M2). Envelope steps are discrete (16 per cycle), exact transition times depend on sample rate.
  • Noise timing: Formula correct (f_clock / (16 × NP)). Output bit sampled at audio rate without bandlimiting → aliasing folds high-frequency noise into audible range.
  • STC interrupt rate: 48.828125 Hz (f_clock / 35840), approximated via step_ms() with floating-point accumulator → sub-microsecond jitter.

Phase relationship between tone, noise, and envelope

All three generators run from the same master clock but are sampled independently in generate_sample():

  1. Tone phase is advanced
  2. Noise and envelope states are read (from their previous state)
  3. Channel outputs computed
  4. Noise phase advanced (update_noise)
  5. Envelope phase advanced (update_envelope)

This means noise and envelope are always one sample behind tone in the same block. At 44100 Hz this is 22.7 µs — inaudible, but means phase correlation measurements will differ from hardware by one sample period.

Available chips

ChipStructsRegistersFeatures
AY-3-8910Ay38910Chip, LofiChipSource16 × 8-bit3 tone channels, noise LFSR, envelope

ParameterWrite trait

Chip emulators implement the ParameterWrite trait from rill-core for register-level control:

#![allow(unused)]
fn main() {
pub trait ParameterWrite {
    fn write_parameter(&mut self, name: &str, value: ParamValue) -> ProcessResult<()>;
    fn read_parameter(&self, name: &str) -> Option<ParamValue> { None }
}
}

Register writes use set_parameter("register_write", ParamValue::Bytes(regs)).

Real-Time Safety

The signal graph runs wherever the IoBackend process callback fires. All backends are callback-driven — they invoke the rill process callback(s) — but differ in which thread runs them.

Two backend models

ModelBackendsRT guarantee
Hardware callbackPipeWire, JACK, PortAudioHard RT — the audio system calls the process callback on its own real-time thread. No syscalls, no allocation, no locks.
Own audio threadALSASoft RT — the backend runs its own audio thread that waits on the device FDs with snd_pcm_wait (event-driven, never thread::sleep()) and fires the same process callbacks per period.

Rules for the RT path (applies to both models)

Any code reached from the process callback — generate(), process(), consume(), propagate(), and everything they call — must obey:

RuleRationale
No heap allocation in RT pathVec::new(), Box::new(), format!() inside propagate/generate/process/consume will cause xruns. All buffers must be stack-allocated or pre-allocated at graph construction.
No locks in RT pathMutex::lock(), RwLock::write() (even parking_lot) may spin. Communication with the control thread uses only rill_core::queues::MpscQueue (lock-free SPSC).
No thread::sleep() in RT paththread::sleep() is a syscall — it blocks the calling thread, introduces timing jitter, and makes deterministic scheduling impossible. Backends that run their own audio thread (ALSA) must wait on the device FDs (snd_pcm_wait / poll), never on sleep.
No file I/O, no socket I/O in RT pathAny syscall (open, read, write, send, recv) can block unpredictably.
downstream_nodes is pre-filledPort::downstream_nodes is populated once by GraphBuilder::build() and iterated at runtime without deduplication or allocation.
Fixed-size stack buffersBackend callbacks must use [f32; MAX_BLOCK_SAMPLES] (512) instead of vec![].

Allowed exceptions

  • MpscQueue::pop() — lock-free atomic, OK on RT.
  • AtomicU32::fetch_add() / AtomicBool::store() — OK on RT.
  • Raw pointer dereference (*mut, *const) — single-threaded DAG, guaranteed valid.
  • IoRingBuffer::read() / write() — lock-free atomic SPSC, OK on RT (used inside backends only, not in graph nodes).

Known issues

  1. Backends with their own audio thread (ALSA) must not use thread::sleep() — wait on the device FDs (snd_pcm_wait / poll) instead. All current backends (PortAudio, ALSA, PipeWire, JACK) respect this rule.
  2. Testing RT code — any new RT path code must be verified with cargo test --release under pw-loopback or similar virtual device to detect xruns.

RT-safety unit tests

rill-fft includes automated RT-safety integration tests (tests/rt_safety.rs) using a custom #[global_allocator] that panics on any heap allocation or deallocation during process() calls. The allocator guard uses thread_local! to isolate test threads, allowing tests to run in parallel. Run with:

cargo test -p rill-fft --test rt_safety

All FFT, convolution, and spectral effect process() paths are verified zero-allocation.

Debugging Rill Applications

Rill provides a two-level diagnostic infrastructure: runtime telemetry (signal probes, command logging) and an interactive debugger (rill-analyzer). Both are gated behind the debug Cargo feature — zero overhead in production builds.

Architecture

┌─────────────────────────────────────────────────┐
│ rill-lang (core, feature = "debug")             │
│  ProbePoint IR  +  ProbeSlot  +  DebugControl   │
│  1 new Instr variant, zero-cost when disabled    │
└──────────────┬──────────────────────────────────┘
               │ depends on
┌──────────────▼──────────────────────────────────┐
│ rill-telemetry (diagnostic infrastructure)       │
│  ProbeStateManager  +  CollectorThread           │
│  CommandFormatter  +  ShmemRegion (IPC)          │
└──────────────┬──────────────────────────────────┘
               │ depends on
┌──────────────▼──────────────────────────────────┐
│ rill-analyzer (CLI + REPL)                      │
│  gdb-style interactive debugger                 │
│  Lua scripting, JSON output                     │
│  attach/launch via shared memory                │
└─────────────────────────────────────────────────┘

All diagnostic data flows from the signal thread (RT) through lock-free SPSC queues to a collector thread (non-RT), which formats and outputs events. No allocations, no locks, no syscalls in the signal path.

Enabling Debugging

Add the debug feature to your Cargo features:

cargo build --features "debug"

For ModularSystem-based applications (using rill-adrift):

cargo run --example chiptune_stc --features "lofi,pipewire,io,debug" -- --file music.stc pipewire

The debug feature activates rill-lang/debug, rill-graph/debug, rill-telemetry/debug, and rill-patchbay/debug.

Signal Probes

How Probes Work

Each graph node gets an automatic probe at its output. The probe captures the first sample of every processed block and pushes it to a lock-free SPSC queue. A collector thread drains the queue and formats the output.

Probes are identified by the node name (node_0, node_1, etc.) and report both the block index and the signal value:

[block 1] probe[0] node_0 = 0
[block 2] probe[0] node_0 = 0
...
[block 20418] probe[0] node_0 = 0.4000
[block 20419] probe[0] node_0 = 0.2888

Probe Lifecycle

  1. build_ir() inserts a ProbePoint IR instruction after the node's CallBlock
  2. The engine allocates ProbeSlots — one per node — each with atomic flags (enabled, break_flag, paused_flag) and an SPSC queue
  3. During processing, the engine captures the output buffer's first sample and pushes a ProbeFrame { value_bits, block_index } into the queue
  4. CollectorThread drains the queue and formats the event via TextFormatter (colored terminal) or JsonFormatter (JSON lines)

Enabling Specific Probes

Probes are auto-enabled in ModularSystem::launch() for each graph node. To enable/disable individual probes, use rill-analyzer:

(rla) enable <probe_id>
(rla) disable <probe_id>

Command Logging

Every SetParameter command that successfully routes to a program parameter is logged. This lets you trace who changes what parameter and when:

[block 17] cmd SetParameter →  register_write: Bytes([112, 4, 0, 0, 124, ...])
[block 33] cmd SetParameter →  register_write: Bytes([112, 4, 0, 0, 124, ...])

After the KeyFrame API is enabled with debug, the log output includes:

  • block_index — which processing block received the command
  • command_kindSetParameter, ClockTick, etc.
  • param_name — the parameter being modified
  • value_repr — human-readable value representation

Commands that fail to route (parameter name not found, node not found) are silently ignored and do not appear in the command log. This makes the log a reliable indicator of successful parameter application.

Pause and Resume

The debugger can pause the engine between processing blocks. The engine spins on an AtomicBool — no syscalls, no locks:

#![allow(unused)]
fn main() {
// Spin if paused, until resume
while self.debug_control.global_pause.load(Acquire)
    && !self.debug_control.global_resume.load(Acquire)
{
    std::hint::spin_loop();
}
}

The collector thread monitors FLAG_PAUSED in the shared memory region and calls debug_control.pause() / debug_control.cont() accordingly.

Inter-Process Debugging via Shared Memory

For debugging a running process, rill-analyzer uses a shared memory region at /dev/shm/rill-debug-<pid>. The region contains:

Offset  Size    Field
─────────────────────────────────────
0       4       magic (0x52494C4C = "RILL")
4       4       version
8       8       process_pid
16      8       debugger_pid
24      4       flags (PAUSED | ATTACHED | SHUTDOWN)
28-64   …       ring buffer positions
64      ~32KB   CmdRingBuffer  (debugger → process)
~32KB   ~32KB   RespRingBuffer (process → debugger)

Each ring buffer is a lock-free SPSC circular buffer. Frames are serialized with serde_cbor. The debugger sends AnalyzerCommand through CmdRingBuffer, the process responds with AnalyzerResponse through RespRingBuffer.

Signal protocol: Only the debugger sends SIGUSR1 to the rill process. The process never sends signals to the debugger — responses are read via polling.

Attach Mode

rill-analyzer attach 12345
  1. Opens /dev/shm/rill-debug-12345
  2. Verifies magic and version
  3. Registers as debugger (writes its PID)
  4. Enters REPL — commands go through the shmem ring buffer

Launch Mode

rill-analyzer launch ./my-app -- --flag value
  1. Creates shmem region
  2. Forks and executes the target with RILL_DEBUG_SHMEM in the environment
  3. Child process opens the shmem and sets FLAG_ATTACHED
  4. Parent waits for the flag, then enters REPL

If the target ends with .json, it's treated as a serialized graph and launched via drift --graph. If it ends with .rll, it's a rill-lang DSL source — compiled and launched via drift.

Lifecycle Logging

When debug feature is enabled, ModularSystem::launch() adds lifecycle logging via the log crate:

rill-adrift: launching rack 'chiptune_stc' — 1 nodes, 1 modules
rill-adrift: rack 'chiptune_stc' engine built — 1 programs
rill-adrift: rack 'chiptune_stc' backend 'pipewire' started
rill-adrift: system launched with 1 rack(s)
rill-adrift: stopping system

Use RUST_LOG=info to see these logs, or integrate with your preferred logger implementation.

RT Safety

All diagnostic data transport uses lock-free atomics and SPSC queues. The signal thread (RT) never allocates, locks, or blocks. The collector thread (non-RT) handles formatting, I/O, and IPC.

Forbidden in the RT path: log::info!, eprintln!, println!, any file or socket I/O. The only permitted path for RT diagnostics is pushing data through SPSC queues and atomics.

Patchbay Inspector

Beyond signal probes, the debug infrastructure can inspect control-path state:

#![allow(unused)]
fn main() {
// Automaton state (via rill-analyzer)
(rla) info automatons

// Sensor status (MIDI, OSC)
(rla) info sensors
}

The PatchbayInspector collects snapshots of Servo automaton state (enabled, value, time) and Sensor status (connected, event count) through DashMap-backed registries.

rill-analyzer — Interactive Debugger

rill-analyzer is a gdb-style interactive debugger for Rill signal processing applications. It connects to running processes via shared memory, inspects signal values at probe points, traces parameter changes, and controls execution (pause, step, continue).

Installation

rill-analyzer is built as part of the workspace:

cargo build --release -p rill-analyzer

The binary supports three operating modes:

ModeCommandUse case
Localrill-analyzer run <graph.json>Run a graph locally with embedded debugger
Attachrill-analyzer attach <pid>Connect to a running rill process
Launchrill-analyzer launch <target>Start a process and connect immediately

Local Mode

rill-analyzer run graph.json

Loads a serialized graph, creates a CompiledGraphEngine, and opens an interactive REPL. The debugger runs in the same process — signals, commands, and probe data all flow through inter-thread channels.

Options:

  • --no-repl — only log telemetry, no interactive prompt
  • --json — machine-parseable JSON output
  • --log <file> — write telemetry to a log file
  • --script <file> — execute a Lua script in batch mode

Attach Mode

rill-analyzer attach 12345

Connects to PID 12345 through the shared memory region at /dev/shm/rill-debug-12345. The target process must be compiled with --features debug and have created the shmem region (automatic with ModularSystem::launch() or manual via rill_adrift::debug_init::init_shmem()).

Attach flow:

  1. Opens the shmem region
  2. Verifies the magic number (RILL) and version
  3. Registers as debugger (writes its PID to the control header)
  4. Opens a REPL — all commands and responses go through lock-free ring buffers

Launch Mode

# Serialized graph
rill-analyzer launch graph.json

# Rill-lang DSL source
rill-analyzer launch chip.rll

# Arbitrary binary with arguments
rill-analyzer launch ./my-app -- --verbose --port 8080

# Cargo command
rill-analyzer launch -- cargo run --example chiptune_stc -- --file music.stc pipewire

When the target ends with .json, it's launched via drift --graph <file>. When it ends with .rll, the source is compiled and launched via drift. Otherwise, the target is executed directly with RILL_DEBUG_SHMEM in the environment.

REPL Commands

The REPL uses prefix-matching — b for break, c for continue, p for print, etc.

Execution Control

CommandShortcutDescription
break <probe>bSet a breakpoint at the given probe
clear [<probe>]Clear breakpoint(s)
continuecResume execution
step [<n>]sExecute N blocks, then pause
pausePause the engine
quitqExit the debugger

Inspection

CommandShortcutDescription
info nodesi nodesList all graph nodes with arity
info probesi probesList all probes with status (ON/OFF/BREAK) and last value
print <probe>pShow the last value of a specific probe
watch <probe>wEnable continuous probe output
unwatch <probe>Disable continuous probe output

Command Tracing

CommandDescription
trace commandsEnable command logging (shows all SetParameter, ClockTick)
untrace commandsDisable command logging

Control-Path Inspection

CommandDescription
info automatonsList all registered automatons (servos)
info sensorsList all registered sensors (MIDI, OSC)
info queuesShow queue statistics (capacity, fill level)

Example Session

$ rill-analyzer launch chiptune_stc -- --file music.stc --no-wait pipewire
[rill-analyzer 0.1] launched PID 118258 (shmem: /dev/shm/rill-debug-118258)

(rla) info nodes
  #0    node_0            in:0 out:1

(rla) b node_0
  Breakpoint set on probe 'node_0'

(rla) c
  [rill-analyzer] running...

(rla) p node_0
  node_0 = 0.4000

(rla) w node_0
  [block 20419] probe[0] node_0 = 0.2888
  [block 20420] probe[0] node_0 = 0.1777
  [block 20421] probe[0] node_0 = 0
  ...

(rla) q

Lua Scripting

rill-analyzer embeds Lua 5.4 via mlua. All REPL commands are exposed as Lua functions:

-- .rill-analyzer.lua — auto-loaded on startup
set_breakpoint("node_0")
continue()

while true do
    local val = get_value("node_0")
    if val > 0.9 then
        print(string.format("CLIPPING: %.4f", val))
    end
    step(1)
end

Available Lua functions:

FunctionREPL equivalent
set_breakpoint(probe)break <probe>
clear_breakpoint(probe)clear <probe>
continue_()continue
step(n)step [<n>]
pause()pause
get_value(probe)print <probe>
list_probes()info probes
list_nodes()info nodes

The .rill-analyzer.lua file in the current directory is auto-loaded on startup. Use --script <file> for explicit script execution.

JSON Output Mode

When --json is specified, all output is formatted as JSON lines (one object per line):

{"type":"probe","probe":"node_0","value":0.4000,"frame":20418}
{"type":"command","frame":20417,"kind":"SetParameter","node":"","param":"register_write","value":"Bytes([112,4,0,0,124,...])"}
{"type":"break","probe":"node_0","value":0.4000,"frame":20418}

This mode is designed for agent-based automation and scripting — each line is a complete, self-contained JSON object.

Architecture

┌──────────────────────────────────────────────────────────┐
│ RILL PROCESS (debug feature enabled)                     │
│                                                          │
│  RT THREAD           SpscQueue        COLLECTOR THREAD   │
│  Engine.process() ─────────────────→  drains probes      │
│  probe capture                        drains commands    │
│  command logging                       ↕ shmem ring buf  │
│  debug_control ←──── atomics ──────→  resp → debugger    │
│                                     cmd ← debugger       │
└─────────────────────────────────────┬────────────────────┘
                                      │ /dev/shm/rill-debug-<pid>
┌─────────────────────────────────────│────────────────────┐
│ rill-analyzer                       │                    │
│  REPL → stdin → cmd_tx ─────────────┘                    │
│  stdout ← formatter ← resp_rx                            │
└──────────────────────────────────────────────────────────┘

Мир Автоматов (The World of Automatons)

Rill Patchbay — это не просто система управления. Это мир, в котором живут автоматы — загадочные существа, которые чувствуют окружающую среду и влияют на неё. Они общаются на языке сигналов, через сенсоры и серво воздействуют на Graph, который управляет звуком.

Примеры ниже используют аудио-сенсоры, но паттерн «автомат → сенсор → серво» применим к любой области: IoT-телеметрия, управление роботами, SCADA, визуализация CAN-шины. Единственное, что меняется — тип сенсора на входе.

Архитектура мира

┌─────────────────────────────────────────────────────┐
│                 МИР АВТОМАТОВ                         │
│  (ваше приложение на Rill)                      │
│                                                       │
│  ┌─────────────────────────────────────────────────┐ │
│  │                    PATCHBAY                       │ │
│  │  ┌─────────────────────────────────────────┐    │ │
│  │  │           АВТОМАТЫ (разум)              │    │ │
│  │  │  ┌──────────┐  ┌──────────┐  ┌──────────┐ │ │
│  │  │  │   LFO    │  │   ENV    │  │  RANDOM  │ │ │
│  │  │  └────┬─────┘  └────┬─────┘  └────┬─────┘ │ │
│  │  │       │             │             │       │ │
│  │  └───────┼─────────────┼─────────────┼───────┘ │ │
│  │          │             │             │         │ │
│  │          ▼             ▼             ▼         │ │
│  │  ┌─────────────────────────────────────────┐   │ │
│  │  │           СЕНСОРЫ (чувства)              │   │ │
│  │  │  • Слышат звук (акустические)           │   │ │
│  │  │  • Чувствуют прикосновения (физические) │   │ │
│  │  │  • Видят MIDI/CV                         │   │ │
│  │  └─────────────────────────────────────────┘   │ │
│  │                   │                              │ │
│  │                   │ Сигналы                      │ │
│  │                   ▼                              │ │
│  │  ┌─────────────────────────────────────────┐   │ │
│  │  │           СЕРВО (руки)                   │   │ │
│  │  │    Применяют сигналы к Graph       │   │ │
│  │  └─────────────────────────────────────────┘   │ │
│  └──────────────────────┬──────────────────────────┘ │
│                         │ Неблокирующие очереди      │
│                         ▼ (Command/Telemetry)        │
│  ┌─────────────────────────────────────────────────┐ │
│  │                 AUDIOGRAPH                        │ │
│  │          (внутренняя схема устройства)            │ │
│  │                                                   │ │
│  │  Осцилляторы → Фильтры → Эффекты → Микшер        │ │
│  └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

Автоматы — разум (Automaton)

Автоматы — это разумные существа, которые принимают решения и генерируют сигналы. Они могут быть простыми (LFO, огибающая) или сложными (логические схемы, математические преобразователи).

АвтоматОписаниеКак выглядит в коде
LFOПульсирует с заданной частотойLfoAutomaton::new("vibrato", 5.0, 0.5, 0.0, LfoWaveform::Sine)
EnvelopeРеагирует на события (нажатия)EnvelopeAutomaton::adsr("amp", 0.01, 0.1, 0.7, 0.2)
Random WalkБлуждает случайным образомRandomAutomaton::walk("chaos", 10.0)
SequencerПроигрывает последовательность шаговSequencerAutomaton::new("seq", steps)
FunctionПроизвольная функция времениFunctionAutomaton::new("math", \|t\| (t * 0.5).sin())
CellularКлеточный автомат (Game of Life, Rule 30)CellularAutomaton::game_of_life("life", 16, 16)

Сенсоры — чувства (Sensors)

Чтобы автоматы могли воспринимать мир, им нужны органы чувств. Сенсоры преобразуют внешние воздействия в сигналы, понятные автоматам.

Акустические сенсоры (слышат звук)

#![allow(unused)]
fn main() {
// Слышит высоту тона
let pitch = AcousticSensor::new("pitch",
    Box::new(PitchDetector::new(44100.0)))
    .listening_to("osc1_out");

// Слышит громкость
let envelope = AcousticSensor::new("envelope",
    Box::new(EnvelopeFollower::new(44100.0)
        .with_attack(0.01)
        .with_release(0.1)))
    .listening_to("vca_out");
}

Физические сенсоры (чувствуют прикосновения)

#![allow(unused)]
fn main() {
// Ручка на передней панели
let cutoff = PhysicalSensor::knob("filter_cutoff")
    .with_range(20.0, 20000.0)
    .with_curve(KnobCurve::Logarithmic);

// Кнопка
let button = PhysicalSensor::button("arpeggio_on");
}

MIDI/CV/OSC сенсоры (видят внешний мир)

MIDI сенсоры (фича midi):

#![allow(unused)]
fn main() {
use rill_patchbay::{spawn_midi_sensor, MidiHub};

// Actor-model: поток опроса в отдельном OS-потоке, события → servo
let sensor_ref = spawn_midi_sensor(
    "keyboard",
    Box::new(MidirBackend::new("rill-midi")?),
    &system,
    servo_ref,
);
}

OSC сенсоры (фича osc):

#![allow(unused)]
fn main() {
use rill_patchbay::{spawn_osc_sensor, OscSensor};
use std::net::SocketAddr;

// Actor-model: UDP-сокет, декодирование OSC → ControlEvent::Osc
let sensor_ref = spawn_osc_sensor(
    "touchosc",
    SocketAddr::from(([0, 0, 0, 0], 9000)),
    &system,
    servo_ref,
);
}

Серво — руки (Servo)

Серво — это исполнительные механизмы автоматов. Подчиняясь законам природы (неблокирующим очередям), они передают сигналы из мира автоматов в Graph, изменяя параметры звука.

#![allow(unused)]
fn main() {
let filter_servo = Servo::new(
    "filter_servo",
    lfo_automaton,
    filter_node_id,
    "cutoff",
    ParameterMapping::Linear,
    20.0, 20000.0,
);
}

Пространство автоматов (Patchbay)

Patchbay — это место, где живут все ваши автоматы, где расположены их чувства и руки.

#![allow(unused)]
fn main() {
use rill_patchbay::prelude::*;
use rill_core::queues::MpscQueue;
use std::sync::Arc;

let cmd_queue = Arc::new(MpscQueue::with_capacity(1024));
let mut control = Engine::new(cmd_queue);

control.add_lfo(
    "vibrato", 5.0, 0.5, 0.0, LfoWaveform::Sine,
    osc_node_id, "frequency", 400.0, 480.0,
);

control.update(1.0 / 60.0);
}

Либо через Manager с отдельным потоком обновления:

#![allow(unused)]
fn main() {
let mut manager = Manager::new(
    Config::default(),
    Arc::new(MpscQueue::with_capacity(1024)),
);

manager.add_lfo_servo(
    "vibrato", 5.0, 0.5, 0.0, LfoWaveform::Sine,
    osc_node_id, "frequency",
    ParameterMapping::Linear, 400.0, 480.0,
)?;

manager.start()?;  // Автоматы начинают жить своей жизнью
}

Философия

Наши создания:

  • Обладают разумом — автоматы принимают решения
  • Имеют чувства — сенсоры воспринимают мир
  • Могут действовать — серво изменяют звук
  • Подчиняются законам природы — неблокирующие очереди связывают миры
  • Живут в своем пространстве — Patchbay объединяет всё

Создавайте своих автоматов, наделяйте их чувствами, давайте им руки и стройте удивительные миры звука.


«В каждом автомате живёт частичка души своего создателя»

Domain-Specific Languages in Rill

Rill provides two built-in domain-specific languages (eDSL) based on macro_rules!:

  • Mathematical eDSL — vector operations, type-independent arithmetic (rill-core::math)
  • WDF eDSL — analog circuit description through element composition (rill-core-model::macros)

Both are implemented via macro_rules!, require no external code generators, and expand to flat code at compile time.


1. Mathematical eDSL

Numeric trait hierarchy

Scalar                          — arithmetic: +, -, *, /, min, max, clamp, abs
  ├── f32, f64
  ├── i8, i16, i32, i64
  │
  └── Transcendental            — trigonometry: sin, cos, sqrt, exp, ln, PI
        └── f32, f64            + from_f32, to_f32

Scalar — base trait for any numeric types. Allows Vector<T, N> to work with i32, i16 and other integer types, not just f32/f64.

Transcendental — extension for floating-point types, adding sin/cos/sqrt/exp/ln.

Vector types

Vector<T: Scalar, N> — trait for N-dimensional vectors:

TypeElementsPurpose
ScalarVector1<T>1Scalar stub
ScalarVector2<T>2Stereo
ScalarVector4<T>4SIMD-capable (SSE, NEON)
ScalarVector8<T>8AVX-capable (stub)
F32x4, F64x4 etc.4+Hardware SIMD via wide crate

Basic operations (available for any T: Scalar):

#![allow(unused)]
fn main() {
use rill_core::math::Scalar;
use rill_core::math::vector::ScalarVector4;

let a = ScalarVector4::new(1i32, 2, 3, 4);
let b = ScalarVector4::new(5i32, 6, 7, 8);
let c = a + b;   // element-wise addition
let d = a * b;   // element-wise multiplication
}

Slice operations:

#![allow(unused)]
fn main() {
use rill_core::math::vector::ops::SlicePair;
use rill_core::math::vector::math::sin_slice;

let input = [0.0f32, 0.5, 1.0, 1.5, 2.0];
let mut output = [0.0f32; 5];

// Element-wise a + b → out via SIMD
SlicePair::new(&input, &input).add_into::<4, ScalarVector4<f32>>(&mut output);

// Accumulate: out += input
use rill_core::math::vector::ops::SliceMut;
let mut out = SliceMut::new(&mut output);
out += &input as &[f32];
out *= 2.0f32;

// Transcendental operations require Transcendental
sin_slice::<f32, 4, ScalarVector4<f32>>(&input, &mut output);
}

vec_map! macro

#![allow(unused)]
fn main() {
use rill_core::prelude::*;

let input = [1.0f32, 2.0, 3.0, 4.0, 5.0];
let mut output = [0.0f32; 5];

vec_map!(&input, &mut output, |x| x * 2.0 + 1.0);
// output = [3.0, 5.0, 7.0, 9.0, 11.0]
}

The macro applies the expression to each chunk of 4 elements via ScalarVector4, then processes the remainder scalar-wise. LLVM folds operations into SIMD instructions.

VectorTranscendental

For sin/cos/sqrt operations on vectors:

#![allow(unused)]
fn main() {
use rill_core::math::vector::{
    ScalarVector4, Vector, VectorTranscendental,
};

fn process<T: Transcendental>(v: ScalarVector4<T>) -> ScalarVector4<T> {
    v.sin()  // only when T: Transcendental
}
}

2. WDF eDSL

Wave Digital Filter (WDF) — a method for modeling analog circuits where each element (resistor, capacitor, diode) is represented as a one-port black box. Elements are connected via series and parallel adapters.

Base trait:

#![allow(unused)]
fn main() {
pub trait WdfElement<T: Transcendental>: Send + Sync {
    fn port_resistance(&self) -> T;
    fn process_incident(&mut self, a: T) -> T;   // a → b
    fn update_state(&mut self);                    // update after calculation
    fn voltage(&self) -> T;
    fn current(&self) -> T;
    fn reset(&mut self);
}
}

2.1 wdf_element! — defining an element

Creates a struct and full WdfElement implementation from a black-box description:

#![allow(unused)]
fn main() {
wdf_element! {
    name: RcPole<T>,
    params: { alpha: T },
    state: { state: T },
    port_resistance: |s| { T::ONE },
    scattering: |s, a| {
        let b = s.state + s.alpha * (a - s.state);
        s.state = b + s.alpha * (a - b);
        b
    },
    update: |_s| {},
    reset: |s| { s.state = T::ZERO; },
}
}

Syntax:

  • params — element constants (set at creation)
  • state — state variables (initialized to T::ZERO)
  • port_resistance: |s| expr — port resistance
  • scattering: |s, a| expr — scattering equation: compute reflected wave b from incident wave a. s — mutable reference to self.
  • update: |s| block — state update (called after wave calculation)
  • reset: |s| block — reset to initial state
  • s.voltage and s.current — writable (store latest values)

Generates:

  • struct $name<T> with fields params, state, voltage, current
  • impl $name<T> { fn new(params...) -> Self }
  • impl WdfElement<T> for $name<T>

2.2 wdf_compose! — composing elements

Series — series connection:

#![allow(unused)]
fn main() {
wdf_compose! {
    name: RcSection<T>,
    kind: Series,
    elements: (Resistor<T>, Capacitor<T>),
}
}

Generates a struct with left and right fields, delegating WdfElement. Port resistance — sum: R_total = R_left + R_right. Waves distribute proportionally to resistances.

Parallel — parallel connection:

#![allow(unused)]
fn main() {
wdf_compose! {
    name: TankCircuit<T>,
    kind: Parallel,
    elements: (Capacitor<T>, Inductor<T>),
}
}

Port resistance — parallel combination: R_total = (R1·R2) / (R1 + R2).

2.3 wdf_cascade! — cascade of N sections + feedback

#![allow(unused)]
fn main() {
wdf_cascade! {
    name: MoogLadder<T>,
    section: RcPole<T>,
    count: 4,
    params: { cutoff: T, resonance: T, sample_rate: T },
    state: { feedback_prev: T },
    feedback: |s, input, fb_prev| {
        let k = s.resonance * T::from_f32(4.0);
        let fb = fb_prev * k;
        input - fb.clamp(-T::ONE, T::ONE)
    },
    update: |s| {
        let g = T::PI * s.cutoff / s.sample_rate;
        let alpha = g / (T::ONE + g);
        for p in &mut s.poles { p.alpha = alpha; }
    },
}
}

Generates:

  • struct $name<T> with field poles: [$section; N] + params + state
  • fn process_sample(&mut self, input: T) -> T — unrolled cascade
  • fn set_cutoff(), fn cutoff(), fn set_resonance(), fn resonance(), fn set_sample_rate()
  • fn update_coeffs(), fn reset()

Closure parameters:

  • feedback: |s, input, fb_prev| { ... }s is &self, input is the input sample, fb_prev is the previous output value
  • update: |s| { ... } — updates section coefficients (called when cutoff/resonance changes)

2.4 Macro hygiene

All expressions inside macros receive self through a named closure parameter:

#![allow(unused)]
fn main() {
// Correct:
port_resistance: |s| { s.rp },
scattering: |s, a| { s.state + s.alpha * (a - s.state) },
update: |s| { },
reset: |s| { s.state = T::ZERO; },
}

self inside captured :tt blocks does NOT work due to macro_rules! hygiene. Using s as the parameter name is a convention.

2.5 Limitations

ConstructSupportedDescription
Two-terminal (R, C, L, D)wdf_element!One port, scattering 2×2
Series<A, B>wdf_compose!Static circuits
Parallel<A, B>wdf_compose!Static circuits
Cascade N+feedbackwdf_cascade!MoogLadder
Three-terminal (transistor)❌ manual implScattering matrix 3×3
Op-amp, OTA❌ manual implMathematical model

3. Examples

MoogLadder (4-pole low-pass with resonance)

#![allow(unused)]
fn main() {
use rill_core_model::wdf::{RcPole, MoogLadder};

// RcPole — one-pole low-pass filter (wdf_element!)
// MoogLadder — cascade of 4 RcPole + resonance feedback (wdf_cascade!)

let pole = RcPole::new(0.0);        // alpha = 0 (fully open)
let mut filter = MoogLadder::new(
    pole, 1000.0, 0.0, 44100.0      // cutoff=1kHz, resonance=0
);
filter.update_coeffs();              // calculate alpha from cutoff

// Process sample
let input = 0.5;
let output = filter.process_sample(input);
}

DiodeClipper (overdrive)

#![allow(unused)]
fn main() {
use rill_core_model::constants::{BOLTZMANN, ELECTRON_CHARGE};
use rill_core_model::elements::Resistor;
use rill_core_model::wdf::{AntiParallelDiode, DiodeClipper};
use rill_core_model::WdfElement;

let r = Resistor::new(1000.0);
let vt = BOLTZMANN * 300.0 / ELECTRON_CHARGE;

let mut diode = AntiParallelDiode::new(1e-15, vt);
diode.reset();

let mut clipper = DiodeClipper::new(r, diode);

// Process
let b = WdfElement::process_incident(&mut clipper, 10.0);
clipper.update_state();
let clipped_voltage: f64 = clipper.right.voltage();  // ≈ 0.6V
}

Vector MAP (SIMD)

#![allow(unused)]
fn main() {
use rill_core::prelude::*;

let input = [1.0f32; 1024];
let mut output = [0.0f32; 1024];

vec_map!(&input, &mut output, |x| (x * 2.0 + 1.0).sin());
}

4. eDSL compilation flow

Source code (macros)
    │
    ▼
macro_rules! expansion (compile-time)
    │
    ▼
Flat Rust code with no indirection
    │
    ▼
LLVM optimization (inlining, constant folding, SIMD)
    │
    ▼
Machine code

All eDSLs expand at compile time into flat structures and methods. No trait objects, dynamic dispatch, or allocations in the hot path. LLVM additionally folds constants and vectorizes loops.

rill-lang: the Signal DSL

rill-lang is a small, Faust-style functional streaming language for describing the internal math of a signal-graph node. You write a block diagram as source text; rill-lang compiles it — lexer, parser, Hindley-Milner type checker, linear IR, β-reduction, and an execution scheduler — into a value implementing rill_core::Algorithm, ready to run in the graph.

The current backend is a safe, allocation-free interpreter. A Cranelift JIT backend is planned behind a future jit feature; it will consume the same intermediate representation, so nothing in the language front-end changes when it arrives.

This page is the canonical language reference. For the broader idea of embedding domain-specific languages in rill, see the eDSL guide.

Overview

rill-lang exists so that a node's DSP can be authored — and, in time, machine-synthesised — at runtime rather than hand-written in Rust and compiled ahead of time. A program is compiled on the fly (no rustc, no external toolchain) to a rill_core::Algorithm<T> that plugs straight into the signal graph. The compiler is tiny and self-contained, and the compiled program obeys rill's real-time rules: no heap allocation, no locks, and no syscalls on the hot path.

Four properties define the language:

  • Block-diagram algebra (Faust-style). Programs are compositions of signal processors via geometric combinators (: , <: :> ~), not imperative statements. There are no runtime variables — only signals flowing through blocks.
  • Haskell-style definitions. Functions and constants use a unified syntax (name args = body), distinguished only by parameter count. All binding groups (where, let, top-level) have mutual visibility.
  • β-reduction. User-defined function calls are fully inlined before lowering. The final IR is flat — only Wire, constants, built-ins, and combinators remain.
  • Hybrid block/sample execution. The compiler analyses the data-dependency graph and runs feedforward regions whole-buffer (SIMD) while only true recurrences (feedback, delay) run sample-by-sample.
  • RT-safe control. Named parameters and smoothing give control-rate automation without recompilation or locks.

A first program

#![allow(unused)]
fn main() {
use rill_lang::compile;
use rill_core::traits::Algorithm;

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

A program is a list of mutually-recursive definitions, each terminated by ;. Exactly one must be named main — the entry point. main must reduce to a signal block of arity (0 or 1) → 1.

Definitions and functions

rill-lang uses a unified syntax for constants and functions — both are definitions of the form name params = body. The only difference is the parameter count: 0 params = constant, 1+ params = function:

gain x = _ * x;    // function of one argument (x is a constant parameter)
main   = gain 0.5; // apply gain to 0.5, producing a (1→1) signal block

Function parameters are Haskell-style λ-parameters: space-separated identifiers after the function name, no parentheses. When a function is called with arguments, the arguments are substituted into the body via β-reduction — the result is an inlined expression with no runtime function dispatch:

// Source:
sq x = _ * x;
main = sq 0.5;

// After β-reduction (compile time):
// main = _ * 0.5

All binding groups — top-level, where blocks, and let bodies — are mutually recursive: every name in the group is visible to every body, regardless of definition order.

Application syntax

rill-lang uses bracket-free juxtaposition as its canonical calling convention — function name followed by space-separated arguments:

main = _ : lowpass 1000.0 0.7;
main = lowpass _ 1000.0 0.7;     // signal as first-class argument
main = sine 440.0 0.5 0.0;       // oscillator with freq, amp, phase

The parenthesized form name(arg, ...) is also supported but juxtaposition is canonical. Each argument must be an atom (identifier, literal, _, !, (expr), -expr); for complex expressions use parentheses around the argument.

Unified arguments

Signals are first-class arguments in the unified calling model. A built-in doesn't require the signal on the left via :, you can pass it inline:

main = lowpass _ 1000.0 0.7;        // signal as first positional arg
main = mixer _1 _2 _3 { gain: 0.8 }; // variadic signal args

Some built-ins accept variadic signal inputs (e.g. mixer takes any number of signals). Others specify a fixed signal arity per their signature. Scalar parameters (floats, ints, records) follow signal args.

where blocks and layout

Definitions can be attached to any function or constant using the where keyword. Two syntaxes are supported:

Explicit braces — definitions inside { ... }, each terminated by ;:

main = osc : filt where {
    osc  = sine 440.0 0.5 0.0;
    filt = _ : lowpass 1200.0 0.7;
}

Layout-based (Haskell-style indentation) — after where, each indented line is a definition. The block starts at the column of the first definition and ends when indentation drops below that column or at EOF:

main = osc : filt where
    osc  = sine 440.0 0.5 0.0
    filt = _ : lowpass 1200.0 0.7

The semicolon after each definition is optional in layout mode — the parser accepts both def = expr and def = expr;. The block terminates when the next line has indent less than the layout column, or when the file ends.

Where-block definitions are scoped to the function they're attached to. They are not visible to other top-level definitions or to the caller.

let expressions

let introduces a mutually-recursive binding group scoped to a single expression. Available in both brace and layout form, like where:

main = let g x = _ * x in g 0.5

main = let { g x = _ * x; } in g 0.5

let can appear anywhere an expression is expected — inside combinators, built-in arguments, or nested inside other let blocks.

Multiple definitions at the top level

A program can have any number of top-level definitions:

gain = _ * 0.5;
main  = gain;

Exactly one must be named main. All top-level definitions are mutually recursive and visible to each other.

main with parameters

main can declare input parameters — their names become slots in the compiled param_map, addressable by name from the control thread:

main cutoff res = _ : lowpass cutoff res;

When compiled via compile_graph(), each main parameter and each function parameter in the where block becomes a named parameter in the resulting graph node. Use the ?name=default syntax for late-binding actor parameters (see Actor Parameters below).

Records and config

Built-ins that accept structured configuration use record literals { key: val }:

main = mixer _1 _2 { channels: 2, buses: 0, master_vol: 0.8 };
main = dry_wet _ wet { mix: 0.5 };
main = eq_parametric _ { bands: [
    { freq: 500.0, q: 2.0, gain_db: -3.0, band_type: 0 },
    { freq: 2000.0, q: 1.0, gain_db: 1.5, band_type: 0 },
]};

Records can be nested — the EQ bands field contains a list of band configurations ({ freq, q, gain_db, band_type }). Record keys must be literals; values can be literals, param() references, or other records.

Primitives

SyntaxMeaningArity (in → out)
_identity wire1 → 1
!cut (discards its input)1 → 0
42integer literal0 → 1
1.5float literal0 → 1
3i, 2.5iimaginary literal0 → 2
+ - * / %binary arithmetic block2 → 1
sin cos tan sqrt exp ln tanh absmath builtins1 → 1
min maxselection2 → 1

Arithmetic also appears in infix position: _ * 0.5 and _ + 1 build the same blocks as * and + used as primitives.

Complex number literals use the suffix i: 3i, 2.5i. The parser also recognises 1.0 + 2.0i as syntactic sugar for complex 1.0 2.0.

Combinators

The block-diagram algebra composes diagrams. For A : (aᵢ, aₒ) and B : (bᵢ, bₒ):

FormNameRequirementResulting arity
A : Bsequentialaₒ = bᵢ(aᵢ, bₒ)
A , Bparallel(aᵢ + bᵢ, aₒ + bₒ)
A <: Bsplit (fan-out)bᵢ is a multiple of aₒ(aᵢ, bₒ)
A :> Bmerge (fan-in, sums)aₒ is a multiple of bᵢ(aᵢ, bₒ)
A ~ Bfeedbackbᵢ ≤ aₒ and bₒ ≤ aᵢ(aᵢ − bₒ, aₒ)
A @ ninteger delayA is _ → 1, n a constant intsame as A

Feedback (~) routes B's outputs back into A's leading inputs through a one-sample delay — this is how stateful filters and recursive structures are built. The delay operator @ requires a compile-time constant integer length (constant-folded from integer literals and arithmetic on them); variable delays are not part of the MVP.

Operator precedence

Loosest to tightest binding, all left-associative:

~   <   :   <   :>   <   <:   <   ,   <   + -   <   * / %   <   @   <   unary -   <   atom

So + ~ _ parses as (+) ~ (_), and _ * 2 , _ as (_ * 2) , _.

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
main = abs _;              // full-wave rectifier

Type checking

rill-lang runs a Hindley-Milner inference pass before code generation:

  • Scalar typesint, float (the runtime T), and type variables — are unified with an occurs check. Overloaded operators default to the runtime scalar when otherwise unconstrained, so arithmetic is monomorphized.
  • Arities are synthesized bottom-up as concrete numbers and checked against the combinator table above.
  • Named functions are let-generalized and instantiated per use site.
  • λ-parameters are counted separately from signal ports. A function f x = _ * x has one λ-parameter (x) and one signal port (from _). Calling f 0.5 consumes the λ-parameter, leaving the signal port open.

A type or arity mismatch is reported as an error carrying the offending source span, and compilation stops there — an ill-typed diagram never reaches the interpreter.

#![allow(unused)]
fn main() {
use rill_lang::compile;
// top-level parallel pair is (2 → 2): not a valid SISO main
assert!(compile::<f32>("main = _ , _;").is_err());
}

Built-in functions

rill-lang programs can call stateful DSP/model built-ins from workspace crates via an extensible FFI registry. Built-ins are not compiled into the interpreter core — bindings live in the individual crates, aggregated by rill-adrift (lang_builtins::full_registry), keeping rill-lang dependent only on rill-core.

Built-in registry

CategoryBuiltinsFeature
Filtersonepole, moog (sample), lowpass, highpass, biquad (block)always
Oscillatorssine, saw, square, triangle, noise (block)always
Effectsdelay, distortion, limiter (block)always
Mixer/EQmixer, eq_parametric, dry_wet, graphic_eq (block)router
Analoganalog_moog, cassettedeck, tape_bridge (block)analog
Spectralspectralgate, spectraldelay, convolver (block)fft
Complexcomplex, conj, re, im, norm, arg, cmul, caddalways
Samplersampler (block)sampler
Lofilofi, ay38910 (block)lofi

Calling convention

Built-ins use the unified argument model: signals are first-class positional arguments, scalars follow, and configuration is passed as a record:

main = lowpass _ 1000.0 0.7;           // filter: signal, cutoff, resonance
main = sine 440.0 0.5 0.0;             // oscillator: freq, amp, phase (no signal in)
main = mixer _ ch2 ch3 { channels: 3 }; // variadic signal args + record

Parameters are compile-time constants (float or integer literals, optionally with arithmetic) or a param(...) reference. Constants are folded to f64 during lowering. The signal port count per built-in is defined by its signature (see individual crate registrations).

Sample built-ins vs block built-ins

KindNamesBehaviourInside ~
Sampleonepole, moogPer-sample state; the built-in's process_sample runs inside the sample-level recurrence loop.Allowed
Blocklowpass, highpass, biquad, delay, distortion, limiter, sine, saw, square, triangle, noise, analog_moog, cassette_deck, tape_bridge, spectralgate, spectraldelay, convolver, lofi, ay38910Opaque whole-buffer step; the built-in implements Algorithm<T> and processes all samples at once.Compile error

Sample built-ins are composed from the feedback combinator just like hand-rolled recurrences:

main = + ~ moog 500.0 0.5;   // feedback-legal per-sample filter

Block built-ins cannot appear inside ~ — the compiler rejects them with an error (block built-in cannot be used inside a feedback loop).

Using built-ins from Rust

The umbrella registry (rill_adrift::lang_builtins::full_registry) aggregates all workspace built-ins. For selective registration, individual crates expose register_lang_builtins() functions:

#![allow(unused)]
fn main() {
use rill_lang::compile_with;
use rill_lang::builtin::Registry;

let mut reg = Registry::<f32>::new();
rill_core_dsp::lang::register::register_lang_builtins(&mut reg);
rill_lang::register::register_core_builtins(&mut reg);

let mut prog = compile_with::<f32>(
    "main = lowpass _ 1000.0 0.7;",
    &reg,
    48_000.0,
).unwrap();
let mut out = [0.0f32; 4];
prog.process(Some(&[1.0, 2.0, 4.0, 8.0]), &mut out).unwrap();
}

Or to compile directly into a graph engine with actor mailbox support:

#![allow(unused)]
fn main() {
use rill_lang::compile_graph;
use rill_adrift::lang_builtins::full_registry;

let reg = full_registry::<f32>();
let mut engine = compile_graph::<f32, BUF_SIZE>(
    "main = _ : lowpass ?cutoff=1000.0 ?resonance=0.7;",
    &reg,
    48_000.0,
).unwrap();
// engine.handle() returns ActorRef<CommandEnum> for sending SetParameter
}

Parameters

rill-lang programs can expose named control-rate parameters — mutable slots that stay constant for one signal block and change only between blocks (at control rate). Parameters are RT-safe because the compiled program bakes them into a flat array indexed by integer handle; no allocation, no locking, and no variable lookup occurs on the hot path.

param(name, default[, min, max])

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

param("gain", 0.5) creates a named control-rate slot that evaluates to 0.5 initially. At runtime the value can be modified from the control thread, and the new value takes effect at the next block boundary. The optional min and max arguments constrain the range (0.0param("gain", 0.5, 0.0, 1.0)1.0); the runtime clamps writes to this range.

Reusing the same name refers to the same slot — every param("gain", …) in a program shares one value. All uses of a name must declare an identical default and range; a conflicting redeclaration is a compile error (this prevents a name from silently meaning two different things).

Parameters have arity 0 → 1 — they are zero-input signal sources — so they can appear anywhere a float literal would: in arithmetic expressions and also as a built-in argument, which lets you dynamically drive filter cutoffs, resonance, and mixer gains:

main = _ : lowpass param("cutoff", 1000.0, 20.0, 20000.0) 0.7;

smooth(x, ms) — zipper-free smoothing

When a parameter changes abruptly at a block boundary, the step creates an audible "zipper" click. smooth(x, ms) is a native one-pole low-pass (one per call site) that slides its input value toward its output with the specified time constant:

main = _ * smooth(param("gain", 0.5, 0.0, 1.0), 10.0);

Here gain is ramped with a 10 ms time constant — the output sample moves smoothly even when the control thread snaps the parameter from 0 to 1. smooth bakes the sample rate at compile time; if the sample rate changes, the program must be recompiled for the time constant to match.

Setting parameters from Rust

The RillProgram API exposes parameter slots by index:

#![allow(unused)]
fn main() {
use rill_lang::compile;

let mut prog = compile::<f32>("main = _ * param(\"gain\", 0.5);").unwrap();

let idx = prog.param_index("gain").unwrap();
prog.set_param(idx, 0.8);
}

Setting parameters on a rill/lang graph node

When the program runs inside a rill/lang factory node (via rill-adrift's lang feature), parameters are also accessible by name from the control side — the node's NodeMetadata advertises them, and you can write a value with Node::set_parameter(name, value). Because the parameter name is stable (the same string you wrote in the DSL), servos, LFOs, envelopes, and MIDI mappings can target it directly (target by NodeId + parameter name).

#![allow(unused)]
fn main() {
// conceptual: a servo targets the "cutoff" parameter of node 0
node_ref.set_parameter("cutoff", 2000.0);
}

Control-rate semantics

Parameters and smooth are control-rate constructs. The compiled program stores one scalar per parameter slot; process() reads the current value once per call and re-uses it for the entire block. The control thread (set_param / set_parameter) writes a new value, and the read is observed at the next process() call — i.e. at the next block boundary. This model is efficient (the hot path is a simple load + multiply / load + onepole) and safe (no locks, no atomics).

For more on the automation plumbing, see the Automaton guide.

Actor parameters

rill-lang supports late-binding actor parameters with the ?name=default syntax — a concise alternative to param() designed for compile_graph():

main = _ : lowpass ?cutoff=1000.0 ?resonance=0.7;
main = sine ?freq=440.0 0.5 0.0;

Each ?name=default creates a named parameter slot. When compiled via compile_graph(), parameters are addressable by the engine's handle():

#![allow(unused)]
fn main() {
use rill_lang::compile_graph;
use rill_adrift::lang_builtins::full_registry;
use rill_core::queues::CommandEnum;
use rill_core::traits::ParamValue;

let reg = full_registry::<f32>();
let mut engine = compile_graph::<f32, BUF_SIZE>(
    "main = _ : lowpass ?cutoff=1000.0 ?resonance=0.7;",
    &reg,
    48_000.0,
).unwrap();
engine.handle().send(CommandEnum::SetParameter(
    rill_core::queues::SetParameter {
        anchor: "main".into(),
        parameter: "cutoff".into(),
        value: ParamValue::Float(2000.0),
        port: String::new(),
        source: SignalOrigin::Manual,
        timestamp: 0,
        sample_pos: None,
    }
)).ok();
}

Where-block namespacing

Where-block definitions with parameters use dot-notation namespacing:

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

The anchor field in SetParameter is the definition name when inside a where block (e.g. "osc" for osc.freq). Top-level main parameters use "main" as their anchor.

?name vs param()

Feature?name=defaultparam("name", default)
Syntax cost3 extra chars9+ extra chars
IntentLate-binding for actor systemInline parameter slot
Works withcompile_graph()compile() / compile_with()
Use caseGraph nodes with external controlStandalone programs

Both are RT-safe: one scalar per slot, read once per block, no locks.

Multi-IO and graph compilation

rill-lang programs can be multi-channel — N inputs, M outputs. Multi-IO programs implement MultichannelAlgorithm<T> when compiled with the router feature:

main = mixer _1 _2 _3 { channels: 3, buses: 2 };  // 3→4 (2 master + 2 bus)
main = dry_wet _ wet_signals { mix: 0.5 };          // 2→2

Graph compilation

compile_graph(src, &registry, sample_rate) compiles a rill-lang program into a CompiledGraphEngine<T, BUF_SIZE> — a self-contained engine that:

compile_graph() and rill-graph::GraphBuilder::build_ir() share the same compilation backend. Both produce a GraphIr (multi-node intermediate representation), which graph_compiler::compile() transforms into a CompiledGraphEngine<T, BUF_SIZE>. The difference is the source: compile_graph() takes a single DSL source string; build_ir() takes a programmatically-built topology with typed parameter signatures.

  • Runs a flat vector of NodeClosures over a pool of pre-allocated FixedBuffers
  • Drains the actor mailbox for SetParameter commands each tick
  • Dispatches to MultichannelAlgorithm::process() for multi-IO programs
  • Uses SISO fast path for 0/1 input → 1 output programs
#![allow(unused)]
fn main() {
use rill_lang::compile_graph;
use rill_core::traits::Algorithm;

let reg = rill_lang::builtin::Registry::<f32>::new();
let mut engine = compile_graph::<f32, BUF_SIZE>(
    "main = _ * 0.5;",
    &reg,
    48_000.0,
).unwrap();
let mut out = [0.0f32; 4];
engine.process(Some(&[1.0, 2.0, 4.0, 8.0]), &mut out).unwrap();
assert_eq!(out, [0.5, 1.0, 2.0, 4.0]);
}

CompiledGraphEngine supports bridge/feedback configurations via GraphNode annotations (is_bridge, feedback_read, feedback_write), used for tape delay and send/return topologies where left-side outputs feed right-side inputs.

Scoping

Binding formVisibilityMutual recursion
Top-level defsAll top-level definitions in the programYes
where blockOnly within the function it's attached toYes, within the block
let expressionOnly within the in bodyYes, within the block

let bindings shadow outer names. where block names shadow top-level names. Nested let/where blocks shadow outer blocks.

Serialization

With the serde feature enabled, a program round-trips through RillLangDef, whose canonical form is simply the source string (a compiled IR would rot across versions; source stays stable and editable):

#![allow(unused)]
fn main() {
use rill_lang::{RillLangDef, compile_def};

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

Using it in a graph

Two paths to runtime:

compile_graph() — direct engine

compile_graph() compiles source directly into a CompiledGraphEngine<T> with actor mailbox support:

#![allow(unused)]
fn main() {
use rill_lang::compile_graph;
use rill_adrift::lang_builtins::full_registry;

let reg = full_registry::<f32>();
let mut engine = compile_graph::<f32, BUF_SIZE>(
    "main = _ * 0.5;",
    &reg,
    48_000.0,
).unwrap();
}

The engine provides handle()ActorRef<CommandEnum> for sending SetParameter commands, and implements Algorithm<T> directly.

rill/lang factory node

The umbrella crate rill-adrift registers a rill/lang node type. A serialized graph can embed a rill-lang block by giving it a source parameter:

{
  "id": 0,
  "type_name": "rill/lang",
  "name": "MyBlock",
  "parameters": { "source": "main = _ * 0.5;" }
}

Setting the node's source parameter at runtime recompiles the program and hot-swaps it — the seed of the runtime code-synthesis loop described in the project's architecture notes. Note that compilation allocates, so a source swap applied through the graph's SetParameter path runs inside the I/O callback; treat it as a control-time operation to be performed when the graph is not under hard real-time load, not as an every-block action.

Execution model and performance

The compiler pipeline: lex → parse → HM type inference → β-reduction → lowering → scheduling.

β-reduction

After type inference, all user-defined function calls are eliminated by substituting argument values directly into the function body. This happens at compile time, producing a flat expression containing only Wire, constants, built-ins, and combinators:

// Before reduction:
gain x = _ * x;
main = gain 0.5;

// After reduction (the IR seen by the back-end):
main = _ * 0.5

let-bound and where-bound definitions are also inlined. The reduction is recursive: chained definitions (h = g 0.5; g = f 0.25) collapse to a single expression.

Scheduling

The interpreter compiles the linear IR into an execution schedule via SCC (strongly-connected component) analysis of the data-dependency graph. Each step in the schedule is classified as either a whole-buffer block op or a per-sample recurrent region:

  • Feedforward regions — all combinational instructions (arithmetic, math builtins, fan-out/fan-in) — are Step::Block and run whole-buffer through the rill_core::math::vector SIMD eDSL (ScalarVector4). The block path computes directly in T (the runtime scalar, e.g. f32), letting LLVM auto-vectorize the hot loop.
  • Recurrent regions — anything containing ~ (feedback) or @ (delay) operators that introduce a cross-sample dependency — are Step::Sample and run as a tight per-sample loop over the instructions in their original IR order. Only the recurrence itself goes sample-by-sample; all upstream feedforward math stays block-wise.

The whole-buffer register store is a flat Vec<Vec<T>> grown once to the block length and reused across calls. The hot process() path performs no heap allocation, no locks, and no syscalls, honoring rill's real-time rules.

A fully-combinational program (e.g. _ * 0.5) compiles to all block steps. A pure feedback program (e.g. + ~ _) degenerates to a single per-sample region. Mixed programs (e.g. (_ * 0.5) : (+ ~ _)) schedule the feedforward block steps first, then the recurrent sample region.

The per-sample interpreter is retained as RillProgram::process_reference — a numerical oracle used by tests to validate the hybrid path. A Cranelift JIT backend is still planned and will reuse the same IR.

Benchmarks

rill-lang ships criterion benchmarks:

cargo bench -p rill-lang   --bench lang_bench
cargo bench -p rill-adrift --features lang --bench lang_dsp_bench

The figures below are representative (256-sample blocks, f32, one core). Absolute times are machine- and build-dependent — read the ratios, not the nanoseconds.

Compilation (full pipeline: lex → parse → HM → lower → schedule)

ProgramCompile
_ * 0.5~1.4 µs
_ * 0.5 : abs : (_ * 2.0)~2.1 µs
+ ~ (_ * 0.5)~1.9 µs
mixed fan-out + feedback~3.8 µs

Compilation is microseconds — cheap enough to recompile a node's source on the control thread when its source parameter changes.

Runtime — one 256-sample block

ProgramTimeKind
_ * 0.5~63 nsfeedforward (block)
_ * 0.5 : abs : (_ * 2.0)~111 nsfeedforward (block)
_ <: (_ , _ * 0.5) :> +~88 nsfeedforward (block)
_ * param("g", 0.5)~61 nsfeedforward (block)
_ @ 4~1.6 µsrecurrent (sample)
+ ~ (_ * 0.5)~3.4 µsrecurrent (sample)
_ * smooth(param("g", 0.5), 10.0)~4.5 µsrecurrent (sample)

Hybrid vs. the per-sample reference — the block-processing win

Comparing process (the hybrid block/sample executor) with process_reference (the per-sample oracle) on the same program:

ProgramHybridReferenceSpeedup
feedforward chain~165 ns~5.5 µs~33×
feedback~3.4 µs~4.2 µs~1.2×

Feedforward programs run whole-buffer through the SIMD eDSL and are an order of magnitude faster than sample-by-sample evaluation. Feedback programs are near parity, because the recurrence forces both paths to go sample-by-sample — which is exactly why the scheduler isolates recurrences and blocks everything else.

Built-ins (via rill-adrift)

ProgramTime
_ : lowpass 1000.0 0.7 (block Biquad)~275 ns
_ : lowpass param("cutoff", 1000.0) 0.7 (dynamic)~306 ns
_ : onepole 1200.0 0.5 (sample)~3.5 µs
_ : moog 800.0 0.6 (sample)~4.0 µs
DSL-wrapped biquad vs. raw Biquad~264 ns vs. ~234 ns (~13% overhead)

Wrapping a rill-core-dsp filter in the DSL costs about 13% over calling the raw Algorithm — the price of the schedule dispatch and the register store. Driving a filter parameter with param(...) adds only the per-block coefficient update.

Status

The language is feature-complete for signal authoring: block-diagram combinators, feedback and delay, Hindley-Milner types, Haskell-style definitions with β-reduction, let and where binding groups with mutual visibility, hybrid block/sample execution, a 27-built-in registry (DSP, effects, oscillators, mixer/EQ, analog, spectral, complex, lofi), RT-safe named parameters (param() and ?name), records for built-in configuration, multi-IO via MultichannelAlgorithm, and graph compilation (compile_graph()CompiledGraphEngine).

Deferred to follow-on work:

  • the Cranelift jit backend (the linear IR is the shared lowering target);
  • whole-graph-as-one-program lowering (fusing a multi-node graph into one schedule);
  • signal-rate (per-sample) modulation of imported built-in parameters (current parameter modulation is control-rate/per-block);
  • composed expressions as built-in arguments.

Graph Serialization

Rill graphs can be serialised to JSON (human-readable) or CBOR (compact binary) and restored via a NodeFactory. This enables preset storage, network transfer, and offline editing of graph topologies.

Feature gate

# Cargo.toml
rill-graph = { features = ["serialization"] }

Enables rill_graph::serialization module, which depends on:

  • serde / serde_json / serde_cbor

Data model

GraphDef

The top-level container:

#![allow(unused)]
fn main() {
pub struct GraphDef {
    pub format_version: String,       // "rill/1"
    pub sample_rate: f32,
    pub block_size: usize,
    pub nodes: Vec<NodeDef>,
    pub connections: Vec<ConnectionDef>,
}
}

NodeDef

#![allow(unused)]
fn main() {
pub struct NodeDef {
    pub id: u32,                          // NodeId (important for patchbay bindings)
    pub type_name: String,                // factory lookup key, e.g. "rill/sine_osc"
    pub name: String,                     // human-readable instance name
    pub backend: Option<String>,          // optional backend name (e.g. "ay38910")
    pub parameters: HashMap<String, ParamValue>,
}
}
  • backend — specifies a named backend from BackendFactory for this I/O node. The orchestrator uses BackendFactory to create backends externally; the builder no longer holds a factory. Processor nodes leave this empty.

ParamValue

#![allow(unused)]
fn main() {
pub enum ParamValue {
    Float(f32),
    Int(i32),
    Bool(bool),
    String(String),
    Choice(String),
    Bytes(Vec<u8>),  // raw data for IoControl::write_data()
}
}

Bytes carries raw byte arrays for backend-specific control protocols (e.g. AY-3-8910 register writes, MIDI SySex). The "io_write" parameter on LofiInput forwards Bytes payloads to IoControl::write_data().

ConnectionDef

#![allow(unused)]
fn main() {
pub struct ConnectionDef {
    pub kind: SignalKind,
    pub from_node: u32,       // NodeId
    pub from_port: usize,
    pub to_node: u32,         // NodeId
    pub to_port: usize,
}

pub enum SignalKind {
    Signal,
    Control,
    Clock,
    Feedback,
}
}

Connections are reconstructed from the port routing state of each node (Port::downstream for audio, Port::feedback_downstream for feedback).

Control and clock connections currently store only the metadata (they are round-tripped via the document), but the port-level routing for these signal kinds is not yet tracked by the engine — this is reserved for future use.

Export (Graph → JSON/CBOR)

#![allow(unused)]
fn main() {
use rill_graph::serialization::{to_json, to_cbor};

let graph: Graph<f32, 64> = /* … */;

let json = to_json(&graph)?;             // pretty-printed JSON
let cbor = to_cbor(&graph)?;             // compact binary (Vec<u8>)
}

Under the hood [GraphDef::from_graph] iterates every node, reads NodeMetadata + get_parameter, and walks output port routing tables.

Import (JSON/CBOR → GraphBuilder)

#![allow(unused)]
fn main() {
use rill_graph::serialization::from_json;

let json = std::fs::read_to_string("preset.json")?;

let mut builder: GraphBuilder<f32, 64> = GraphBuilder::new();
let def = from_json(&json)?;
def.populate(&mut builder)?;
let graph = builder.build()?;
}

Validation

On import [GraphDef::populate] performs:

  1. Duplicate NodeId check — every NodeDef.id must be unique.
  2. Block size match — the document's block_size must equal the builder's B.
  3. Type resolution — every type_name must be registered in the builder's NodeFactory.

Type names and the factory

Each node type that participates in serialisation must be registered in a NodeFactory via the [node_ctor!] macro or register_fn:

#![allow(unused)]
fn main() {
use rill_core::traits::{Node, NodeId, Params, NodeVariant};
use rill_graph::{node_ctor, NodeFactory};

let mut factory = NodeFactory::<f32, 64>::new();
node_ctor!(factory, "rill/sine_osc", |id: NodeId, params: &Params| {
    let freq = params.get_f32("frequency", 440.0);
    let mut osc = SineOsc::<f32, 64>::new().with_frequency(freq);
    osc.set_id(id);
    osc.init(params.sample_rate);
    NodeVariant::Source(Box::new(osc))
});
}

The type_name in the factory should match the type_name exposed in [NodeMetadata] so that export and import are consistent:

  • node_ctor! / register_fn key → used as the factory lookup key on import.
  • NodeMetadata::type_name (with name fallback) → written into the document on export.

Node IDs

NodeId is preserved through serialisation. This is critical when the graph integrates with rill-patchbay: control bindings reference specific PortIds, which in turn depend on exact NodeIds.

When importing, the document's id field is passed directly to [GraphBuilder::add_node_with_id]. If two nodes share the same ID, [SerializationError::DuplicateNodeId] is returned.

Formats

FormatWhen to use
JSONDebugging, manual editing, version-controlled presets
CBORNetwork transfer, embedded preset storage, low-bandwidth links

Both encode the identical [GraphDef] structure — switching formats is a single function call.

Example round-trip

#![allow(unused)]
fn main() {
use rill_graph::prelude::*;

// Build
let mut builder = GraphBuilder::<f32, 64>::new();
builder.add_node(&registry, "rill/sine", &Params::new(44100.0))?;
builder.add_node(&registry, "rill/delay", &Params::new(44100.0))?;
builder.connect_signal(0, 0, 1, 0);
let graph = builder.build()?;

// Export
let json = rill_graph::serialization::to_json(&graph)?;

// Import
let def = rill_graph::serialization::from_json(&json)?;
let mut restored_builder = GraphBuilder::new();
def.populate(&mut restored_builder)?;
let graph2 = restored_builder.build()?;
}

Error types

ErrorCause
UnknownType(name)type_name not found in registry
DuplicateNodeId(id)Two NodeDefs with the same id
InvalidFormat(msg)Malformed JSON/CBOR or block size mismatch

Tests

Serialisation tests live in rill-graph/src/serialization.rs under mod tests. Run them with the feature flag:

cargo test -p rill-graph --features serialization -- serialization

Coverage includes:

  • JSON and CBOR round-trips
  • Parameter preservation
  • Feedback connection export
  • Type name explicit vs fallback
  • Node ID preservation
  • Complex multi-node topologies
  • Error handling (unknown type, duplicate ID, block size mismatch, malformed input)

Automatic node registration (rill-adrift)

In 0.6.0-M2, the register_all_nodes() function was removed. Registration now happens via per-crate register modules that populate a rill_core::builtin::Registry:

#![allow(unused)]
fn main() {
use rill_core::builtin::Registry;

let mut reg = Registry::<f32>::new();
rill_core_dsp::lang::register::register_lang_builtins(&mut reg);
rill_router::register::register_lang_builtins(&mut reg);
rill_digital_effects::register::register_lang_builtins(&mut reg);
}

For the full ecosystem, rill-adrift provides lang_builtins::full_registry() as a convenience that aggregates all available per-crate registries:

#![allow(unused)]
fn main() {
let registry = rill_adrift::lang_builtins::full_registry::<f32>();
}

When building rill-lang graphs, use compile_with() or compile_graph() with the registry. For GraphBuilder-based workflows, use per-crate register_lang_builtins() functions.

Convenience deserialisation helper

rill-adrift re-exports load_graph_json for quick graph loading:

#![allow(unused)]
fn main() {
use rill_adrift::registration::load_graph_json;

let def = load_graph_json(r#"{"nodes":[…], "connections":[…]}"#)?;
// Then populate into a builder:
// def.populate(&mut builder)?;
}

Custom nodes at the application level

Applications can define their own graph nodes and register them alongside the built-in rill types. There are three levels of integration.

Level 1: Register a closure

For simple one-off processing, use register_fn on NodeFactory:

#![allow(unused)]
fn main() {
use rill_core::traits::{Node, NodeId, Params, NodeVariant};
use rill_graph::NodeFactory;

let mut factory = NodeFactory::<f32, 64>::new();
factory.register_fn("app/gain", |id: NodeId, params: &Params| {
    let mut n = GainNode::<f32, 64>::new(params.get_f32("gain", 1.0));
    n.set_id(id);
    n.init(params.sample_rate);
    NodeVariant::Processor(Box::new(n))
});
}

The type name "app/gain" can then be used in JSON documents:

{"id": 0, "type_name": "app/gain", "name": "Volume", "parameters": {"gain": 0.8}}

Level 2: Full custom graph node

Implementing the Node, Source, Processor, or Sink trait:

#![allow(unused)]
fn main() {
use rill_core::math::Transcendental;
use rill_core::traits::{
    Node, Processor, NodeId, NodeMetadata, NodeCategory,
    NodeState, ParamValue, ParameterId, Port, PortId, PortDirection, PortType,
    ProcessResult,
};
use rill_core::time::ClockTick;

/// A simple sine-wave tremolo: multiplies the input by a low-frequency
/// oscillation. Demonstrates a complete custom graph node with metadata,
/// parameters, and ports.
pub struct Tremolo<T: Transcendental, const BUF_SIZE: usize> {
    id: NodeId,
    state: NodeState<T, BUF_SIZE>,
    rate: f32,
    depth: f32,
    phase: f32,
    inputs: Vec<Port<T, BUF_SIZE>>,
    outputs: Vec<Port<T, BUF_SIZE>>,
}

impl<T: Transcendental, const BUF_SIZE: usize> Tremolo<T, BUF_SIZE> {
    pub fn new(rate: f32, depth: f32) -> Self {
        let mut inputs = Vec::with_capacity(1);
        inputs.push(Port::input(NodeId(0), 0, "signal_in"));

        let mut outputs = Vec::with_capacity(1);
        outputs.push(Port::output(NodeId(0), 0, "signal_out"));

        Self {
            id: NodeId(0),
            state: NodeState::default(),
            rate, depth, phase: 0.0,
            inputs, outputs,
        }
    }
}

impl<T: Transcendental, const BUF_SIZE: usize> Node<T, BUF_SIZE>
    for Tremolo<T, BUF_SIZE>
{
    fn metadata(&self) -> NodeMetadata {
        NodeMetadata::new("Tremolo", NodeCategory::Processor)
            .with_type_name("app/tremolo")
            .with_parameter("rate", 0.1, 50.0, 5.0)
            .with_parameter("depth", 0.0, 1.0, 0.5)
    }

    fn init(&mut self, sample_rate: f32) {
        self.state.sample_rate = sample_rate;
    }

    fn reset(&mut self) { self.phase = 0.0; }

    fn get_parameter(&self, id: &ParameterId) -> Option<ParamValue> {
        match id.as_str() {
            "rate" => Some(ParamValue::Float(self.rate)),
            "depth" => Some(ParamValue::Float(self.depth)),
            _ => None,
        }
    }

    fn set_parameter(&mut self, id: &ParameterId, value: ParamValue) -> ProcessResult<()> {
        match id.as_str() {
            "rate"  => self.rate  = value.as_f32().unwrap_or(self.rate),
            "depth" => self.depth = value.as_f32().unwrap_or(self.depth),
            _ => return Err(ProcessError::Parameter(ParameterError::NotFound)),
        }
        Ok(())
    }

    fn id(&self) -> NodeId { self.id }
    fn set_id(&mut self, id: NodeId) { self.id = id; }

    fn input_port(&self, idx: usize) -> Option<&Port<T, BUF_SIZE>> { self.inputs.get(idx) }
    fn input_port_mut(&mut self, idx: usize) -> Option<&mut Port<T, BUF_SIZE>> { self.inputs.get_mut(idx) }
    fn output_port(&self, idx: usize) -> Option<&Port<T, BUF_SIZE>> { self.outputs.get(idx) }
    fn output_port_mut(&mut self, idx: usize) -> Option<&mut Port<T, BUF_SIZE>> { self.outputs.get_mut(idx) }
    fn state(&self) -> &NodeState<T, BUF_SIZE> { &self.state }
    fn state_mut(&mut self) -> &mut NodeState<T, BUF_SIZE> { &mut self.state }
    fn num_signal_inputs(&self) -> usize { 1 }
    fn num_signal_outputs(&self) -> usize { 1 }
}

impl<T: Transcendental, const BUF_SIZE: usize> Processor<T, BUF_SIZE>
    for Tremolo<T, BUF_SIZE>
{
    fn process(
        &mut self,
        clock: &ClockTick,
        inputs: &[&[T; BUF_SIZE]],
        _control: &[T],
        _clocks: &[ClockTick],
        _feedback: &[&[T; BUF_SIZE]],
    ) -> ProcessResult<()> {
        let dt = clock.delta_seconds();
        let out = self.output_port_mut(0).unwrap();
        let buf = out.buffer.as_mut_array();

        for i in 0..BUF_SIZE {
            self.phase += self.rate * dt / BUF_SIZE as f32;
            if self.phase > 1.0 { self.phase -= 1.0; }

            let lfo = (self.phase * std::f32::consts::TAU).sin();
            let gain = 1.0 - self.depth * 0.5 * (lfo + 1.0);

            buf[i] = inputs[0][i] * T::from_f32(gain);
        }
        Ok(())
    }
}
}

Wiring custom nodes into the factory

#![allow(unused)]
fn main() {
use rill_graph::NodeFactory;

let mut factory = NodeFactory::<f32, 64>::new();

// Per-crate registration (rill-core-dsp, rill-digital-filters, rill-router, etc.)
rill_core_dsp::lang::register::register_lang_builtins_for_factory(&mut factory);

// Custom app nodes
factory.register_fn("app/gain", |id, params| {
    /* … */
});
factory.register_fn("app/tremolo", |id, params| {
    let rate = params.get_f32("rate", 5.0).clamp(0.1, 50.0);
    let depth = params.get_f32("depth", 0.5).clamp(0.0, 1.0);
    let mut n = Tremolo::<f32, 64>::new(rate, depth);
    n.set_id(id);
    n.init(params.sample_rate);
    NodeVariant::Processor(Box::new(n))
});

// Serialize/deserialize
use rill_graph::serialization::{to_json, from_json};
let mut builder = GraphBuilder::new(factory);
// … populate builder, build, export/import
}

Referencing custom nodes from GraphDef

Once registered, custom type names work identically to built-in types in all serialisation formats:

{
  "format_version": "rill/1",
  "sample_rate": 48000.0,
  "block_size": 64,
  "nodes": [
    {
      "id": 0,
      "type_name": "app/tremolo",
      "name": "MyTremolo",
      "parameters": { "rate": 4.0, "depth": 0.7 }
    },
    {
      "id": 1,
      "type_name": "rill/sine",
      "name": "Carrier",
      "parameters": { "freq": 440.0, "amp": 0.5 }
    }
  ],
  "connections": [
    { "kind": "Signal", "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 }
  ]
}

Building a custom factory

The recommended pattern for production applications is to build a dedicated factory at startup:

use rill_graph::NodeFactory;

fn build_app_factory() -> NodeFactory<f32, 64> {
    let mut factory = NodeFactory::new();
    // Per-crate registration from individual crates
    rill_core_dsp::lang::register::register_lang_builtins_for_factory(&mut factory);
    factory.register_fn("app/tremolo", |id, params| { /* … */ });
    factory
}

fn main() {
    let factory = build_app_factory();
    let mut builder = GraphBuilder::new(factory);
    // … add nodes, build
}

Git Flow and contribution workflow

Rill uses Git Flow for release management with Conventional Commits.

Branch structure

BranchPurpose
mainStable releases
developIntegration branch
feature/*New features (branch off develop)
release/*Release candidates (branch off develop)
hotfix/*Urgent fixes (branch off main)

Workflow

Setting up

git clone https://github.com/DigitalRats/rill
cd rill
git flow init -d

Creating a feature

git flow feature start my-awesome-effect
# ... work, commit, test ...
git flow feature finish my-awesome-effect

Preparing a release

git flow release start 0.7.0
# update versions in Cargo.toml
cargo test --workspace
git flow release finish 0.7.0
git push --all origin
git push --tags origin

Hotfix

git flow hotfix start 0.6.1
# fix, commit, test
git flow hotfix finish 0.6.1
git push --all origin
git push --tags origin

Commit conventions

<type>(<scope>): <description>

[optional body]

Types: feat, fix, docs, style, refactor, test, chore

Examples:

feat(core): add ParameterId with validation
fix(automation): prevent crash when LFO frequency is zero
docs(readme): add git flow section

Versioning

All crates in the workspace version synchronously. Breaking changes, new features, and patches bump together.

Crates

The Rill workspace consists of 20 crates, all versioned synchronously.

CrateVersionDescriptionDocs
rill-adrift0.6.0-M2Umbrella crate — re-exports all workspace crates; lang feature replaces register_all_nodes() with rill-lang builtin registries via full_registry() / full_registry_f32()docs.rs
rill-core0.6.0-M2Core traits, math, buffers, queues, time, macros, interpolation; builtin module (Registry of lang built-ins), MultichannelAlgorithm and BridgeAlgorithm traitsdocs.rs
rill-core-actor0.6.0-M2Actor model — ActorRef, Actor, ActorSystem for lock-free message passingdocs.rs
rill-core-dsp0.6.0-M2DSP algorithms, vector ops, filters, generators, sample playerdocs.rs
rill-core-model0.6.0-M2WDF core + physical modeling — string, plate, modal, cavitydocs.rs
rill-graph0.6.0-M2Static DAG signal graph with topological sort; optional lang feature enables build_graph_ir() path that bridges to rill-lang::graph_ir::GraphIrdocs.rs
rill-digital-filters0.6.0-M2Biquad, SVF, Comb, MoogLadder filter nodesdocs.rs
rill-digital-effects0.6.0-M2Delay, Distortion, Limiter nodesdocs.rs
rill-router0.6.0-M2EQ (graphic, parametric) + mixer (channels, sends, master)docs.rs
rill-fft0.6.0-M2Radix-2 FFT, frequency-domain convolution, spectrum analysis, spectral effectsdocs.rs
rill-patchbay0.6.0-M2Automation — LFO, envelopes, sensors, servos, mappingsdocs.rs
rill-lofi0.6.0-M2Lo-fi emulation — NES, AY-3-8910, Akai S900docs.rs
rill-io0.6.0-M2Audio I/O — PortAudio, ALSA, PipeWire, JACK backendsdocs.rs
rill-telemetry0.6.0-M2Probes, collectors, real-time monitoring, debug IPCdocs.rs
rill-analyzer0.6.0-M2[CLI] Interactive gdb-style debugger — signal probes, breakpoints, shmem IPC
rill-analog-filters0.6.0-M2WDF-based analog filters — WdfMoogLadderdocs.rs
rill-analog-effects0.6.0-M2Analog circuit models — cassette deck, tape bridge/delaydocs.rs
rill-osc0.6.0-M2OSC — UDP server, encode/decode, pattern dispatchdocs.rs
rill-sampler0.6.0-M2Sample playback + time-series reader + WAV loadingdocs.rs
rill-lang0.6.0-M2Faust-style functional signal DSL; compiles to Algorithm<T> or MultichannelAlgorithm<T> via compile()/compile_with(), or to a full CompiledGraphEngine via compile_graph() with runtime ?name parameter supportdocs.rs

Feature flags

CrateFeatures
rill-coreserde, simd
rill-core-dspsimd, f64, fast_math
rill-core-modellang
rill-fftsimd, f64, graph, lang
rill-graphdebug, serialization
rill-langrouter, serde, debug
rill-patchbaydebug, serde, json, cbor, serialization, midi (MIDI input), osc (OSC input), alsa
rill-ioportaudio (default), midir (default), alsa, pipewire, jack, all-backends, serde-config
rill-samplerwav (default, enables hound), graph, lang
rill-adriftio, lofi, telemetry, osc, sampler, fft, portaudio, serialization (default); debug, analog, midi, alsa, jack, pipewire

Changelog

CHANGELOG

[0.6.0-M2] — 2026-08-02

🐛 Bug fixes

  • Fixed missing register_analog_builtins function in rill-analog-effects/src/lang.rs — the function was called from register.rs but did not exist, causing a compilation error. Added the function with CassetteDeckBuiltin block registration.
  • Fixed rill-adrift/src/modular/mod.rsengine variable needed mut for allocate_probe_slots(1) call under the debug feature.

📚 Documentation

  • Updated docs/src/index.md: removed "audio" from the umbrella description (now "signal processing framework"), added missing crates to the layer table (rill-adrift, rill-sampler, rill-analyzer), fixed rill-lofi description (replaced "console emulation" with "vintage DAC, tape effects, chip emulation"), fixed rill-lang type reference (RillGraphEngineCompiledGraphEngine).
  • Updated docs/src/reference/crates.md: fixed rill-analog-effects description (replaced "op-amp, tape deck, preamps" with "cassette deck, tape bridge/delay"), fixed rill-lang type reference (RillGraphEngineCompiledGraphEngine), corrected all feature flag rows for rill-core-model, rill-fft, rill-graph, rill-lang, rill-patchbay, rill-io, rill-sampler, and rill-adrift.
  • Updated docs/src/architecture/overview.md: "outside audio" → "in any signal domain".
  • Fixed rill-analog-effects/src/lib.rs and register.rs crate doc comments.
  • Updated AGENTS.md: crate count (19 → 20), rill-analog-effects description.
  • Updated README.md: library crate count (18 → 19), rill-analog-effects description, test count badge.

🧹 Housekeeping

  • All 20 crates bumped to 0.6.0-M2 (synchronous versioning).

[0.6.0-M1] — 2026-07-11

🐛 New crate: rill-analyzer — interactive gdb-style debugger

CLI tool for debugging Rill signal processing applications. Three modes:

  • run graph.json — local debugging with embedded REPL
  • attach <pid> — connect to a running process via shared memory
  • launch <target> — start a process and connect immediately

Supports signal probes (break, print, step), command tracing (SetParameter flow), Lua scripting via mlua, and JSON output for automation. Connects to running ModularSystem processes through /dev/shm/rill-debug-<pid> with lock-free ring buffers and SIGUSR1 notifications.

🧪 Diagnostic and debugging infrastructure (debug feature)

rill-lang:

  • ProbePoint IR instruction — lock-free signal sampling (zero overhead when disabled)
  • ProbeSlot with atomic flags (enabled, break_flag, paused_flag) and SPSC queue
  • DebugControl — inter-block pause/resume atomics for engine control
  • CommandFrame — fixed-size copy-compatible frame for actor mailbox tracing

rill-graph:

  • build_ir() now compiles graph nodes to complete rill_lang::Ir with builtins, parameters, and instructions — mirroring the rill-lang DSL compilation path
  • Automatic ProbePoint insertion at each node's output under debug feature

rill-telemetry:

  • CollectorThread — background thread drains probe queues + command log, formats via TextFormatter (colored terminal) or JsonFormatter (JSON lines)
  • ProbeStateManager — handles breakpoints, continue/step/pause, probe enable/disable
  • ShmemRegion/dev/shm/rill-debug-<pid> mmap region with two lock-free SPSC ring buffers for AnalyzerCommand/AnalyzerResponse via serde_cbor
  • AnalyzerCommand/AnalyzerResponse protocol types with automaton/sensor/queue inspection variants

rill-patchbay:

  • PatchbayInspector — collects automaton/sensor snapshots for control-path debugging
  • Servo::inspector() — automaton state snapshot via Arc<Mutex<>>
  • OscSensor::inspect() / MidiHub::inspect() — sensor status snapshots
  • ModuleFactory::construct() accepts an inspector parameter for auto-registration

rill-adrift:

  • debug_init module — init_shmem() / init_shmem_from_env() for IPC setup
  • Lifecycle logging in ModularSystem::launch() — rack creation, engine build, backend connection, shutdown (via log crate)
  • Auto-probes enabled for each graph node; CollectorThread spawned with shmem and PatchbayInspector

⚡ Execution model unification

  • compile_graph() and graph_lower::lower() now use identical buffer numbering (output_bufs = [0], output_mapping = [0], buffers = 1). Both paths converge on the same RillProgram::new_with()ScheduledGraphRillGraphEngine pipeline.
  • build_ir() produces complete Ir with builtins, params, and instrs — no more stub IRs. GraphDef-based graphs and rill-lang DSL programs share the same execution mechanism.

❌ Removals

  • rill-oscillators crate removed from workspace (obsolete Port-based nodes, replaced by rill-lang builtins).
  • rill/input and rill/output built-in identity pass-through nodes removed. ProgramRunner handles I/O directly; graphs no longer need explicit Source/Sink nodes for signal routing.

📋 Breaking changes

  • GraphBuilder::build_ir() now returns complete Ir — may change behavior for existing graphs that depended on stub IRs.
  • RillProgram::new_with() made pub — previously pub(crate).
  • Graphs using SinkDef with type_name: "rill/output" require updating to remove the sink node (output routing is handled by ProgramRunner).
  • SourceDef.backend: None graphs now produce output through graph-level outputs computation (leaf node arity sum), not through explicit sink passthrough.
  • rill-oscillators direct dependencies broken — use rill-oscillators builtins via rill-lang registry or rill-adrift.

📚 Documentation

  • Debugging guide (docs/src/guides/debugging.md) — probe architecture, command logging, shmem IPC, RT safety, lifecycle logging
  • rill-analyzer guide (docs/src/guides/rill-analyzer.md) — REPL commands, Lua scripting, JSON output, attach/launch flows
  • Updated root architecture.md with debug infrastructure and rill-analyzer
  • All crate-level READMEs updated with debug features and architecture changes
  • AGENTS.md: naming conventions (Automaton), debugging priority, RT safety rules, warnings policy, forbidden eprintln! in signal path

🧹 Housekeeping

  • AGENTS.md — strengthened Automaton naming rule with rationale and scope; debugging priority rule; RT safety rules for logging; warnings policy
  • Fixed 3 pre-existing clippy warnings (GraphBuilder Default impl, probe struct init)
  • CmdStr<N> — fixed-size Copy-compatible string buffer for SpscQueue in RT path
  • chiptune_stc example: added --no-wait flag; removed SinkDef; compiles to identical IR as lang_chiptune
  • lang_chiptune example: added --no-wait flag

[0.5.0] — 2026-07-06

🧬 New crate: rill-lang — Faust-style signal DSL

A new workspace crate that compiles a small, functional block-diagram language into a rill_core::Algorithm<T>. Programs describe the internal math of a graph node as source text; the compiler runs a hand-written lexer + Pratt parser, a Hindley-Milner type checker (scalar unification + let-polymorphism, with bottom-up arity synthesis), lowers to a flat linear IR, and runs it on a safe, allocation-free sample-by-sample interpreter.

  • Combinators : (sequential), , (parallel), <: (split), :> (merge), ~ (feedback), @ (integer delay); arithmetic, math builtins, named functions, and top-level process with arity (0|1) → 1.
  • compile::<T>(src)RillProgram<T>: Algorithm<T>.
  • serde featureRillLangDef { source } + compile_def (the source string is the canonical serialized form).
  • rill-adrift lang feature — re-exports rill-lang and registers a rill/lang factory node (reads a source parameter; recompiles on set_parameter).
  • Backend is trait-based; a Cranelift JIT backend is planned behind a future jit feature and will reuse the same IR.
  • Hybrid block processing. The interpreter compiles the IR into an execution schedule via SCC analysis: feedforward regions run whole-buffer through the rill_core::math::vector SIMD eDSL, while feedback/delay recurrences run per-sample. The block path computes in T. The per-sample interpreter is retained as a reference oracle (RillProgram::process_reference). Foundation for whole-graph-as-one-program lowering and the future JIT.
  • DSP/model built-ins (FFI registry). rill-lang programs can call stateful built-ins from rill-core-dsp/rill-core-model via compile_with(src, &registry, sample_rate): per-sample built-ins (onepole, moog — feedback- legal) and whole-buffer built-ins (lowpass, highpass, analog_moog). Params are constants (_ : lowpass(1000.0, 0.7)); signals flow via combinators. Bindings live in rill-adrift (lang_builtins::full_registry, analog_moog behind the analog feature). rill-lang core stays rill-core-only.
  • Named parameters + smoothing. param("cutoff", 1000.0) exposes RT-safe control-rate parameter slots (settable via RillProgram::set_param and, on the rill/lang graph node, by name — so servos/LFO/MIDI automate them for free); smooth(x, ms) is a native one-pole for zipper-free changes; built-in args may be param(...) for dynamic parameterization (lowpass(param("cutoff"), 0.7)).

⏱️ Sample-accurate parameter automation (rill-core, rill-graph, rill-io, rill-patchbay)

Fixes tick-driven control (sequencers, servos) collapsing under backends that batch many block_size chunks into a single I/O callback (e.g. PipeWire's 12288-frame buffer = 48 × 256 chunks). Previously all parameter writes for a callback were applied at the first chunk, so the AY chip in chiptune_stc rendered ~4 register states/s instead of ~48.8 in release builds — the melody dragged. Correct playback on ALSA / debug PipeWire was incidental timing.

  • SetParameter.sample_pos: Option<u64> — optional absolute sample position at which a parameter change should take effect. None = apply on drain (legacy behaviour, unchanged for UI/MIDI-driven writes). Builder: SetParameter::new(...).with_sample_pos(pos).
  • ClockTick.io_quantum: u32 — frames the backend processes per I/O callback (its quantum). Defaults to samples_since_last; chunking backends set the whole callback size. Builder: ClockTick::with_io_quantum(n).
  • Graph applies parameters per block — the graph actor now queues writes that carry a sample_pos and applies each during the 256-sample block whose range contains it (ProcessingState::process_block / Graph::process_block), instead of flushing everything at drain time. Writes without sample_pos still apply immediately (preserves duplex/legacy paths).
  • Producers look ahead by one quantum — because an asynchronous control module reacting to a tick in callback N can only be rendered in callback N+1, producers stamp sample_pos = tick.sample_pos + tick.io_quantum. The chiptune_stc example and the ClockTick-driven Servo writes do this; MIDI/UI-driven Servo writes stay immediate (no latency on live input).
  • Cost: ~one I/O quantum of control latency, i.e. the negotiated buffer duration. Both the PipeWire and PortAudio backends now bound this to buffer_size × AudioConfig::buffer_blocks (default 16 × 256 = 4096 frames ≈ 93 ms at 44.1 kHz); ALSA ≈ 5.8 ms (one period). Tunable via buffer_blocks.

🎛️ Graph adopts the backend's hardware sample rate

The graph has no clock of its own — it runs inside the backend process callback and now adopts the rate carried by each ClockTick.

  • ProcessingState re-initialises nodes on rate change — when the driving ClockTick.sample_rate differs from the rate the nodes were built with (e.g. JACK locked to 48 kHz while the graph was configured for 44.1 kHz), every node is re-inited so chip clocks and filter coefficients match the real rate.
  • JACK backend — the ClockTick now carries the actual JACK hardware rate (was config_rate), fixing playback running hw_rate / config_rate too fast (e.g. +8.8 % at 48 kHz vs 44.1 kHz) with no resampling.
  • PortAudio backend — request a large DMA buffer (buffer_size × AudioConfig::buffer_blocks, default 16 × 256 = 4096 frames) instead of a single 256-frame period, then chunk it back into block_size pieces in the callback, sending one ClockTick per rill block. A single 256-frame period was unstable through the PipeWire ALSA plugin (crackling); a large buffer fixes stability but, when driven as one tick, starved the sequencer (~6× slow). The chunk loop gives the sequencer the correct ~172 ticks/s and a stable buffer; the size also sets the control look-ahead latency (buffer_size × buffer_blocks / sample_rate ≈ 93 ms at 16). The old forced-duplex workaround is removed.
  • PipeWire backend — negotiate a bounded DMA buffer via a SPA_PARAM_Buffers object on stream connect (buffer_size × buffer_blocks = 16 × 256 = 4096 frames by default) instead of accepting PipeWire's large default (~12288 frames). The per-chunk loop still emits one ClockTick per 256-frame block, so tempo is unchanged while the async-control look-ahead latency drops from ~278 ms to ~93 ms.
  • AudioConfig::buffer_blocks — new field (default 16) exposing the DMA buffer size as a multiple of buffer_size for callback-driven backends (PipeWire, PortAudio). Set via with_buffer_blocks() or the "buffer_blocks" backend param. Larger = more robust on constrained/untuned systems, higher control latency; the stable minimum is hardware/config dependent. ALSA (period fixed to buffer_size) and JACK (buffer size set by the JACK server) ignore it.
  • ALSA backend — callback-driven capture and playback. The audio thread now fires the rill process callbacks per period — the capture chain (set_input_process_callback) then the playback chain (set_process_callback) — matching the split-chain model of PipeWire/JACK, and implements a real read_input (previously stubbed to silence, so capture never reached the graph) by publishing each just-read period as an input window. The backend now advertises IoCapture when input_channels > 0, so full-duplex graphs work. Still event-driven via snd_pcm_wait (no thread::sleep).

📦 Version bump and cleanup

  • All 18 crates bumped to 0.5.0-beta.7.
  • Documentation updated: SensorDef::Osc described in architecture docs, rill-osc README cross-references OscSensor, patchbay README covers midi/osc feature flags, stale 0.5.0-beta.2 references fixed throughout docs.

🔌 I/O Backend Extraction (rill-core, rill-io, rill-graph)

Major architecture change: backends extracted from graph nodes to the orchestrator layer. Signal graph is now pure DSP (no I/O knowledge), all hardware interaction lives in ProcessingState + backend traits.

rill-core (io.rs):

  • IoBackendIoDriver + IoCapture + IoPlayback — single monolithic trait split into three orthogonal capabilities. One struct can implement any combination: IoDriver runs the clock loop, IoCapture reads input samples, IoPlayback writes output samples. Mirrors the MidiInput/MidiOutput split on the audio side.
  • BufferView trait — zero-copy DMA access during I/O callback: read_input(channel, dst) and write_output(channel, src). Nodes hold Arc<dyn BufferView> and read/write directly without intermediate ring buffers.
  • ProcessingState — new: owns graph runtime parts (actor mailbox, node storage, parent rack ref). Created via graph.into_processing_state(). Wired with backends via wire_backends(capture, playback). Drives processing loop: process_block(&ClockTick) → DSP → send_clock_tick().
  • ParameterWrite trait — polymorphic parameter injection into the graph mid-cycle (used by PipeWire per-chunk params).
  • Removed: IoNode, ActiveNode traits — backends no longer injected into graph nodes.

rill-io:

  • DirectView — interleaved/planar DMA access via raw pointers, implements BufferView. Created per-callback by each backend. read_input()/write_output() operate directly on hardware DMA buffers — no copies between graph and backend.
  • OutputWindow — adapter for backends that need partial buffer writes. Wraps IoPlayback + DirectView, handles multi-chunk DMA.
  • ClockTick.is_final — flag for chunking backends, gating send_clock_tick(). Note: current chunking backends (PipeWire, JACK) leave it true on every chunk, so control modules receive one ClockTick per block_size block; sample-accurate placement is handled by SetParameter.sample_pos + ClockTick.io_quantum (see the entry at the top of this file), not by coalescing ticks per buffer.
  • PipeWire backend — major rewrite for chunk processing. DMA buffer split into chunks of block_size, per-chunk parameter updates via ParameterWrite. Zero-fill DMA remainder after chunk loop. (Buffer size is whatever PipeWire allocates — the backend does not yet negotiate SPA_PARAM_Buffers.)
  • JACK backend — chunk processing by block_size, uses orchestrator running flag for shutdown. run() returns immediately (callback-driven), stop() coordinates with JACK thread.
  • PortAudio backend — unchanged structurally, gains DirectView
    • OutputWindow for output path.
  • ALSA backend — unchanged structurally, poll-driven (snd_pcm_wait), gains same view/window pattern.

rill-graph (backend_factory.rs):

  • BackendFactory refactored. Constructor signature changed: fn(params) -> Box<dyn IoBackend>fn(params) -> (Arc<dyn IoDriver>, Option<Arc<dyn IoCapture>>, Option<Arc<dyn IoPlayback>>).
  • Bundle types: DuplexBundle (driver + capture + playback), OutputBundle (driver + playback), InputBundle (driver + capture).
  • create_any() — returns whatever capabilities the backend provides. Replaces create() -> Box<dyn IoBackend>.
  • Caching — backends cached by name in factory, reused across racks.

Backend lifecycle (complete):

orchestrator:
  1. factory.create_any(name, params) → (driver, capture, playback)
  2. graph.into_processing_state() → ProcessingState
  3. state.wire_backends(capture, playback)
  4. driver.set_process_callback(|tick| { state.process_block(&tick); })
  5. driver.run(running)

callback (RT thread):
  state.process_block(&tick) → Source::generate → DSP → Sink::consume
  state.send_clock_tick(&tick) [gated on tick.is_final]

Removed: LofiInput node (rill-lofi). Replaced by LofiChipSource — a Source node wrapping any Algorithm<f32> + ChipEmulator + ParameterWrite. IoControl trait provides write_data() channel for chip register writes via the backend's control interface.

🎹 MIDI Output (rill-io, rill-patchbay, rill-adrift)

MIDI output infrastructure — rill as MIDI master, sending Clock, Transport, and (future) Note messages to external devices.

rill-io — backend architecture:

  • MidiBackendMidiInput (breaking rename) — trait now accurately reflects its input-only role (poll() -> Vec<MidiMessage>).
  • MidiOutput trait (new) — send(&mut self, &MidiMessage) -> IoResult<()>, symmetric to MidiInput. Together they mirror the audio-side IoCapture/IoPlayback separation — input and output are distinct traits, each backend implements the direction(s) it supports.
  • MidirBackend — struct refactored: _conn field changed from MidiInputConnection<()> to MidirConnection enum (Input/Output variants). New constructors: new_output(), new_output_by_name() using midir::MidiOutput::connect(). Backend can now be opened in either direction — reused across both MidiInput and MidiOutput trait impls.
  • AlsaSeqBackend — struct unchanged (seq::Seq is inherently bidirectional). New new_output() constructor opens with Direction::Playback + PortCap::WRITE (vs Capture + READ for input). New midi_to_alsa_event() helper — reverse of existing alsa_event_to_midi() — converts MidiMessage to ALSA Event for event_output() + drain_output().
  • JackMidiBackend — most significant struct change: rx split to Option<Receiver<MidiMessage>>, new tx: Option<SyncSender<MidiMessage>>. JackMidiHandler (process callback) becomes bidirectional: MidiIn port → channel → MidiInput::poll(), and channel → MidiOut port → MidiOutput::send(). Both directions coexist in one JACK client — connect() opens input, connect_output() opens output. Same pattern for internal comms (input drains tx → rx, output feeds tx → rx in reverse).

rill-patchbay:

  • MidiClockGenerator — output-side counterpart of MidiClockTracker. Pure math: converts ClockTickVec<ControlEvent::MidiClock> using 24ppqn (24 pulses per quarter note). Derives tick spacing from absolute sample position — no cumulative drift. Transport state machine: Start resets phase, Stop/Continue follow standard MIDI transport semantics. 6 unit tests.
  • spawn_midi_clock_output() — actor owning MidiClockGenerator + Box<dyn MidiOutput>. Receives ClockTick via Rack broadcast and MidiTransport commands, serializes via serialize_to_midi(), sends through backend.
  • serialize_to_midi() — reverse of parse_midi(). Converts ControlEvent::MidiClock0xF8, MidiTransport0xFA/0xFB/0xFC, MidiNote0x90/0x80. Round-trip tests: parse_midi(serialize_to_midi(e)) == e.
  • ClockDef { backend, port_name, auto_start } — serializable MIDI clock output descriptor. Added to ModuleDef::Clock(ClockDef) variant.
  • Re-exports: MidiClockGenerator, spawn_midi_clock_output, serialize_to_midi, ClockDef.

rill-adrift:

  • ModuleDef::Clock(ClockDef) variant in adrift serialization layer, for ModularSystemDef JSON documents.
  • ClockConstructor — registered in ModuleFactory as "clock". Creates MidiOutput backend, calls spawn_midi_clock_output(), supports auto_start.
  • to_pb_module() + rack dispatchClockDef conversion and module ID extraction for rack actor fan-out.

Design doc + plan: docs/superpowers/specs/2026-06-30-midi-output-design.md, docs/superpowers/plans/2026-06-30-midi-output-plan.md.

⚡ Servo conflict resolution (rill-patchbay)

  • Servo::with_control() / Servo::with_conflict() — builder methods to configure ControlStrategy and ConflictStrategy on a Servo.
  • with_control(Modulation { depth }) — automaton output modulates around state.base, combinable with HID input via BasePlusModulation.
  • with_conflict(TouchOverride) — HID input freezes automaton via state.frozen, resumes on UiRelease.
  • with_conflict(BasePlusModulation) — HID input updates state.base; automaton modulates around it on next ClockTick.
  • ServoConstructor now passes ServoDef.control_strategy and ServoDef.conflict_strategy through to Servo construction.
  • Control handler fallback mapping arm now checks ConflictStrategy: was ignoring state.frozen and state.base — now respects all three strategies.
  • Dead code removed: UiCommand enum (strategy.rs) — never used.
  • Docs: all PortCombiner references replaced with Servo+strategy architecture diagrams across README.md, patchbay-rack.md, actor.md, two_thread_architecture.md.

[0.5.0-beta.5]

🕐 Unified RenderContext (Breaking)

rill-core (time/render.rs):

  • RenderContext — single stack-allocated context per processing block: sample_pos, samples_since_last, sample_rate, transport: TransportState, speed_ratio (hardware clock correction, default 1.0).
  • TransportStateis_playing, bpm, frame_pos, time_sig_num/den, bar_start_frame. Replaces ClockTick::tempo: Option<f32>.
  • Musical methods moved from ClockTick to RenderContext: beat_position(), musical_position(), is_new_bar(), is_new_beat() — now use configurable time_sig_num/den (no longer hardcoded 4/4).
  • ProcessContext and ActionContext removed — replaced by &RenderContext throughout the trait system.

Trait signatures (breaking):

  • Algorithm::process(input, output)ctx parameter removed (97.4% of impls ignored it; 2 tape heads now use init() for sample rate).
  • Source::generate(&RenderContext, …), Processor::process(&RenderContext, …), Sink::consume(&RenderContext, …), Router::route(&RenderContext, …) — all use &RenderContext instead of &ClockTick.
  • Port::propagate() — context parameter removed; single &RenderContext flows through the DAG without re-wrapping.
  • Port::run_action() — context parameter removed.
  • Port::pre_process()_tick parameter removed.

Graph:

  • Graph::run() I/O callback creates one RenderContext per block and passes it to both process_block() and propagate() — no more ProcessContext + ActionContext duplication.
  • Graph.system_clock: Option<Arc<SystemClock>> — when set, creates RenderContext::with_tempo() with BPM from the shared clock.

🎛️ MIDI Clock Sync

rill-patchbay (midi_clock.rs):

  • MidiClockTracker — counts 24ppqn clock pulses (0xF8), derives BPM via running average, writes atomically into Arc<SystemClock>.
  • MidiClockStrategy trait with three built-in strategies: FreeRunning (BPM only), ResetOnStart (position reset on Start), SongPosition (position reset + is_playing() flag).
  • is_playing: Arc<AtomicBool> — shared flag, set on MIDI Start/Continue, cleared on Stop. Sequencers and automations check this before producing output.
  • Integrated into MidiHub — optional via MidiHub::with_clock_tracker(). The tracker's SystemClock feeds BPM to Graph.system_clock.

🌐 OSC Sensor (rill-patchbay)

  • OscSensor (osc.rs) — OSC input sensor modelled after MidiHub/spawn_midi_sensor. Binds a UDP socket in a dedicated OS thread, decodes incoming OSC packets via rill-osc, produces ControlEvent::Osc { address, args } events. Bundles unwound recursively. Implements Module + Sensor traits.
  • spawn_osc_sensor() — actor-model variant: spawns a control actor for SetEnabled commands + UDP recv loop in OS thread. Sends CommandEnum::Control(event) to the servo for mapping.
  • parse_osc() — converts OscMessageControlEvent::Osc. Numeric args (Int, Float) collected; strings and blobs silently dropped.
  • SensorDef::Osc { port, mappings } — serializable descriptor variant in module_def.rs. into_sensor() gated on any(feature = "midi", feature = "osc").
  • OscConstructor — registered in ModuleFactory via rill-adrift: creates mapping-only servo + spawn_osc_sensor() pair. Activated by ModuleDef::Sensor(SensorDef::Osc { ... }).
  • Feature gate: osc = ["dep:rill-osc"] in rill-patchbay; rill-adrift/osc enables rill-patchbay/osc passthrough.
  • Existing EventPattern::OscAddress / OscPattern matching in servo works out-of-the-box — sensor produces ControlEvent::Osc, servo matches via EventPattern::matches().

🔌 JACK MIDI + Transport

rill-io:

  • JackMidiBackend — JACK MIDI input backend. Registers a MidiIn port, bridges JACK process callback to MidiBackend::poll() via mpsc channel (same pattern as MidirBackend).
  • JackBackend::set_system_clock() — JACK transport sync: reads BPM from TransportBBT in process callback, writes atomically to SystemClock.

🔈 Lofi: DC Offset + Output Ceiling

rill-lofi (config.rs, lofi_processor.rs):

  • LofiConfig.dc_offset — subtracted from signal after dry/wet (before gain). Default 0.0. Use 0.5 for AY-3-8910 to centre [0, 1] around zero.
  • LofiConfig.output_ceiling — hard clamp [-ceiling, +ceiling] (default 1.0).
  • Formula order: (dry_wet_mix - offset) * gain, clamp to ±ceiling.
  • New parameters exposed as "dc_offset" and "output_ceiling" in LofiProcessor metadata → available through SourceDef.parameters.
  • 3 new tests: offset removal, ceiling clamp, combined behaviour.

Registration (rill-adrift/src/registration.rs):

  • rill/lofi_input constructor now reads dc_offset, output_gain, output_ceiling from Params.

🧱 Physical Modeling in rill-core-model

Four new resonant model modules (rill-core-model):

  • string — 1D digital waveguide with fractional-delay allpass interpolation, stiffness dispersion, and frequency-dependent damping. Implements Algorithm<T> + ParameterizedAlgorithm<T, Params = StringParams<T>>.
  • plate — 2D FDTD waveguide mesh on rectangular grid with clamped/free boundary conditions. Impulse excitation at configurable position.
  • modal — parallel bank of 2-pole resonant filters for modal synthesis. Pre-built presets: bell_modes() (5 modes, inharmonic bell ratios) and marimba_modes() (3 modes, harmonic bar ratios).
  • cavityHelmholtzCavity (single Helmholtz resonator with optional reed excitation for wind instrument modeling) and CavityArray (1D chain of coupled cavities for wave propagation experiments / acoustic metamaterials).

All four types implement Algorithm<T> + ParameterizedAlgorithm<T>. 24 new tests.

♻️ ParameterizedAlgorithmrill-core

rill-core (traits/algorithm.rs):

  • ParameterizedAlgorithm<T> trait added — typed parameter access for any Algorithm (params(), set_params(), set_parameter()). Generic over type Params: Clone + Send + Sync. Previously lived in rill-core-dsp.

rill-core-dsp:

  • rll-core-dsp/src/algorithm.rs — now re-exports ParameterizedAlgorithm from rill-core; definition removed.
  • Algorithm, AlgorithmCategory, AlgorithmMetadata, ActionContext, ProcessResult no longer re-exported from rill-core-dsp — all consumers import directly from rill_core::traits.
  • 7 filter ParameterizedAlgorithm impls unchanged.

📦 rill-core-wdfrill-core-model

  • Crate renamed: rill-core-wdfrill-core-model
  • Internal module filterswdf (path: rill_core_model::wdf::*)
  • All imports across workspace updated (4 crates, 14 docs, 3 scripts)
  • Current module listing: macros, analysis, constants, wdf, tape, string, plate, modal, cavity

📝 Terminology: «audio» → «signal» / «I/O»

Public API (breaking):

  • rill_oscillators::audiorill_oscillators::signal — module rename
  • PortType::is_audio_rate()is_signal_rate() in rill-core
  • AudioTimerSignalTimer in rill-core
  • AudioConfigIoConfig in rill-core
  • RackCase::audio_threadsignal_thread in rill-adrift

Cargo.toml descriptions — «audio» → «signal» / «I/O» in 7 crates: rill-graph, rill-sampler, rill-telemetry, rill-router, rill-osc, rill-digital-effects, rill-adrift.

Documentation — «audio thread» → «signal thread», «audio data» → «signal data», «audio backends» → «I/O backends», «audio path» → «signal path», etc. (~120 occurrences across .rs doc comments, architecture docs, AGENTS.md, README.md).

IoBackend in rill-core formally positioned as a generic I/O archetype — applicable to any discrete data stream, not just audio.

Preserved: rill-io and rill-lofi keep «audio» terminology (genuinely audio-specific — hardware I/O, emulators).

🎛️ AY-3-8910 Emulator Fixes

rill-lofi:

  • Mixer register R7 bit layout — bits 0–2 = tone A/B/C, 3–5 = noise A/B/C (fixed; was grouping bits 0-1,2-3,4-5 per channel)
  • Envelope period dividerf / (16 × EP)f / (256 × EP) per AY-3-8910 datasheet
  • Noise LFSR output bit — save bit 0 before shift (was reading bit 16 after shift)
  • Test test_mixer_register_bit_mapping updated for correct layout

🏭 Module Factory

rill-patchbay/src/module_factory.rs (new):

  • ModuleConstructor trait — construct(id, params, system, graph_ref) → BoxedModule
  • ModuleFactoryregister_fn(type_name, drain, closure), register_fn_send()
  • Drain enum — OsThread { interval_ms }, TokioTask { interval_ms } (for many actors without OS thread overhead)
  • GenericModule — factory-provided Module impl, no manual struct needed

rill-patchbay/src/serialization/mod.rs:

  • ModuleDef::Custom { type_name, params } — dispatch through ModuleFactory in build_servos()

rill-adrift/src/modular/mod.rs:

  • ModularSystem.module_factory: ModuleFactorymodule_factory_mut() for pre-launch registration
  • Rack actor drain loop: tokio::spawnstd::thread::spawn (avoids Send requirement on handler)

🎭 Actor Model Unification

rill-core-actor:

  • Removed: Actor<M> (old Send variant), LocalActor<M>, ActorCell trait, MessageDispatcher, build_actor()
  • Added: spawn_detached(name, make_handler, ms) — handler created inside spawned thread, ActorRef returned immediately
  • Added: spawn_detached_tokio(name, make_handler, ms) — same but on tokio task (handler: Send)
  • spawn(name, handler) — remains for inline drain (Graph, Rack)
  • Actor design rule: handler is always created on the thread where it is drained; never crosses thread boundary; Send bound removed from handler closure

🔧 Sequencer & Servo Fixes

rill-patchbay/src/automaton/sequencer.rs:

  • Removed dead Step.value and Step.curve fields (Step now only has duration)
  • Fixed step_duration() formula: removed × 4.0 factor (now 1.0 = quarter note, not whole note)

rill-patchbay/src/engine.rs:

  • Added Servo::with_table() builder — propagates table from ServoDef to Servo
  • Servo::spawn() uses spawn_detached_tokio — handler created inside tokio task, no actor crossing thread boundary

rill-patchbay/src/serialization/mod.rs:

  • build_servos() now propagates ServoDef.tableServo::with_table()

🎵 Chiptune Examples

rill-adrift/examples/chiptune.rs:

  • 3-channel AY melody: Ch A (melody), Ch B (bass), Ch C (snare), 16 steps × 120ms, bass changes every 4 steps
  • Fixed Output channels=1 (was defaulting to stereo, causing PipeWire panic)
  • Duration: 120ms → 0.24 quarter-note beats (matching fixed step_duration formula)
  • Removed unused HashMap import

rill-adrift/examples/chiptune_stc.rs:

  • Rewritten to use ModularSystemDef + ModuleFactory (register_fn with Drain::OsThread)
  • STC player registered as ModuleDef::Custom { type_name: "stc_player" }
  • Removed: manual GraphBuilder, graph.run(), StcModule struct, sys.spawn(), actor.drain(), thread::spawn

🔩 RackCase Fix

rill-adrift/src/modular/case.rs:

  • RackCase::stop() — added handle.thread().unpark() before handle.join() (was hanging on exit)
  • tasks type: Vec<tokio::task::JoinHandle>Vec<std::thread::JoinHandle>

📝 Documentation

docs/src/guides/chip-emulators.md:

  • Rewritten: accurate register map, architecture diagram, io_write control chain, lofi processing chain
  • Known Limitations section — output sampling, anti-aliasing, register change timing, I/O ports, phase delay
  • Timing accuracy section — tone/envelope/noise frequency formulas, accuracy bounds

docs/src/architecture/actor.md:

  • Updated for current API: Actor<M>, three spawn variants, handler-creation design rule

🧹 Cleanup

  • Removed: dead Actor<M> (Send variant), ActorCell, build_actor(), Step.value/Step.curve
  • rill-io/Cargo.toml — removed unused base64 dependency
  • rill-core-actor/Cargo.toml — added optional tokio dependency (feature-gated spawn_detached_tokio)
  • PortAudio callback — removed debug base64 output

🏗️ Architecture: RackDef unification + CaseDef removal

rill-adrift/src/modular/serialization.rs:

  • New RackDef with graph: GraphDef field — graph lives inside the rack, not in a separate CaseDef
  • New ModuleDef::Graph { graph: GraphDef } variant — multiple graphs per rack
  • build_servos() moved from rill-patchbay to rill-adrift
  • ModularSystemDef.racks: Vec<RackDef> replaces cases: Vec<CaseDef>
  • CaseDef removed entirely — patchbay: Option<RackDef> no longer needed

rill-adrift/src/modular/mod.rs:

  • launch() simplified: single loop over def.racks, no has_rack check
  • Rack actor drain: tokio::spawnstd::thread::spawn (avoids Send requirement)
  • Graph construction stays in launch() (not via factory)

rill-patchbay/src/serialization/mod.rs:

  • RackDefPatchbayDef (backward-compatible rename, without graph field)
  • ModuleDef (without Graph variant) + build_servos() remain in rill-patchbay

rill-adrift/src/modular/config.rs:

  • LaunchConfig.rack_def type: RackDefPatchbayDef

🔌 CommandEnum::Stop + Drain::IoCallback

rill-core/src/queues/signal.rs:

  • CommandEnum::Stop + CommandType::Stop — shutdown command for I/O loops

rill-patchbay/src/module_factory.rs:

  • Drain::IoCallback variant — for graph modules with inline drain (not yet used via factory)

📝 Documentation

docs/src/architecture/actor.md:

  • Updated for current API: Actor<M>, three spawn variants, handler-creation design rule

docs/src/guides/chip-emulators.md:

  • Rewritten: accurate register map, known limitations, timing accuracy section

Previous (0.5.0-beta.4)

rill-core-actor:

  • Actor<M> — handler: Send, для многопоточных акторов (Patchbay через tokio)
  • LocalActor<M> — handler: !Send, для однопоточных (Graph, RackCase)
  • ActorSystem::spawn() / spawn_local() — создание акторов с handler-замыканием
  • ActorRef<M> — lock-free handle для отправки сообщений, единственный внешний интерфейс
  • Удалены: ActorCell trait, Mbox, MessageDispatcher, ActorRef::new_pair(), generic ActorSystem<M>

rill-graph:

  • GraphBuilder::build(&ActorSystem) — создаёт актор с handler'ом, захватывающим nodes
  • Graph::run() — tick-замыкание владеет actor'ом напрямую (без *mut Graph)
  • Nodes хранятся в Rc<UnsafeCell<Vec<NodeVariant>>> — interior mutability на одном потоке
  • Удалены: *mut NodeVariant, *mut Graph, ActorCell impl, mailbox поле
  • Сигнальные тесты: test_graph_source_to_sink, test_graph_source_proc_sink

rill-patchbay:

  • Patchbay struct удалён. Вместо него — Servo::spawn(self) → ActorRef<CommandEnum>
    • Создаёт актор с полным handler'ом (ClockTick → automaton.step → SetParameter)
    • Запускает std::thread drain loop (1ms interval)
    • Внешний код получает только ActorRef — никакого прямого доступа к состоянию
  • Servo больше не Module — автономный актор, не type-erased box
  • PatchbayDefRackDefbuild_servos(&ActorSystem, &graph_ref) → HashMap<String, ActorRef>
  • add_lfo, add_envelope, add_boxed_servo удалены — сборка в launch() напрямую
  • Module trait — только для Sensor; убраны drain(), update()
  • Channel-forwarding (mpsc) между actor'ами удалён — каждый актор самодрейнится

rill-adrift:

  • RackCase — минимальный хост: modules: HashMap<String, ActorRef>, tasks: Vec<JoinHandle>
    • Удалены: patchbay, incoming, outgoing, ActorCell impl, межкейсовый routing
    • handle() → ActorRef — для parent_ref в Graph
    • stop() — abort всех tasks, join audio thread
  • launch():
    1. Создаёт актор RackCase (с Arc<Mutex<HashMap>> для модулей)
    2. Запускает drain thread актора (пересылает ВСЕ сообщения всем модулям)
    3. Строит граф на audio thread
    4. Получает graph_ref через oneshot канал
    5. rack_def.build_servos() — создаёт Servo'ы с drain threads
    6. Регистрирует servo ActorRef'ы в RackCase модулях
  • Удалены: create_case(), load_patchbay(), load_graph(), create_patchbay(), tick(), start_osc(), OSC, control, control_shared, control_arc, AutomatonFactory

Архитектура ClockTick → Sequencer → Graph:

Graph.run() → tick: parent_ref.send(ClockTick)
  → RackCase actor (drain thread): for ref in modules: ref.send(msg)
  → Servo actor (drain thread): ClockTick → automaton.step() → graph_ref.send(SetParameter)

🔧 Сопутствующие исправления

  • PortAudio: off-by-one в write()cap / nch теперь используется как bound цикла (был краш index out of bounds: 256)
  • advanced_player: комментарий --features "cpal,…""portaudio,…"
  • play_wav: пример ручной сборки графа переписан (был заглушкой let _ = system)

SIMD acceleration (feature/simd)

  • Vector infrastructure:

    • SimdDetector — real CPU feature detection via std::arch (SSE2/AVX/NEON/SIMD128)
    • VectorMask<T, N> completed for F32x4, F32x8, F64x2, ScalarVector4
    • VectorReduce, VectorScalarOps traits with blanket impls
    • Scalar::from_usize() added to core math trait
    • Dead expr module + vec_expr!/vec_eval! stubs removed
  • Algorithm SIMD (rill-core-dsp):

    • BasicOscillator — 6 waveforms via ScalarVector4 block processing (4 samples/iter)
    • Saw BLEP — VectorMask::select replaces per-lane scalar conditional (2.5× speedup)
    • InterpolatedReader — 4-wide lerp math for linear/cubic interpolation
    • CombFilter — batched 4-sample read/write when delay_samples >= 4
    • NoiseGenerator — White (batched xorshift), Brown (unrolled integrator), Blue/Violet (4-wide diff)
    • Biquad — block state-space 4×4 feedforward matrix via BiquadBlock precomputation
    • Resampler<T> — sample-rate converter on InterpolatedReader (44.1k→48k etc.)
  • Node-level SIMD:

    • Distortion — HardClip/Tube 4-wide SIMD; zero-copy port output
    • DryWetMix — 4-wide multiply-add, stereo in one pass
    • WriteHead — batched 4-sample math per tape write
    • pre_process() — feedback mix via 4-wide add (all feedback nodes accelerated)
    • 8 nodes: direct port buffer write eliminates 2 [T; BUF_SIZE] copies per block per node
  • WDF SIMD (rill-core-model):

    • process_incident_vector on Resistor, Capacitor, Inductor, Diode via ScalarVector4
    • Diode Newton-Raphson vectorized with VectorMask::all() early exit
    • process_batch_simd free function for batch processing
    • simd.rs deleted (378 LOC) — no more parallel SIMD type hierarchy
  • I/O SIMD:

    • Generic f32_to_i16_chunk / i16_to_f32_chunk in rill-core::math::functions (reusable for ALSA, rill-lofi)
    • ALSA backend uses SIMD f32↔i16 conversion
    • PipeWire byte→f32 batched 4-sample conversion
    • Deinterleave/interleave SIMD in PipeWire backend
  • Infrastructure:

    • FixedBuffer now #[repr(align(16))] (hardware SIMD-ready)
    • const { assert!(BUF_SIZE % 4 == 0) } in processable.rs (monomorphization-time check)
    • Criterion benchmarks: vector ops, 6 oscillators, 3 filters, 4 noise types, reader/resampler
    • Benchmark results at docs/superpowers/specs/2026-05-10-simd-benchmark-results.md
    • Key finding: ScalarVector4 + LLVM auto-vectorization matches/exceeds explicit wide crate on x86_64. Rill outperforms JUCE (C++) by 10-160× on key DSP primitives.

✨ Patchbay architecture refactor (feature/refactor/midi-hub, feature/refactor/sensor-midi)

  • Automaton trait redesigned:

    • (config, &mut internal, &current, time, action) → ParamValue
    • type Internal: Clone — mutable automaton-specific state (phase, RNG, step counter)
    • initial_internal(), reset() with default impls
    • All state moved inside structs; old State/Output associated types removed
    • All 6 automata (LFO, envelope, sequencer, function, random, cellular) updated
    • LFO: now uses self.waveform — all 8 waveform types functional (was hardcoded to Sine)
    • Random: update_rate field drives throttling via last_update_time in Internal
  • Servo as actor:

    • Servo<A: Automaton> implements ActorCell<Msg = AutomatonMsg>
    • AutomatonMsg { Tick(ClockTick), SetEnabled(bool), Reset } — unified queue for clock + commands
    • Servo::update() drains mailbox before stepping (same pattern as Graph::run)
    • Servo::handle() returns ActorRef<AutomatonMsg> for external control
    • Servo::with_table(Vec<ParamValue>) — table-based step-to-value mapping for sequencers
    • SequencerAutomaton returns ParamValue::Int(step_index) → Servo looks up in table
  • Sensor trait — unified external input bridge:

    • trait Sensor { attach(), start(), stop() } — MIDI, OSC, knobs, acoustic analysis
    • MidiHub implements Sensor — no more Arc<Mutex<Patchbay>>
    • Patchbay::event_mailbox — single MpscQueue<ControlEvent> for ALL sensors
    • event_handle() → ActorRef<ControlEvent>, drain_events() called from drain_clock()
    • Multiple sensors can run independently, all events via one lock-free mailbox
  • Hearing module for future acoustic sensors:

    • PitchDetector, EnvelopeFollower, ZeroCrossing — audio analysis algorithms
    • Ready for wiring into graph telemetry (audio feedback → control signals)

🗑️ Removed

  • crossbeam-channel — removed from all crates (rill-core, rill-patchbay, rill-adrift)

    • CommandQueue (crossbeam-based) deleted; Command trait kept in rill-core::queues
    • TelemetryTx (crossbeam wrapper) deleted; Telemetry types kept for future use
    • Observer moved to rill-patchbay, now uses ActorRef<Telemetry>
    • SequencerHandle (crossbeam command channel) deleted
    • attach_sequencer() (crossbeam Receiver<Telemetry> parameter) deleted
  • Manager (806 LOC) — deprecated sync rack, zero external callers

  • SnapshotSequencer + sequencer types (728 LOC in sequencer/)

  • SequencerDef serialization (170 LOC)

  • sensor/physical.rs (dead code referencing non-existent types)

  • automaton/mapping/ (156+183+155 LOC dead code)

  • MidiActor renamed to MidiHub; midi_actor.rsmidi.rs

  • Graph::receive() now drains via ActorCell (was manual set_parameter loop)

🔧 Fixes

  • RT safety: MixerNode vec![][f32; BUF_SIZE] stack allocation
  • RT safety: PortAudio vec![] temp buffer → [f32; 8192] stack
  • RT safety: ParallelAdapter Vec<T>[T; 8] stack allocation
  • Graph::receive()debug_assert! for SetParameter misconfiguration
  • LFO: all 8 waveform types functional in step() (was hardcoded to Sine)
  • Random: update_rate field drives throttling
  • Documentation synced with code (12 discrepancies fixed)
  • Zero compiler warnings with --all-features

✨ STC chiptune player (feature/feat/stc-player)

  • rill-adrift/examples/chiptune_stc.rs — full Sound Tracker Compiled (STC) player
    • Plays ZX Spectrum chiptune files through the Ay38910Backend AY-3-8910 emulator
    • Loads the STC file (Bonysoft - Popcorn (1993).stc) via include_bytes!
    • Implements the libayemu-compatible event-driven architecture:
      • Per-channel byte-stream event reading with delay/interrupt timing
      • Per-frame pitch computation: ST_TABLE[note + ornament[pos] + transposition] ± sample_delta
      • 32-step sample (instrument) rendering with volume, tone/noise mixer masks, and pitch deltas
      • Synchronized 32-step ornament (pitch modulation) and sample position advancement
      • Sample repeat/loop logic, envelope triggering, position advancement on channel A end marker
    • Timing at 48.828 Hz Pentagon INT rate via step_ms() time accumulation from audio callbacks
    • Uses the same graph/clock architecture as chiptune.rs — validates the engine timing

[0.5.0-beta.4] — 2026-05-08

✨ New

  • IoNode / ActiveNode trait hierarchy in rill-core::traits::node:

    • Node — base trait, no backend, no run method
    • IoNode: Noderesolve_backend(backend) for I/O-capable nodes
    • ActiveNode: IoNoderun(tick, running) for the single driver node
    • as_io_node_mut() / as_active_node_mut() downcasting helpers on Node
    • Input, Output, LofiInput implement IoNode
    • Input, Output implement ActiveNode
    • GraphBuilder::build() uses downcasting instead of name-based matching
    • Graph::run() calls ActiveNode::run() instead of Node::run()
    • GraphRunner trait removed — replaced by Box<dyn FnMut(u64, f32)>
    • Inherent resolve_backend() convenience methods on Input/Output
  • Chip emulator architecture — unified model for vintage sound chips:

    • Ay38910Chip + Ay38910Backend — AY-3-8910 / YM2149 (3 tone, noise, envelope)
    • NesChip + NesBackend — NES 2A03 APU (2 pulse + sweep, triangle, noise, DPCM)
    • IoControl trait in rill-core::io — uniform register write interface
    • LofiInput<T, BUF_SIZE>Source node wrapping any IoBackend with lofi processing
  • WDF tape module in rill-core-model:

    • RecordHead<T>, PlaybackHead<T> — analog tape physics, Algorithm<T>
    • OpAmp<T> — operational amplifier as WdfElement<T>
    • CassetteDeck in rill-analog-effects refactored to use heads from rill-core-model
  • Transcendental trait extended: tanh(), signum(), random() — enables stochastic modeling in generic WDF/dsp code

  • NES 2A03 sweep unit — full hardware sweep emulation (divider, direction, shift, period underflow/overflow mute)

🔧 Fixes

  • rill-io: set_process_callback signature changed from Fn() to Fn(f32) — each backend passes its actual negotiated sample rate to the process callback. ClockTick.sample_rate now always reflects the true device rate.
  • rill-io/jack: reads client.sample_rate() after activation, passes to callback.
  • rill-io/alsa: queries hw.get_rate() after set_rate(Nearest), enforces exact period match (hw.get_period_size() == BUF_SIZE), rejects mismatches. Fixed write() — was hardcoded for stereo, now handles N channels with proper interleaving.
  • rill-io/pipewire: output chunk no longer hardcoded to 512 samples — uses buf_frames * out_channels for correct mono timing. write() fixed for N channels.
  • rill-lofi/emulators: removed unsafe impl Send/Sync — backends run exclusively in the hard-RT audio thread.
  • rill-core/io: IoBackend and IoControl traits no longer require Send + Sync.
  • rill-core-actor: ActorCell no longer requires Send.
  • rill-adrift/chiptune: step() uses f64 timing (no millisecond quantization), Ay38910Backend lazily created with actual sample rate, lofi.init(sr) called for correct processor configuration.
  • rill-adrift/record_mic: graph built inside audio thread spawn (no Send needed).

✨ New

  • rill-io/portaudio — cross-platform PortAudio backend (portaudio feature). Exact buffer size, no BufferSize::Default issues, simpler API. Default backend replacing CPAL.

🧹 Removed

  • rill-io/cpal — replaced by rill-io/portaudio (cross-platform, cleaner API)
  • Ay38910Emulator, NesEmulator — replaced by Chip + Backend + LofiInput
  • rill-analog-effects::OperationalAmplifier — replaced by rill_core_model::OpAmp

📖 Documentation

  • New guide: Chip Emulators (docs/src/guides/chip-emulators.md)
  • Examples section added to root README.md — all 5 rill-adrift examples described with cargo run commands
  • Spec + plan for IoBackend-based emulator architecture in docs/superpowers/

[0.5.0-beta.3] — 2026-05-07

✨ New

  • rill-core-actor crate — actor model infrastructure:

    • ActorRef<M> — thread-safe handle, strong Arc reference, send() is lock-free and RT-safe
    • ActorCell trait — for types that own a mailbox and process messages
    • MessageDispatcher<M> — dispatcher with dead letters support
    • ActorSystem<M> — named mailbox registry, route(), broadcast(), dead letters
  • rill-adrift: serialization added to default features — serde + toml available out of the box

  • rill-adrift: config.toml — new example config file with backend_name, backend_params, sample_rate, block_size

  • rill-adrift: RuntimeConfig now derives serde::Deserialize (behind serialization feature)

  • Missing graph nodes registered:

    • rill/moog_ladder — digital Moog ladder filter (rill-digital-filters)
    • rill/lofi — lo-fi processor (rill-lofi, gated behind lofi)
    • rill/analog_moog_ladder — WDF Moog ladder filter (rill-analog-filters, gated behind analog)
    • rill/cassette_deck — cassette deck emulation (rill-analog-effects, gated behind analog)
    • rill/parametric_eq — parametric equalizer (rill-router)
    • rill/graphic_eq — graphic equalizer (rill-router)
    • All router nodes (dry_wet_mix, mixer, EQ) consolidated into register_router()

🧹 Removed

  • rill-core-dsp: removed unstable feature (no code behind it, required nightly)
  • rill-patchbay: PatchbayEngine removed (folded into Engine)
  • rill-core: traits::actor module removed (moved to rill-core-actor)

🔧 Fixes

  • rill-io/pipewire: fixed AudioBackend::write stub returning 0 instead of buffer.len()
  • rill-graph: removed redundant B as usize cast, pre-existing clippy warnings fixed
  • rill-patchbay, rill-adrift: fixed redundant closures, unused imports, unused variables
  • rill-adrift: --no-default-features compilation fixed:
    • register_all_nodes no longer gated behind io (oscillators, filters, effects available without I/O)
    • register_backends call in Runtime::new() gated behind io
    • cfg_from_params() gated behind io
    • Patchbay import decoupled from osc feature
    • ActorRef import gated behind any(osc, serialization)
    • Dead register_io stub removed
  • rill-adrift examples:
    • play_json renamed to player — now reads config.toml instead of hardcoded paths
    • All examples have explicit required-features (clear error with --no-default-features)
    • play_wav: unused registration import removed

📝 Documentation

  • Architecture article: actor model (docs/src/architecture/actor.md) with RT boundary section
  • AGENTS.md: quoting rules for commit messages with backticks
  • All docs updated to reflect Engine, *Def, ActorRef naming

[0.5.0-beta.1] — 2026-05-04

🎉 First beta release

All 17 crates published on crates.io at 0.5.0-beta.1.

✨ New

  • WAV playback example (rill-adrift/examples/play_wav.rs) — full pipeline from file to speaker: load WAV → SamplePlayer → BiquadFilter → AudioOutput
  • CLI backend selectioncargo run --example play_wav -- [backend] [file]
  • 24-bit WAV supportrill-sampler now handles 24-bit PCM in addition to 16-bit

🔧 Improvements

  • All 4 audio backends produce clean audio: CPAL, ALSA, PipeWire, JACK
  • OutputWindow patternwrite_output() writes directly into DMA buffer, eliminating intermediate ring buffers and associated sizing issues (CPAL, PW, JACK)
  • Lock-free IoRingBuffer — rewritten with UnsafeCell interior mutability, all methods take &self, no Mutex/RwLock in the RT path
  • No thread::sleep in any backend — all backends are event-driven or callback-driven
  • WDF macros accept bare expressions$pr:expr replaces $pr:tt, no more unnecessary braces

🧹 Dependencies removed

  • parking_lot — removed from rill-io dependencies (all uses replaced with std::sync::Mutex/AtomicU32 or lock-free patterns)
  • crossbeam-channel — removed from rill-io dependencies (start/stop via AtomicBool + thread::park/unpark, MIDI events via std::sync::mpsc)

🏗️ Infrastructure

  • CI — GitHub Actions with 4 jobs: lint, test, test-minimal, doc
  • Pre-commit hook — rejects direct commits to develop/main/master
  • clippy.toml — workspace-level lint configuration (later removed, needless_range_loop allowed at workspace level)
  • 491 tests — all passing, 0 clippy warnings (excluding intentional needless_range_loop in SIMD code)

📚 Documentation

  • Root README: 1270 → 154 lines, English only, no duplication
  • 6 new mdBook chapters: core, graph, real-time-safety, world-of-automatons, git-flow, overhauled getting-started
  • Doc comments on all public API items — 0 missing-docs warnings
  • Doc link warnings: 48 → 0
  • All 17 crate READMEs present and up to date
  • rill-sampler/README.md written from scratch
  • rill-patchbay/README.md rewritten with green thread architecture
  • rill-adrift/README.md expanded with feature flags table
  • CHANGELOG.md, MANIFESTO.md moved to repository root

🧪 Quality

  • cargo clippy --workspace: 0 warnings (down from 755)
  • cargo doc --workspace --no-deps: 0 warnings (down from 48)
  • cargo test --workspace: 491 passed, 0 failed

[0.4.0] — 2026-05-02

💥 Breaking changes

  • AudioSignal rename across the entire API surface:

    • AudioNodeSignalNode
    • AudioBufferSignalBuffer
    • AudioError / AudioResultSignalError / SignalResult
    • AudioGraphSignalGraph
    • AudioEngineSignalEngine

    All crates bumped to 0.4.0. Only rill-io::AudioBackend keeps its name (genuinely audio-specific trait).


[0.4.1] — 2026-05-04

✨ Audio I/O backends — AudioIo trait

Реализован AudioIo для всех бэкендов:

БэкендСтатусМеханизм вызова callback
NullBackendЗаглушка, callback не дёргается
PipewireBackendRT callback (PW thread)
JackBackendRT callback (JACK thread)
AlsaBackendsnd_pcm_wait() — event-driven, без thread::sleep
CpalBackendThread + thread::sleep(interval) — poll-driven
  • AudioInput::init_backend(name, config) — узел сам создаёт бэкенд по имени (null, alsa, cpal, pipewire, jack), каждый под feature gate
  • AudioOutput::set_active(source_idx) + start() — pull model (active Sink). Sink хранит ссылку на Source и дёргает generate() + propagate() при каждом цикле обработки. Callback идентичен push-модели.
  • AudioOutput::consume() — читает из собственных входных портов (self.inputs), а не из параметра signal_inputs (пуст при вызове через process_blockpropagate)
  • ParamValue::as_str() — доступ к строковому значению String/Choice

🧹 Удалён глобальный реестр бэкендов

Из rill-adrift удалён BACKEND_PTR, set_audio_backend(), clear_audio_backend(), get_audio_backend(). I/O узлы регистрируются в фабрике без бэкенда — бэкенд создаётся внутри узла через init_backend() при десериализации графа (параметр "backend").

⚡ ALSA: poll → event-driven

  • Убран thread::sleep(1000μs) из run_alsa_thread(). Вместо этого используется pcm_playback.wait(None) (snd_pcm_wait()). Тред спит в ядре, просыпается только когда DMA готов. Никакого busy-wait.

🧪 Тесты

  • test_pull_model_sync_inject_and_verify — интеграционный тест pull model: граф SineOsc → AudioOutput через GraphDocument, SyncBackend с ручным триггером, верификация данных в output ring.
  • test_alsa_pull_model — ALSA loopback через snd-aloop, проверка xruns после работы pull model.

📝 Документация

  • AGENTS.md: раздел Hard-RT safety переписан. Две модели бэкендов (callback-driven / poll-driven), thread::sleep() запрещён в RT path. Добавлен Known issues (ALSA/CPAL poll loop). Threading model исправлен — ALSA больше не указан как RT thread.
  • README.md: таблица версий обновлена (все 0.4.0).
  • docs/architecture.md: версии крейтов обновлены (0.3.0 → 0.4.0).
  • docs/src/index.md, docs/src/guides/getting-started.md: версии зависимостей обновлены ("0.3""0.4").

[0.3.2 / 0.3.1 / 0.3.1] — 2026-05-02

🆕 Новые крейты

КрейтВерсияОписание
rill-sampler0.3.1Сэмплер + time-series reader (Source-узлы графа)

✨ rill-core (0.3.2)

  • Interpolate trait — дробно-индексное чтение &[T] с тремя стратегиями: interpolate_linear, interpolate_cubic (Hermite), interpolate_nearest. Blanket impl на [T] где T: Transcendental + Copy — работает для Vec<T>, Box<[T]>, [T; N] через Deref.

✨ rill-core-dsp (0.3.1)

  • InterpolatedReader<T> — heap-буфер с дробной позицией, rate-ом и wrap-интерполяцией (clamp для семплов, periodic wrap для вейвтейблов). Основа для SamplePlayer и WavetableOscillator.
  • WavetableOscillator<T, N> — переписан на InterpolatedReader. Добавлены set_cubic() / is_cubic(). Методы Generator<T>: frequency → rate, phase → normalized position, amplitude → gain.
  • SamplePlayer<T> — воспроизведение буфера с loop-режимами. LoopMode (OneShot / Forward / PingPong), gate-управление, per-sample boundary check. Методы Generator<T>: частота отображается в rate, фаза — в normalized позицию.
  • LoopMode — публичный enum для выбора стратегии зацикливания.

✨ rill-oscillators

  • WavetableOscNode<T, BUF_SIZE, WT_SIZE> — Source-узел графа, обёртка над WavetableOscillator. Параметры: "frequency", "amplitude", "phase", "interpolation" (choice: linear / cubic).

✨ rill-sampler (0.3.1)

  • SamplePlayerNode<T, BUF_SIZE> — Source-узел для воспроизведения аудиосэмплов. Стерео (два output port — left/right). Параметры, automatable через patchbay: "gate", "rate", "loop_mode", "start", "end", "amplitude", "interpolation", "position" (read-only).
  • SampleBuffer<T> — контейнер для загруженных сэмплов с метаданными (sample_rate, channels, name). Mono / stereo deinterleaved.
  • WAV loading (feature "wav") — 16-bit PCM, mono/stereo, через hound.
  • TimeSeriesReader<T> — читатель неравномерных временных рядов. Бинарный поиск по timestamps → отображение времени на дробный индекс → Interpolate trait. Три стратегии: Nearest, Linear, Cubic.
  • TimeSeriesNode<T, BUF_SIZE> — мультиканальный Source-узел (N output ports, по одному на канал). Параметры: "sample_rate" (виртуальная частота), "interpolation", "play", "speed", "position". Заполняет блоки planar: [ch0_s0, ch0_s1, ..., chN_sBUF-1].
  • from_csv() — загрузка t,channel,valueTimeSeriesReader<f64>. Группировка по каналам, сортировка по времени, пропуск непарсируемых строк.

🏗️ Инфраструктура

  • rill-sampler добавлен в workspace и rill-adrift (feature "sampler", включён в default). Обновлён scripts/publish.sh.

📦 Публикации на crates.io

КрейтВерсия
rill-core0.3.2
rill-core-dsp0.3.1
rill-sampler0.3.1

📊 Статистика

МетрикаЗначение
Крейтов в workspace17 активных
Добавлено тестов+46

[0.3.0] — 2026-04-27

🏗️ Фундаментальные изменения

Фреймворк переписан почти с нуля. Единый rill-core вместо россыпи мелких крейтов, новая система очередей и сигналов, модульная архитектура DSP.

Ядро

  • rill-core — единый крейт ядра: трейты (AudioNode, ParameterId, PortId, Clock), математика (AudioNum, вектора), буферы (кольцевые, FIFO), очереди (CommandQueue<T>, TelemetryQueue), время (ClockTick, SystemClock), макросы
  • Типобезопасные идентификаторы: ParameterId (с валидацией), PortId (с типом порта: AudioIn, AudioOut, Control, CV)
  • Очереди как единый механизм коммуникации: неблокирующие MPMC очереди с политиками переполнения, телеметрия, наблюдатель микро-контроля
  • Векторный eDSL — обобщённые математические абстракции над AudioNum через трейт Vector, подготовка к SIMD

DSP

  • rill-core-dsp — единое хранилище DSP-алгоритмов: трейт Algorithm, фильтры (Biquad, SVF, Butterworth, Chebyshev, Comb, OnePole, MoogLadder), генераторы (Sine, Saw, Square, Triangle, Pulse, Noise, LFO, Envelope, FM), маппинг, сглаживание
  • Все алгоритмы работают через process_block с ScalarVector
  • Векторные макросы (simple_algorithm!, filter_algorithm!, effect_algorithm!, generator_algorithm!)

Аналоговое моделирование

  • rill-core-model — WDF-ядро: элементы (R, C, L, диод), адаптеры (последовательный, параллельный), анализ, MoogLadder
  • rill-analog-filters — аналоговые фильтры на WDF (WdfMoogLadder, WdfRcPole)
  • rill-analog-effects — аналоговые эффекты (операционный усилитель, кассетный декастер)

Граф и управление

  • rill-graph — аудиограф с топологической сортировкой, Source/Processor/Sink
  • rill-patchbay — мир автоматов: LFO, огибающие, случайные блуждания, сенсоры, серво, маппинг
  • rill-router — EQ (графический, параметрический) + микшер (каналы, посылы, мастер)

Обработка

  • rill-digital-filters — цифровые фильтры как Processor-узлы
  • rill-digital-effects — Delay, Distortion, Limiter
  • rill-oscillators — Sine, Noise, LFO, Envelope как Processor-узлы
  • rill-lofi — lo-fi процессор (bitcrush, downsampling, noise, wow&flutter)

Ввод/вывод

  • rill-io — аудио-бекенды: NullBackend, CpalBackend, ALSA, PipeWire, JACK
  • rill-telemetry — пробники и коллекторы телеметрии
  • rill-server — OSC-сервер для удалённого управления (UDP, encode/decode, диспетчеризация по паттернам)

🆕 Новые крейты

КрейтОписание
rill-coreЕдиное ядро (трейты, очереди, математика, макросы)
rill-core-dspDSP-алгоритмы (фильтры, генераторы, векторные операции)
rill-core-modelWDF-ядро (элементы, адаптеры, анализ)
rill-patchbayАвтоматы, сенсоры, серво
rill-routerEQ + микшер
rill-telemetryПробники и коллекторы
rill-analog-filtersАналоговые фильтры на WDF
rill-analog-effectsАналоговые эффекты
rill-serverOSC-сервер

🗑️ Удалённые крейты

КрейтЗамена
rill-core-traitsrill-core
rill-signalrill-core::queues
rill-buffersrill-core::buffer + rill-core-dsp::buffer
rill-automationrill-patchbay
rill-controlrill-patchbay
rill-eqrill-router::eq
rill-mixerrill-router::mixer
rill-hprill-core-dsp (f64)

📊 Статистика

МетрикаЗначение
Крейтов в workspace15 активных
Тестов300+
Версия0.3.0 (единая для всех крейтов)

[0.2.0] — 2026-02-23

Крупнейший рефакторинг: Единое ядро rill-core

  • Создан rill-core (объединение rill-core-traits + rill-signal)
  • Все крейты обновлены до версии 0.2.0
  • ParameterId (экспериментальный), PortId выделен в отдельный модуль
  • Удалены старые крейты: rill-core-traits, rill-signal