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:
| Layer | Crates |
|---|---|
| Core | rill-core — traits, math, buffers, queues, time, macros |
| Actor | rill-core-actor — lock-free actor model (ActorRef, ActorSystem) |
| DSP | rill-core-dsp — algorithms, filters, generators, delay, vector ops |
| Graph | rill-graph — static DAG signal graph, GraphBuilder; build_ir() produces GraphIr compiled by rill-lang into a CompiledGraphEngine |
| Effects | rill-digital-filters, rill-digital-effects, rill-router |
| FFT | rill-fft — radix-2 FFT, frequency-domain convolution, spectral effects |
| Automation | rill-patchbay — LFO, envelopes, sensors, servos, mappings |
| Language | rill-lang — Faust-style functional signal DSL, compiles to Algorithm<T> or MultichannelAlgorithm<T>, or to CompiledGraphEngine for whole-graph compilation |
| Analog | rill-core-model, rill-analog-filters, rill-analog-effects — WDF circuit modeling |
| I/O | rill-io — ALSA, PortAudio, PipeWire, JACK backends (pure I/O, no engine) |
| Network | rill-osc — OSC server and networking; powers rill-patchbay OSC sensors for graph control |
| Monitoring | rill-telemetry — probes, collectors |
| Sampler | rill-sampler — sample playback, time-series reader, WAV loading |
| Lo-Fi | rill-lofi — vintage DAC, tape effects, chip emulation (NES, AY-3-8910, Akai S900) |
| Umbrella | rill-adrift — re-exports all workspace crates |
| Devtools | rill-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
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-effect) - Run tests (
cargo test --workspace) - Submit a pull request
Git Flow
The project uses Git Flow:
main— stable releasesdevelop— integration branchfeature/*— new featuresrelease/*— release preparationhotfix/*— 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_nodeAPI, no separate source/processor/sink distinction at builder level) - Edge kinds —
Signal(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
Patchbaywith 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
| Direction | Active side | Node type |
|---|---|---|
| Output | Playback | Engine writes output buffers from MultichannelAlgorithm::process() |
| Input | Capture | Engine reads input buffers into CompiledGraphEngine::process() |
Execution model
The signal graph has no external engine loop. CompiledGraphEngine::process_tick()
drives execution:
- Drain the actor mailbox — apply queued
SetParametercommands - Execute nodes in topological order via
NodeClosure::execute() - Each node reads from its input buffers in the pool, runs its algorithm, writes to its output buffers
CompiledGraphEngineimplements bothAlgorithm<T>(SISO) andMultichannelAlgorithm<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
- Domain-agnostic core —
Scalar,Vector, lock-free queues work in any signal domain (embedded, IoT, robotics) - Minimal dependencies — each crate depends only on what it uses
- Zero-cost abstractions — static dispatch, const generics, SIMD-ready vectors
- Real-time safety — no allocation, no locks, no syscalls on the signal path
- 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)containspos.
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:
| Type | Purpose |
|---|---|
Registry<T> | HashMap-backed collection of built-in definitions |
BuiltinSig | Type-checker-facing signature: name, params (list of ParamType), signal_outs, BuiltinKind |
ParamType | Signal, Float, Int, String, Bool, Record(RecordSchema), Enum(...), Variadic(Box<ParamType>) |
RecordSchema | Named fields with type and optional default |
BlockBuiltin<T> | Whole-buffer built-in extending Algorithm<T> |
SampleBuiltin<T> | Per-sample built-in (feedback-legal) |
SignatureSource | T-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 CompiledGraphEngine — rill-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:
| Kind | Purpose |
|---|---|
connect_signal | Forward signal flow — included in topological sort |
connect_control | Modulation values (e.g. LFO → filter cutoff) |
connect_clock | Timing signals (MIDI clock, transport) |
connect_feedback | Feedback 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):
- Node lookup — each recipe's
type_nameis resolved in theRegistry - Topological sort — Kahn's algorithm on signal edges; cycles are rejected
- Built-in compilation — each node's built-in is compiled to an
Ir(single-node program) - Optimization — dead-edge elimination, constant inlining, parallel node merging
(
rill-lang/src/graph_optimize.rs) - Compiler —
graph_compiler::compile()flattensGraphIrinto aCompiledGraphwith a fixed-sizeFixedBufferpool and orderedNodeClosurevector
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(®, 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
| Component | Location | Purpose |
|---|---|---|
GraphBuilder<T, BUF_SIZE> | rill-graph | Mutable builder: adds nodes, connections, resources; build_ir() produces GraphIr |
GraphResource | rill-graph | Named shared resource (tape loop, buffer) |
BuildError | rill-graph | Error type for graph construction |
GraphDef | rill-graph::serialization | Serializable graph topology (format_version, nodes, connections) |
NodeDef | rill-graph::serialization | Enum: Source(SourceDef), Processor(ProcessorDef), Router(RouterDef), Sink(SinkDef) |
ConnectionDef | rill-graph::serialization | Serializable connection: from_node/port → to_node/port + SignalKind |
GraphIr | rill-lang::graph_ir | Multi-node IR — bridges GraphBuilder to rill-lang compilation |
GraphNode | rill-lang::graph_ir | One graph node: arity, IR, params, bridge/feedback annotations |
GraphEdge | rill-lang::graph_ir | Directed edge: node names + ports + EdgeKind |
EdgeKind | rill-lang::graph_ir | Signal, Control, Clock, or Feedback |
CompiledGraphEngine<T, BUF_SIZE> | rill-lang::graph_engine | Execution engine: flat NodeClosure vector + FixedBuffer pool; implements Algorithm<T> and MultichannelAlgorithm<T> |
Integration
rill-core—BuiltinSig,Registry,Algorithm,MultichannelAlgorithm,ParamValuerill-core-actor—ActorRef<CommandEnum>/Mailbox(parameter control)rill-lang—GraphIr,GraphNode,GraphEdge,CompiledGraphEngine,graph_compiler::compile(),graph_optimize::optimize()rill-patchbay— automation viaCommandEnum::SetParameterthroughengine.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, noSendrequirement. - 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:
| Method | Handler location | Drain | Returns | Use case |
|---|---|---|---|---|
spawn(name, handler) | Caller's thread | Caller (actor.drain()) | Actor<M> | Graph, inline drain |
spawn_detached(name, make_handler, ms) | Inside new OS thread | Auto (std::thread::spawn + sleep) | ActorRef<M> | Rack, Servo (handler !Send) |
spawn_detached_tokio(name, make_handler, ms) | Inside new tokio task | Auto (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() │
└──────────────────────────┘ └──────────────────────────┘
| Direction | Message | Mailbox owner | Sender |
|---|---|---|---|
| Control → Signal | SetParameter | Graph actor | Servo actors via graph.handle() |
| Signal → Control | ClockTick | Rack actor | Graph via parent_ref.send() |
| Method | RT-safe? | Notes |
|---|---|---|
ActorRef::send() | ✅ Hard RT | Lock-free, bounded queue |
Actor::drain() | ⚠️ Depends on caller's thread | In I/O callback = hard RT, in control = soft RT |
ActorSystem::route() | ❌ Soft RT only | Heap iteration |
ActorSystem::broadcast() | ❌ Soft RT only | Heap 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):
| Module | Role | Configured via |
|---|---|---|
| Automatons | Modulation generators (LFO, envelope) | automatons + servos |
| MidiInput | External MIDI event source | SensorDef::Midi |
| OscSensor | External OSC event source (UDP) | SensorDef::Osc |
| Sequencer | Step sequencer driven by signal clock | attach_sequencer() |
| OscSurface | OSC → EventPattern bridge | osc_surface |
All modules produce ControlEvents that flow through mappings →
SetParameter 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:
| Instance | Purpose | Fields populated |
|---|---|---|
control | Owns automaton handles (port_combiners, automaton_handles) | All |
control_shared (Arc<Mutex<>>) | Receives events from OSC/MIDI | mappings 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, ®istry)?; // ↑ 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
| Crate | File | Change |
|---|---|---|
rill-patchbay | serialization/mod.rs | Add MidiInputDef, midi field in PatchbayDef |
rill-patchbay | engine.rs | Add set_midi_actor(), as_shared(), extend stop_all() |
rill-adrift | runtime/mod.rs | LaunchConfig, Runtime::launch(), rewrite stop() |
rill-adrift | runtime/config.rs | LaunchConfig 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()→ControlEvent→ActorRef<CommandEnum>→ Servo →SetParameter→ Graph. - Output:
ClockTick(Rack broadcast) →MidiClockGenerator→ControlEvent→serialize_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, noArc<Mutex> - Input: sensors produce
ControlEvent, dispatched through actor mailbox - Output:
ClockTickarrives 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
| Backend | Feature | Platform | MidiInput | MidiOutput |
|---|---|---|---|---|
MidirBackend | midir (default) | All | new(), new_by_port(), new_by_name() | new_output(), new_output_by_name() |
AlsaSeqBackend | alsa | Linux | new() — Direction::Capture port | new_output() — Direction::Playback port |
JackMidiBackend | jack | All | new() + connect() — MidiIn port | new_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 byte | ControlEvent variant |
|---|---|
0x80 Note Off | MidiNote { on: false, velocity: 0 } |
0x90 Note On (vel > 0) | MidiNote { on: true, velocity } |
0x90 Note On (vel = 0) | MidiNote { on: false } |
0xA0 Poly Aftertouch | MidiNote { on: true, velocity } |
0xB0 Control Change | MidiControl { controller, value, normalized: value / 127 } |
0xE0 Pitch Bend | MidiControl { controller: 128, normalized } |
0xF8 Clock | MidiClock |
0xFA / 0xFB / 0xFC | MidiTransport { 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:
ControlEvent | Status byte | Data1 | Data2 |
|---|---|---|---|
MidiClock | 0xF8 | 0 | 0 |
MidiTransport { kind: Start } | 0xFA | 0 | 0 |
MidiTransport { kind: Stop } | 0xFC | 0 | 0 |
MidiTransport { kind: Continue } | 0xFB | 0 | 0 |
MidiNote { note, on: true } | 0x90 | note | velocity |
MidiNote { note, on: false } | 0x80 | note | 0 |
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:
- If BPM changed, recalculate
samples_per_tickfromclock.tempo - While
next_tick_at < clock.sample_pos + block_size: emitControlEvent::MidiClock, advancenext_tick_atbysamples_per_tick - Return accumulated events (0, 1, or several per block)
Transport state machine:
Start→ setsplaying = true, resetsnext_tick_atto current sample positionStop→ setsplaying = false, no ticks producedContinue→ setsplaying = true, continues from current phaseStartwhile 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 transportResetOnStart— resets clock position on StartSongPosition— 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::AnyMidimatches all four MIDI event typesEventPattern::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
| Feature | Crate | Enables |
|---|---|---|
midir (default) | rill-io | MidirBackend — cross‑platform MIDI input + output |
alsa | rill-io | AlsaSeqBackend — ALSA sequencer input + output |
jack | rill-io | JackMidiBackend — JACK MIDI input + output |
midi | rill-patchbay | MidiHub, MidiClockTracker, MidiClockGenerator, spawn_midi_sensor(), spawn_midi_clock_output(), serialize_to_midi() — pulls rill-io dependency |
midi | rill-adrift | MidiConstructor, 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:
| Algorithm | What it detects |
|---|---|
PitchDetector | Pitch via autocorrelation |
EnvelopeFollower | Amplitude envelope with attack/release |
ZeroCrossing | Frequency 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(®, 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/Inputfromrill-io(feature-gated behindio). The orchestration layer creates the backend and drives theCompiledGraphEngineviaprocess().
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", ®istry, 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", ®istry, 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)", ®, 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
Patchbaywith automatons (LFO, envelopes, sequencers). Communicates via the actor mailbox (ActorRef<CommandEnum>) returned byengine.handle().
Next steps
- Architecture Overview — core concepts
- Signal graph (rill-graph) — graph processing details
- The World of Automatons — automation system
- Real-Time Safety — RT constraints and rules
- Crates reference — full crate list with features
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:
actor.drain()— applies queuedCommandEnum::SetParametercommands from the actor mailbox- Builds a
RenderContextwith sample clock, transport state, and hardware clock correction Source::generate()/Processor::process()/Sink::consume()viaprocess_block(&ctx)Port::propagate()— recursive DAG traversal through direct port pointers- Sends
CommandEnum::ClockTickto 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:
- Receives
CommandEnum::ClockTickfrom the graph - Advances time and calls
automaton.step() - Applies
ControlStrategyandConflictStrategy - Sends
CommandEnum::SetParameterto the graph'sActorRef<CommandEnum> - The
SetParameterlands 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.
| Module | Key types | Purpose |
|---|---|---|
complex_fft | ComplexFft<T> | Radix‑2 DIT complex FFT (forward + inverse) |
real_fft | RealFft<T> | Real‑valued FFT via two‑for‑one packing |
overlap_add | OverlapAddConvolver<T, BUF> | Frequency‑domain convolution (medium IRs) |
partitioned_conv | PartitionedConvolver<T, BUF> | Partitioned convolution (long IRs) |
spectrum | FftSpectrumAnalyzer<T> | FFT‑based spectrum analyser |
effects | SpectralGate, SpectralDelay | Frequency‑domain effects |
nodes | ConvolverNode | Graph‑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 length | Method | Per‑block cost (128‑sample block) |
|---|---|---|
| ≤ 128 | DirectConvolver | ~10 µs |
| 256…16384 | OverlapAddConvolver | ~60 µs (IR 2048) |
| > 16384 | PartitionedConvolver | ~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)
| Operation | Size | Time | Throughput |
|---|---|---|---|
ComplexFft::forward | 1024 | 6.7 µs | 153 Melem/s |
RealFft::forward | 1024 | 6.2 µs | 165 Melem/s |
ComplexFft::forward | 16384 | 177 µs | 92 Melem/s |
OverlapAddConvolver | IR 2048, BUF 128 | 61 µs/block | ~2100 blocks/s |
PartitionedConvolver | IR 65536, BUF 128 | 104 µs/block | ~9600 blocks/s |
DirectConvolver | 128 taps, BUF 128 | 10 µs/block | 12.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:
-
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(). -
Safe Rust —
#![deny(unsafe_code)]guarantees no UB in the FFT path. Thewidecrate provides safe SIMD wrappers when thesimdfeature is enabled. -
Minimal dependencies — only
num-complex(already inrill-core-dspfor filter design) andnum-traits(workspace dep). -
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:
| Builtin | I/O channels | Description |
|---|---|---|
complex(re, im) | 0 → 2 | Generator |
conj(x) | 2 → 2 | Conjugate |
re(x), im(x) | 2 → 1 | Real / imaginary part |
norm(x) | 2 → 1 | Magnitude |
arg(x) | 2 → 1 | Phase (atan2) |
cmul(a, b) | 4 → 2 | Complex multiply |
cadd(a, b) | 4 → 2 | Complex 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:
Chip → Backend → LofiInput. 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# | Name | Bits | Description |
|---|---|---|---|
| R0–R1 | Tone A period | 12 | f = 1.75 MHz / (16 × TP) |
| R2–R3 | Tone B period | 12 | |
| R4–R5 | Tone C period | 12 | |
| R6 | Noise period | 5 | f = 1.75 MHz / (16 × NP) |
| R7 | Mixer | 8 | Bits 0–2: tone A/B/C, 3–5: noise A/B/C (0=ON) |
| R8–R10 | Volume A/B/C | 5 | Bit 4: envelope mode, bits 0–3: 0–15 |
| R11–R12 | Envelope period | 16 | f = 1.75 MHz / (256 × EP) |
| R13 | Envelope shape | 4 | Continue, Attack, Alternate, Hold |
| R14–R15 | I/O port A/B | 8 | Not 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:
| Parameter | Type | Default | Description |
|---|---|---|---|
enable_bitcrush | Bool | true | Quantization to bit_depth bits |
enable_noise | Bool | true | Vintage noise floor (dB → linear) |
enable_sr_reduction | Bool | true | Sample-rate decimation |
dry_wet | Float | 1.0 | Wet/dry mix (0.0 = dry, 1.0 = fully processed) |
output_gain | Float | 1.0 | Output gain (0.0–4.0) |
For ClassicSystem::Custom, three parameters are set at construction via LofiConfig:
| Parameter | Example | Description |
|---|---|---|
bit_depth | 8 | Quantization bit depth |
nonlinear | false | Non-linear encoding (dead code for Custom) |
noise_floor | -48.0 | Noise 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:
| Aspect | Current behaviour | Real AY-3-8910 |
|---|---|---|
| Output sampling | 1 sample per generate_sample() call | Continuous analog output with infinite bandwidth |
| Anti-aliasing | None | Implicit in analog stage (amplifier bandpass) |
| Noise LFSR | 17-bit, output = bit 0, polynomial x^17+x^14+1 | Same LFSR, but output filtered by analog stage |
| Envelope | 4-bit mode, 16-bit period, linear ramp | Same, but real chip has minor non-linearities |
| Register changes | Applied 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 implemented | Bidirectional 8-bit GPIO |
| YM2149 compatibility | Not implemented | YM 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 viastep_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():
- Tone phase is advanced
- Noise and envelope states are read (from their previous state)
- Channel outputs computed
- Noise phase advanced (
update_noise) - 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
| Chip | Structs | Registers | Features |
|---|---|---|---|
| AY-3-8910 | Ay38910Chip, LofiChipSource | 16 × 8-bit | 3 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
| Model | Backends | RT guarantee |
|---|---|---|
| Hardware callback | PipeWire, JACK, PortAudio | Hard RT — the audio system calls the process callback on its own real-time thread. No syscalls, no allocation, no locks. |
| Own audio thread | ALSA | Soft 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:
| Rule | Rationale |
|---|---|
| No heap allocation in RT path | Vec::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 path | Mutex::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 path | thread::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 path | Any syscall (open, read, write, send, recv) can block unpredictably. |
downstream_nodes is pre-filled | Port::downstream_nodes is populated once by GraphBuilder::build() and iterated at runtime without deduplication or allocation. |
| Fixed-size stack buffers | Backend 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
- 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. - Testing RT code — any new RT path code must be verified with
cargo test --releaseunderpw-loopbackor 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
build_ir()inserts aProbePointIR instruction after the node'sCallBlock- The engine allocates
ProbeSlots — one per node — each with atomic flags (enabled,break_flag,paused_flag) and an SPSC queue - During processing, the engine captures the output buffer's first sample and pushes a
ProbeFrame { value_bits, block_index }into the queue CollectorThreaddrains the queue and formats the event viaTextFormatter(colored terminal) orJsonFormatter(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_kind —
SetParameter,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
- Opens
/dev/shm/rill-debug-12345 - Verifies magic and version
- Registers as debugger (writes its PID)
- Enters REPL — commands go through the shmem ring buffer
Launch Mode
rill-analyzer launch ./my-app -- --flag value
- Creates shmem region
- Forks and executes the target with
RILL_DEBUG_SHMEMin the environment - Child process opens the shmem and sets
FLAG_ATTACHED - 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:
| Mode | Command | Use case |
|---|---|---|
| Local | rill-analyzer run <graph.json> | Run a graph locally with embedded debugger |
| Attach | rill-analyzer attach <pid> | Connect to a running rill process |
| Launch | rill-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:
- Opens the shmem region
- Verifies the magic number (
RILL) and version - Registers as debugger (writes its PID to the control header)
- 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
| Command | Shortcut | Description |
|---|---|---|
break <probe> | b | Set a breakpoint at the given probe |
clear [<probe>] | — | Clear breakpoint(s) |
continue | c | Resume execution |
step [<n>] | s | Execute N blocks, then pause |
pause | — | Pause the engine |
quit | q | Exit the debugger |
Inspection
| Command | Shortcut | Description |
|---|---|---|
info nodes | i nodes | List all graph nodes with arity |
info probes | i probes | List all probes with status (ON/OFF/BREAK) and last value |
print <probe> | p | Show the last value of a specific probe |
watch <probe> | w | Enable continuous probe output |
unwatch <probe> | — | Disable continuous probe output |
Command Tracing
| Command | Description |
|---|---|
trace commands | Enable command logging (shows all SetParameter, ClockTick) |
untrace commands | Disable command logging |
Control-Path Inspection
| Command | Description |
|---|---|
info automatons | List all registered automatons (servos) |
info sensors | List all registered sensors (MIDI, OSC) |
info queues | Show 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:
| Function | REPL 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:
| Type | Elements | Purpose |
|---|---|---|
ScalarVector1<T> | 1 | Scalar stub |
ScalarVector2<T> | 2 | Stereo |
ScalarVector4<T> | 4 | SIMD-capable (SSE, NEON) |
ScalarVector8<T> | 8 | AVX-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 toT::ZERO)port_resistance: |s| expr— port resistancescattering: |s, a| expr— scattering equation: compute reflected wavebfrom incident wavea.s— mutable reference to self.update: |s| block— state update (called after wave calculation)reset: |s| block— reset to initial states.voltageands.current— writable (store latest values)
Generates:
struct $name<T>with fields params, state,voltage,currentimpl $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 fieldpoles: [$section; N]+ params + statefn process_sample(&mut self, input: T) -> T— unrolled cascadefn 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| { ... }—sis&self,inputis the input sample,fb_previs the previous output valueupdate: |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
| Construct | Supported | Description |
|---|---|---|
| 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+feedback | ✅ wdf_cascade! | MoogLadder |
| Three-terminal (transistor) | ❌ manual impl | Scattering matrix 3×3 |
| Op-amp, OTA | ❌ manual impl | Mathematical 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
| Syntax | Meaning | Arity (in → out) |
|---|---|---|
_ | identity wire | 1 → 1 |
! | cut (discards its input) | 1 → 0 |
42 | integer literal | 0 → 1 |
1.5 | float literal | 0 → 1 |
3i, 2.5i | imaginary literal | 0 → 2 |
+ - * / % | binary arithmetic block | 2 → 1 |
sin cos tan sqrt exp ln tanh abs | math builtins | 1 → 1 |
min max | selection | 2 → 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ₒ):
| Form | Name | Requirement | Resulting arity |
|---|---|---|---|
A : B | sequential | aₒ = bᵢ | (aᵢ, bₒ) |
A , B | parallel | — | (aᵢ + bᵢ, aₒ + bₒ) |
A <: B | split (fan-out) | bᵢ is a multiple of aₒ | (aᵢ, bₒ) |
A :> B | merge (fan-in, sums) | aₒ is a multiple of bᵢ | (aᵢ, bₒ) |
A ~ B | feedback | bᵢ ≤ aₒ and bₒ ≤ aᵢ | (aᵢ − bₒ, aₒ) |
A @ n | integer delay | A is _ → 1, n a constant int | same 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 types —
int,float(the runtimeT), 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 = _ * xhas one λ-parameter (x) and one signal port (from_). Callingf 0.5consumes 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
| Category | Builtins | Feature |
|---|---|---|
| Filters | onepole, moog (sample), lowpass, highpass, biquad (block) | always |
| Oscillators | sine, saw, square, triangle, noise (block) | always |
| Effects | delay, distortion, limiter (block) | always |
| Mixer/EQ | mixer, eq_parametric, dry_wet, graphic_eq (block) | router |
| Analog | analog_moog, cassettedeck, tape_bridge (block) | analog |
| Spectral | spectralgate, spectraldelay, convolver (block) | fft |
| Complex | complex, conj, re, im, norm, arg, cmul, cadd | always |
| Sampler | sampler (block) | sampler |
| Lofi | lofi, ay38910 (block) | lofi |
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
| Kind | Names | Behaviour | Inside ~ |
|---|---|---|---|
| Sample | onepole, moog | Per-sample state; the built-in's process_sample runs inside the sample-level recurrence loop. | Allowed |
| Block | lowpass, highpass, biquad, delay, distortion, limiter, sine, saw, square, triangle, noise, analog_moog, cassette_deck, tape_bridge, spectralgate, spectraldelay, convolver, lofi, ay38910 | Opaque 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;", ®, 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;", ®, 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.0 ≤ param("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;", ®, 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=default | param("name", default) |
|---|---|---|
| Syntax cost | 3 extra chars | 9+ extra chars |
| Intent | Late-binding for actor system | Inline parameter slot |
| Works with | compile_graph() | compile() / compile_with() |
| Use case | Graph nodes with external control | Standalone 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, ®istry, 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-allocatedFixedBuffers - Drains the actor mailbox for
SetParametercommands 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;", ®, 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 form | Visibility | Mutual recursion |
|---|---|---|
| Top-level defs | All top-level definitions in the program | Yes |
where block | Only within the function it's attached to | Yes, within the block |
let expression | Only within the in body | Yes, 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;", ®, 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::Blockand run whole-buffer through therill_core::math::vectorSIMD eDSL (ScalarVector4). The block path computes directly inT(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 — areStep::Sampleand 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)
| Program | Compile |
|---|---|
_ * 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
| Program | Time | Kind |
|---|---|---|
_ * 0.5 | ~63 ns | feedforward (block) |
_ * 0.5 : abs : (_ * 2.0) | ~111 ns | feedforward (block) |
_ <: (_ , _ * 0.5) :> + | ~88 ns | feedforward (block) |
_ * param("g", 0.5) | ~61 ns | feedforward (block) |
_ @ 4 | ~1.6 µs | recurrent (sample) |
+ ~ (_ * 0.5) | ~3.4 µs | recurrent (sample) |
_ * smooth(param("g", 0.5), 10.0) | ~4.5 µs | recurrent (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:
| Program | Hybrid | Reference | Speedup |
|---|---|---|---|
| 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)
| Program | Time |
|---|---|
_ : 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
jitbackend (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 fromBackendFactoryfor this I/O node. The orchestrator usesBackendFactoryto 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:
- Duplicate NodeId check — every
NodeDef.idmust be unique. - Block size match — the document's
block_sizemust equal the builder'sB. - Type resolution — every
type_namemust be registered in the builder'sNodeFactory.
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_fnkey → used as the factory lookup key on import.NodeMetadata::type_name(withnamefallback) → 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
| Format | When to use |
|---|---|
| JSON | Debugging, manual editing, version-controlled presets |
| CBOR | Network 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(®istry, "rill/sine", &Params::new(44100.0))?; builder.add_node(®istry, "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
| Error | Cause |
|---|---|
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
| Branch | Purpose |
|---|---|
main | Stable releases |
develop | Integration 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.
| Crate | Version | Description | Docs |
|---|---|---|---|
| rill-adrift | 0.6.0-M2 | Umbrella 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-core | 0.6.0-M2 | Core traits, math, buffers, queues, time, macros, interpolation; builtin module (Registry of lang built-ins), MultichannelAlgorithm and BridgeAlgorithm traits | docs.rs |
| rill-core-actor | 0.6.0-M2 | Actor model — ActorRef, Actor, ActorSystem for lock-free message passing | docs.rs |
| rill-core-dsp | 0.6.0-M2 | DSP algorithms, vector ops, filters, generators, sample player | docs.rs |
| rill-core-model | 0.6.0-M2 | WDF core + physical modeling — string, plate, modal, cavity | docs.rs |
| rill-graph | 0.6.0-M2 | Static DAG signal graph with topological sort; optional lang feature enables build_graph_ir() path that bridges to rill-lang::graph_ir::GraphIr | docs.rs |
| rill-digital-filters | 0.6.0-M2 | Biquad, SVF, Comb, MoogLadder filter nodes | docs.rs |
| rill-digital-effects | 0.6.0-M2 | Delay, Distortion, Limiter nodes | docs.rs |
| rill-router | 0.6.0-M2 | EQ (graphic, parametric) + mixer (channels, sends, master) | docs.rs |
| rill-fft | 0.6.0-M2 | Radix-2 FFT, frequency-domain convolution, spectrum analysis, spectral effects | docs.rs |
| rill-patchbay | 0.6.0-M2 | Automation — LFO, envelopes, sensors, servos, mappings | docs.rs |
| rill-lofi | 0.6.0-M2 | Lo-fi emulation — NES, AY-3-8910, Akai S900 | docs.rs |
| rill-io | 0.6.0-M2 | Audio I/O — PortAudio, ALSA, PipeWire, JACK backends | docs.rs |
| rill-telemetry | 0.6.0-M2 | Probes, collectors, real-time monitoring, debug IPC | docs.rs |
| rill-analyzer | 0.6.0-M2 | [CLI] Interactive gdb-style debugger — signal probes, breakpoints, shmem IPC | — |
| rill-analog-filters | 0.6.0-M2 | WDF-based analog filters — WdfMoogLadder | docs.rs |
| rill-analog-effects | 0.6.0-M2 | Analog circuit models — cassette deck, tape bridge/delay | docs.rs |
| rill-osc | 0.6.0-M2 | OSC — UDP server, encode/decode, pattern dispatch | docs.rs |
| rill-sampler | 0.6.0-M2 | Sample playback + time-series reader + WAV loading | docs.rs |
| rill-lang | 0.6.0-M2 | Faust-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 support | docs.rs |
Feature flags
| Crate | Features |
|---|---|
rill-core | serde, simd |
rill-core-dsp | simd, f64, fast_math |
rill-core-model | lang |
rill-fft | simd, f64, graph, lang |
rill-graph | debug, serialization |
rill-lang | router, serde, debug |
rill-patchbay | debug, serde, json, cbor, serialization, midi (MIDI input), osc (OSC input), alsa |
rill-io | portaudio (default), midir (default), alsa, pipewire, jack, all-backends, serde-config |
rill-sampler | wav (default, enables hound), graph, lang |
rill-adrift | io, 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_builtinsfunction inrill-analog-effects/src/lang.rs— the function was called fromregister.rsbut did not exist, causing a compilation error. Added the function withCassetteDeckBuiltinblock registration. - Fixed
rill-adrift/src/modular/mod.rs—enginevariable neededmutforallocate_probe_slots(1)call under thedebugfeature.
📚 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), fixedrill-lofidescription (replaced "console emulation" with "vintage DAC, tape effects, chip emulation"), fixedrill-langtype reference (RillGraphEngine→CompiledGraphEngine). - Updated
docs/src/reference/crates.md: fixedrill-analog-effectsdescription (replaced "op-amp, tape deck, preamps" with "cassette deck, tape bridge/delay"), fixedrill-langtype reference (RillGraphEngine→CompiledGraphEngine), corrected all feature flag rows forrill-core-model,rill-fft,rill-graph,rill-lang,rill-patchbay,rill-io,rill-sampler, andrill-adrift. - Updated
docs/src/architecture/overview.md: "outside audio" → "in any signal domain". - Fixed
rill-analog-effects/src/lib.rsandregister.rscrate doc comments. - Updated
AGENTS.md: crate count (19 → 20),rill-analog-effectsdescription. - Updated
README.md: library crate count (18 → 19),rill-analog-effectsdescription, 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 REPLattach <pid>— connect to a running process via shared memorylaunch <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:
ProbePointIR instruction — lock-free signal sampling (zero overhead when disabled)ProbeSlotwith atomic flags (enabled,break_flag,paused_flag) and SPSC queueDebugControl— inter-block pause/resume atomics for engine controlCommandFrame— fixed-size copy-compatible frame for actor mailbox tracing
rill-graph:
build_ir()now compiles graph nodes to completerill_lang::Irwith builtins, parameters, and instructions — mirroring the rill-lang DSL compilation path- Automatic
ProbePointinsertion at each node's output underdebugfeature
rill-telemetry:
CollectorThread— background thread drains probe queues + command log, formats viaTextFormatter(colored terminal) orJsonFormatter(JSON lines)ProbeStateManager— handles breakpoints, continue/step/pause, probe enable/disableShmemRegion—/dev/shm/rill-debug-<pid>mmap region with two lock-free SPSC ring buffers forAnalyzerCommand/AnalyzerResponseviaserde_cborAnalyzerCommand/AnalyzerResponseprotocol types with automaton/sensor/queue inspection variants
rill-patchbay:
PatchbayInspector— collects automaton/sensor snapshots for control-path debuggingServo::inspector()— automaton state snapshot viaArc<Mutex<>>OscSensor::inspect()/MidiHub::inspect()— sensor status snapshotsModuleFactory::construct()accepts an inspector parameter for auto-registration
rill-adrift:
debug_initmodule —init_shmem()/init_shmem_from_env()for IPC setup- Lifecycle logging in
ModularSystem::launch()— rack creation, engine build, backend connection, shutdown (vialogcrate) - Auto-probes enabled for each graph node;
CollectorThreadspawned with shmem andPatchbayInspector
⚡ Execution model unification
compile_graph()andgraph_lower::lower()now use identical buffer numbering (output_bufs = [0],output_mapping = [0],buffers = 1). Both paths converge on the sameRillProgram::new_with()→ScheduledGraph→RillGraphEnginepipeline.build_ir()produces completeIrwithbuiltins,params, andinstrs— no more stub IRs.GraphDef-based graphs and rill-lang DSL programs share the same execution mechanism.
❌ Removals
rill-oscillatorscrate removed from workspace (obsolete Port-based nodes, replaced by rill-lang builtins).rill/inputandrill/outputbuilt-in identity pass-through nodes removed.ProgramRunnerhandles I/O directly; graphs no longer need explicit Source/Sink nodes for signal routing.
📋 Breaking changes
GraphBuilder::build_ir()now returns completeIr— may change behavior for existing graphs that depended on stub IRs.RillProgram::new_with()madepub— previouslypub(crate).- Graphs using
SinkDefwithtype_name: "rill/output"require updating to remove the sink node (output routing is handled byProgramRunner). SourceDef.backend: Nonegraphs now produce output through graph-leveloutputscomputation (leaf node arity sum), not through explicit sink passthrough.rill-oscillatorsdirect dependencies broken — userill-oscillatorsbuiltins viarill-langregistry orrill-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.mdwith 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, forbiddeneprintln!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 forSpscQueuein RT pathchiptune_stcexample: added--no-waitflag; removed SinkDef; compiles to identical IR aslang_chiptunelang_chiptuneexample: added--no-waitflag
[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-levelprocesswith arity(0|1) → 1. compile::<T>(src)→RillProgram<T>: Algorithm<T>.serdefeature —RillLangDef { source }+compile_def(the source string is the canonical serialized form).rill-adriftlangfeature — re-exportsrill-langand registers arill/langfactory node (reads asourceparameter; recompiles onset_parameter).- Backend is trait-based; a Cranelift JIT backend is planned behind a future
jitfeature 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::vectorSIMD eDSL, while feedback/delay recurrences run per-sample. The block path computes inT. 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-modelviacompile_with(src, ®istry, 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 inrill-adrift(lang_builtins::full_registry,analog_moogbehind theanalogfeature). rill-lang core staysrill-core-only. - Named parameters + smoothing.
param("cutoff", 1000.0)exposes RT-safe control-rate parameter slots (settable viaRillProgram::set_paramand, on therill/langgraph 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 beparam(...)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 tosamples_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_posand 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 withoutsample_posstill 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. Thechiptune_stcexample and the ClockTick-drivenServowrites do this; MIDI/UI-drivenServowrites 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 viabuffer_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.
ProcessingStatere-initialises nodes on rate change — when the drivingClockTick.sample_ratediffers 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
ClockTicknow carries the actual JACK hardware rate (wasconfig_rate), fixing playback runninghw_rate / config_ratetoo 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 intoblock_sizepieces in the callback, sending oneClockTickper 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_Buffersobject 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 oneClockTickper 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 ofbuffer_sizefor callback-driven backends (PipeWire, PortAudio). Set viawith_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 tobuffer_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 realread_input(previously stubbed to silence, so capture never reached the graph) by publishing each just-read period as an input window. The backend now advertisesIoCapturewheninput_channels > 0, so full-duplex graphs work. Still event-driven viasnd_pcm_wait(nothread::sleep).
📦 Version bump and cleanup
- All 18 crates bumped to
0.5.0-beta.7. - Documentation updated:
SensorDef::Oscdescribed in architecture docs,rill-oscREADME cross-referencesOscSensor, patchbay README coversmidi/oscfeature flags, stale0.5.0-beta.2references 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):
IoBackend→IoDriver+IoCapture+IoPlayback— single monolithic trait split into three orthogonal capabilities. One struct can implement any combination:IoDriverruns the clock loop,IoCapturereads input samples,IoPlaybackwrites output samples. Mirrors theMidiInput/MidiOutputsplit on the audio side.BufferViewtrait — zero-copy DMA access during I/O callback:read_input(channel, dst)andwrite_output(channel, src). Nodes holdArc<dyn BufferView>and read/write directly without intermediate ring buffers.ProcessingState— new: owns graph runtime parts (actor mailbox, node storage, parent rack ref). Created viagraph.into_processing_state(). Wired with backends viawire_backends(capture, playback). Drives processing loop:process_block(&ClockTick)→ DSP →send_clock_tick().ParameterWritetrait — polymorphic parameter injection into the graph mid-cycle (used by PipeWire per-chunk params).- Removed:
IoNode,ActiveNodetraits — backends no longer injected into graph nodes.
rill-io:
DirectView— interleaved/planar DMA access via raw pointers, implementsBufferView. 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. WrapsIoPlayback+DirectView, handles multi-chunk DMA.ClockTick.is_final— flag for chunking backends, gatingsend_clock_tick(). Note: current chunking backends (PipeWire, JACK) leave ittrueon every chunk, so control modules receive oneClockTickperblock_sizeblock; sample-accurate placement is handled bySetParameter.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 viaParameterWrite. Zero-fill DMA remainder after chunk loop. (Buffer size is whatever PipeWire allocates — the backend does not yet negotiateSPA_PARAM_Buffers.) - JACK backend — chunk processing by
block_size, uses orchestratorrunningflag for shutdown.run()returns immediately (callback-driven),stop()coordinates with JACK thread. - PortAudio backend — unchanged structurally, gains
DirectViewOutputWindowfor output path.
- ALSA backend — unchanged structurally, poll-driven (
snd_pcm_wait), gains same view/window pattern.
rill-graph (backend_factory.rs):
BackendFactoryrefactored. 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. Replacescreate() -> 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:
MidiBackend→MidiInput(breaking rename) — trait now accurately reflects its input-only role (poll() -> Vec<MidiMessage>).MidiOutputtrait (new) —send(&mut self, &MidiMessage) -> IoResult<()>, symmetric toMidiInput. Together they mirror the audio-sideIoCapture/IoPlaybackseparation — input and output are distinct traits, each backend implements the direction(s) it supports.MidirBackend— struct refactored:_connfield changed fromMidiInputConnection<()>toMidirConnectionenum (Input/Outputvariants). New constructors:new_output(),new_output_by_name()usingmidir::MidiOutput::connect(). Backend can now be opened in either direction — reused across bothMidiInputandMidiOutputtrait impls.AlsaSeqBackend— struct unchanged (seq::Seqis inherently bidirectional). Newnew_output()constructor opens withDirection::Playback+PortCap::WRITE(vsCapture+READfor input). Newmidi_to_alsa_event()helper — reverse of existingalsa_event_to_midi()— convertsMidiMessageto ALSAEventforevent_output()+drain_output().JackMidiBackend— most significant struct change:rxsplit toOption<Receiver<MidiMessage>>, newtx: Option<SyncSender<MidiMessage>>.JackMidiHandler(process callback) becomes bidirectional:MidiInport → channel →MidiInput::poll(), and channel →MidiOutport →MidiOutput::send(). Both directions coexist in one JACK client —connect()opens input,connect_output()opens output. Same pattern for internal comms (input drainstx → rx, output feedstx → rxin reverse).
rill-patchbay:
MidiClockGenerator— output-side counterpart ofMidiClockTracker. Pure math: convertsClockTick→Vec<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 owningMidiClockGenerator+Box<dyn MidiOutput>. ReceivesClockTickvia Rack broadcast andMidiTransportcommands, serializes viaserialize_to_midi(), sends through backend.serialize_to_midi()— reverse ofparse_midi(). ConvertsControlEvent::MidiClock→0xF8,MidiTransport→0xFA/0xFB/0xFC,MidiNote→0x90/0x80. Round-trip tests:parse_midi(serialize_to_midi(e)) == e.ClockDef { backend, port_name, auto_start }— serializable MIDI clock output descriptor. Added toModuleDef::Clock(ClockDef)variant.- Re-exports:
MidiClockGenerator,spawn_midi_clock_output,serialize_to_midi,ClockDef.
rill-adrift:
ModuleDef::Clock(ClockDef)variant in adrift serialization layer, forModularSystemDefJSON documents.ClockConstructor— registered inModuleFactoryas"clock". CreatesMidiOutputbackend, callsspawn_midi_clock_output(), supportsauto_start.to_pb_module()+ rack dispatch —ClockDefconversion 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 configureControlStrategyandConflictStrategyon a Servo.with_control(Modulation { depth })— automaton output modulates aroundstate.base, combinable with HID input viaBasePlusModulation.with_conflict(TouchOverride)— HID input freezes automaton viastate.frozen, resumes onUiRelease.with_conflict(BasePlusModulation)— HID input updatesstate.base; automaton modulates around it on nextClockTick.ServoConstructornow passesServoDef.control_strategyandServoDef.conflict_strategythrough to Servo construction.Controlhandler fallback mapping arm now checksConflictStrategy: was ignoringstate.frozenandstate.base— now respects all three strategies.- Dead code removed:
UiCommandenum (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).TransportState—is_playing,bpm,frame_pos,time_sig_num/den,bar_start_frame. ReplacesClockTick::tempo: Option<f32>.- Musical methods moved from
ClockTicktoRenderContext:beat_position(),musical_position(),is_new_bar(),is_new_beat()— now use configurabletime_sig_num/den(no longer hardcoded 4/4). ProcessContextandActionContextremoved — replaced by&RenderContextthroughout the trait system.
Trait signatures (breaking):
Algorithm::process(input, output)—ctxparameter removed (97.4% of impls ignored it; 2 tape heads now useinit()for sample rate).Source::generate(&RenderContext, …),Processor::process(&RenderContext, …),Sink::consume(&RenderContext, …),Router::route(&RenderContext, …)— all use&RenderContextinstead of&ClockTick.Port::propagate()— context parameter removed; single&RenderContextflows through the DAG without re-wrapping.Port::run_action()— context parameter removed.Port::pre_process()—_tickparameter removed.
Graph:
Graph::run()I/O callback creates oneRenderContextper block and passes it to bothprocess_block()andpropagate()— no moreProcessContext+ActionContextduplication.Graph.system_clock: Option<Arc<SystemClock>>— when set, createsRenderContext::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 intoArc<SystemClock>.MidiClockStrategytrait 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 viaMidiHub::with_clock_tracker(). The tracker'sSystemClockfeeds BPM toGraph.system_clock.
🌐 OSC Sensor (rill-patchbay)
OscSensor(osc.rs) — OSC input sensor modelled afterMidiHub/spawn_midi_sensor. Binds a UDP socket in a dedicated OS thread, decodes incoming OSC packets viarill-osc, producesControlEvent::Osc { address, args }events. Bundles unwound recursively. ImplementsModule+Sensortraits.spawn_osc_sensor()— actor-model variant: spawns a control actor forSetEnabledcommands + UDP recv loop in OS thread. SendsCommandEnum::Control(event)to the servo for mapping.parse_osc()— convertsOscMessage→ControlEvent::Osc. Numeric args (Int,Float) collected; strings and blobs silently dropped.SensorDef::Osc { port, mappings }— serializable descriptor variant inmodule_def.rs.into_sensor()gated onany(feature = "midi", feature = "osc").OscConstructor— registered inModuleFactoryviarill-adrift: creates mapping-only servo +spawn_osc_sensor()pair. Activated byModuleDef::Sensor(SensorDef::Osc { ... }).- Feature gate:
osc = ["dep:rill-osc"]inrill-patchbay;rill-adrift/oscenablesrill-patchbay/oscpassthrough. - Existing
EventPattern::OscAddress/OscPatternmatching in servo works out-of-the-box — sensor producesControlEvent::Osc, servo matches viaEventPattern::matches().
🔌 JACK MIDI + Transport
rill-io:
JackMidiBackend— JACK MIDI input backend. Registers aMidiInport, bridges JACK process callback toMidiBackend::poll()via mpsc channel (same pattern asMidirBackend).JackBackend::set_system_clock()— JACK transport sync: reads BPM fromTransportBBTin process callback, writes atomically toSystemClock.
🔈 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"inLofiProcessormetadata → available throughSourceDef.parameters. - 3 new tests: offset removal, ceiling clamp, combined behaviour.
Registration (rill-adrift/src/registration.rs):
rill/lofi_inputconstructor now readsdc_offset,output_gain,output_ceilingfromParams.
🧱 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. ImplementsAlgorithm<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) andmarimba_modes()(3 modes, harmonic bar ratios).cavity—HelmholtzCavity(single Helmholtz resonator with optional reed excitation for wind instrument modeling) andCavityArray(1D chain of coupled cavities for wave propagation experiments / acoustic metamaterials).
All four types implement Algorithm<T> + ParameterizedAlgorithm<T>.
24 new tests.
♻️ ParameterizedAlgorithm → rill-core
rill-core (traits/algorithm.rs):
ParameterizedAlgorithm<T>trait added — typed parameter access for anyAlgorithm(params(),set_params(),set_parameter()). Generic overtype Params: Clone + Send + Sync. Previously lived inrill-core-dsp.
rill-core-dsp:
rll-core-dsp/src/algorithm.rs— now re-exportsParameterizedAlgorithmfromrill-core; definition removed.Algorithm,AlgorithmCategory,AlgorithmMetadata,ActionContext,ProcessResultno longer re-exported fromrill-core-dsp— all consumers import directly fromrill_core::traits.- 7 filter
ParameterizedAlgorithmimpls unchanged.
📦 rill-core-wdf → rill-core-model
- Crate renamed:
rill-core-wdf→rill-core-model - Internal module
filters→wdf(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::audio→rill_oscillators::signal— module renamePortType::is_audio_rate()→is_signal_rate()inrill-coreAudioTimer→SignalTimerinrill-coreAudioConfig→IoConfiginrill-coreRackCase::audio_thread→signal_threadinrill-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 divider —
f / (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_mappingupdated for correct layout
🏭 Module Factory
rill-patchbay/src/module_factory.rs (new):
ModuleConstructortrait —construct(id, params, system, graph_ref) → BoxedModuleModuleFactory—register_fn(type_name, drain, closure),register_fn_send()Drainenum —OsThread { interval_ms },TokioTask { interval_ms }(for many actors without OS thread overhead)GenericModule— factory-providedModuleimpl, no manual struct needed
rill-patchbay/src/serialization/mod.rs:
ModuleDef::Custom { type_name, params }— dispatch throughModuleFactoryinbuild_servos()
rill-adrift/src/modular/mod.rs:
ModularSystem.module_factory: ModuleFactory—module_factory_mut()for pre-launch registration- Rack actor drain loop:
tokio::spawn→std::thread::spawn(avoidsSendrequirement on handler)
🎭 Actor Model Unification
rill-core-actor:
- Removed:
Actor<M>(oldSendvariant),LocalActor<M>,ActorCelltrait,MessageDispatcher,build_actor() - Added:
spawn_detached(name, make_handler, ms)— handler created inside spawned thread,ActorRefreturned 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;
Sendbound removed from handler closure
🔧 Sequencer & Servo Fixes
rill-patchbay/src/automaton/sequencer.rs:
- Removed dead
Step.valueandStep.curvefields (Stepnow only hasduration) - Fixed
step_duration()formula: removed× 4.0factor (now1.0= quarter note, not whole note)
rill-patchbay/src/engine.rs:
- Added
Servo::with_table()builder — propagatestablefromServoDeftoServo Servo::spawn()usesspawn_detached_tokio— handler created inside tokio task, no actor crossing thread boundary
rill-patchbay/src/serialization/mod.rs:
build_servos()now propagatesServoDef.table→Servo::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.24quarter-note beats (matching fixedstep_durationformula) - Removed unused
HashMapimport
rill-adrift/examples/chiptune_stc.rs:
- Rewritten to use
ModularSystemDef+ModuleFactory(register_fnwithDrain::OsThread) - STC player registered as
ModuleDef::Custom { type_name: "stc_player" } - Removed: manual
GraphBuilder,graph.run(),StcModulestruct,sys.spawn(),actor.drain(),thread::spawn
🔩 RackCase Fix
rill-adrift/src/modular/case.rs:
RackCase::stop()— addedhandle.thread().unpark()beforehandle.join()(was hanging on exit)taskstype:Vec<tokio::task::JoinHandle>→Vec<std::thread::JoinHandle>
📝 Documentation
docs/src/guides/chip-emulators.md:
- Rewritten: accurate register map, architecture diagram,
io_writecontrol 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>, threespawnvariants, handler-creation design rule
🧹 Cleanup
- Removed: dead
Actor<M>(Send variant),ActorCell,build_actor(),Step.value/Step.curve rill-io/Cargo.toml— removed unusedbase64dependencyrill-core-actor/Cargo.toml— added optionaltokiodependency (feature-gatedspawn_detached_tokio)- PortAudio callback — removed debug
base64output
🏗️ Architecture: RackDef unification + CaseDef removal
rill-adrift/src/modular/serialization.rs:
- New
RackDefwithgraph: GraphDeffield — graph lives inside the rack, not in a separateCaseDef - New
ModuleDef::Graph { graph: GraphDef }variant — multiple graphs per rack build_servos()moved fromrill-patchbaytorill-adriftModularSystemDef.racks: Vec<RackDef>replacescases: Vec<CaseDef>CaseDefremoved entirely —patchbay: Option<RackDef>no longer needed
rill-adrift/src/modular/mod.rs:
launch()simplified: single loop overdef.racks, nohas_rackcheck- Rack actor drain:
tokio::spawn→std::thread::spawn(avoidsSendrequirement) - Graph construction stays in
launch()(not via factory)
rill-patchbay/src/serialization/mod.rs:
RackDef→PatchbayDef(backward-compatible rename, withoutgraphfield)ModuleDef(withoutGraphvariant) +build_servos()remain in rill-patchbay
rill-adrift/src/modular/config.rs:
LaunchConfig.rack_deftype:RackDef→PatchbayDef
🔌 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::IoCallbackvariant — for graph modules with inline drain (not yet used via factory)
📝 Documentation
docs/src/architecture/actor.md:
- Updated for current API:
Actor<M>, threespawnvariants, 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 для отправки сообщений, единственный внешний интерфейс- Удалены:
ActorCelltrait,Mbox,MessageDispatcher,ActorRef::new_pair(), genericActorSystem<M>
rill-graph:
GraphBuilder::build(&ActorSystem)— создаёт актор с handler'ом, захватывающим nodesGraph::run()— tick-замыкание владеет actor'ом напрямую (без*mut Graph)- Nodes хранятся в
Rc<UnsafeCell<Vec<NodeVariant>>>— interior mutability на одном потоке - Удалены:
*mut NodeVariant,*mut Graph,ActorCellimpl,mailboxполе - Сигнальные тесты:
test_graph_source_to_sink,test_graph_source_proc_sink
rill-patchbay:
Patchbaystruct удалён. Вместо него —Servo::spawn(self) → ActorRef<CommandEnum>- Создаёт актор с полным handler'ом (ClockTick → automaton.step → SetParameter)
- Запускает
std::threaddrain loop (1ms interval) - Внешний код получает только
ActorRef— никакого прямого доступа к состоянию
Servoбольше неModule— автономный актор, не type-erased boxPatchbayDef→RackDef—build_servos(&ActorSystem, &graph_ref) → HashMap<String, ActorRef>add_lfo,add_envelope,add_boxed_servoудалены — сборка вlaunch()напрямуюModuletrait — только для Sensor; убраныdrain(),update()- Channel-forwarding (mpsc) между actor'ами удалён — каждый актор самодрейнится
rill-adrift:
RackCase— минимальный хост:modules: HashMap<String, ActorRef>,tasks: Vec<JoinHandle>- Удалены:
patchbay,incoming,outgoing,ActorCellimpl, межкейсовый routing handle() → ActorRef— дляparent_refв Graphstop()— abort всех tasks, join audio thread
- Удалены:
launch():- Создаёт актор RackCase (с
Arc<Mutex<HashMap>>для модулей) - Запускает drain thread актора (пересылает ВСЕ сообщения всем модулям)
- Строит граф на audio thread
- Получает
graph_refчерез oneshot канал rack_def.build_servos()— создаёт Servo'ы с drain threads- Регистрирует servo ActorRef'ы в RackCase модулях
- Создаёт актор 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 viastd::arch(SSE2/AVX/NEON/SIMD128)VectorMask<T, N>completed forF32x4,F32x8,F64x2,ScalarVector4VectorReduce,VectorScalarOpstraits with blanket implsScalar::from_usize()added to core math trait- Dead
exprmodule +vec_expr!/vec_eval!stubs removed
-
Algorithm SIMD (rill-core-dsp):
BasicOscillator— 6 waveforms viaScalarVector4block processing (4 samples/iter)- Saw BLEP —
VectorMask::selectreplaces per-lane scalar conditional (2.5× speedup) InterpolatedReader— 4-wide lerp math for linear/cubic interpolationCombFilter— batched 4-sample read/write whendelay_samples >= 4NoiseGenerator— White (batched xorshift), Brown (unrolled integrator), Blue/Violet (4-wide diff)Biquad— block state-space 4×4 feedforward matrix viaBiquadBlockprecomputationResampler<T>— sample-rate converter onInterpolatedReader(44.1k→48k etc.)
-
Node-level SIMD:
Distortion— HardClip/Tube 4-wide SIMD; zero-copy port outputDryWetMix— 4-wide multiply-add, stereo in one passWriteHead— batched 4-sample math per tape writepre_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_vectoronResistor,Capacitor,Inductor,DiodeviaScalarVector4- Diode Newton-Raphson vectorized with
VectorMask::all()early exit process_batch_simdfree function for batch processingsimd.rsdeleted (378 LOC) — no more parallel SIMD type hierarchy
-
I/O SIMD:
- Generic
f32_to_i16_chunk/i16_to_f32_chunkinrill-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
- Generic
-
Infrastructure:
FixedBuffernow#[repr(align(16))](hardware SIMD-ready)const { assert!(BUF_SIZE % 4 == 0) }inprocessable.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 explicitwidecrate 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, ¤t, time, action) → ParamValuetype Internal: Clone— mutable automaton-specific state (phase, RNG, step counter)initial_internal(),reset()with default impls- All state moved inside structs; old
State/Outputassociated 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_ratefield drives throttling vialast_update_timein Internal
-
Servo as actor:
Servo<A: Automaton>implementsActorCell<Msg = AutomatonMsg>AutomatonMsg { Tick(ClockTick), SetEnabled(bool), Reset }— unified queue for clock + commandsServo::update()drains mailbox before stepping (same pattern asGraph::run)Servo::handle()returnsActorRef<AutomatonMsg>for external controlServo::with_table(Vec<ParamValue>)— table-based step-to-value mapping for sequencersSequencerAutomatonreturnsParamValue::Int(step_index)→ Servo looks up in table
-
Sensor trait — unified external input bridge:
trait Sensor { attach(), start(), stop() }— MIDI, OSC, knobs, acoustic analysisMidiHubimplementsSensor— no moreArc<Mutex<Patchbay>>Patchbay::event_mailbox— singleMpscQueue<ControlEvent>for ALL sensorsevent_handle() → ActorRef<ControlEvent>,drain_events()called fromdrain_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;Commandtrait kept inrill-core::queuesTelemetryTx(crossbeam wrapper) deleted;Telemetrytypes kept for future useObservermoved torill-patchbay, now usesActorRef<Telemetry>SequencerHandle(crossbeam command channel) deletedattach_sequencer()(crossbeamReceiver<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)
-
MidiActorrenamed toMidiHub;midi_actor.rs→midi.rs -
Graph::receive()now drains viaActorCell(was manualset_parameterloop)
🔧 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_ratefield 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
Ay38910BackendAY-3-8910 emulator - Loads the STC file (
Bonysoft - Popcorn (1993).stc) viainclude_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
- Plays ZX Spectrum chiptune files through the
[0.5.0-beta.4] — 2026-05-08
✨ New
-
IoNode/ActiveNodetrait hierarchy inrill-core::traits::node:Node— base trait, no backend, no run methodIoNode: Node—resolve_backend(backend)for I/O-capable nodesActiveNode: IoNode—run(tick, running)for the single driver nodeas_io_node_mut()/as_active_node_mut()downcasting helpers onNodeInput,Output,LofiInputimplementIoNodeInput,OutputimplementActiveNodeGraphBuilder::build()uses downcasting instead of name-based matchingGraph::run()callsActiveNode::run()instead ofNode::run()GraphRunnertrait removed — replaced byBox<dyn FnMut(u64, f32)>- Inherent
resolve_backend()convenience methods onInput/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)IoControltrait inrill-core::io— uniform register write interfaceLofiInput<T, BUF_SIZE>—Sourcenode wrapping anyIoBackendwith lofi processing
-
WDF tape module in
rill-core-model:RecordHead<T>,PlaybackHead<T>— analog tape physics,Algorithm<T>OpAmp<T>— operational amplifier asWdfElement<T>CassetteDeckinrill-analog-effectsrefactored to use heads fromrill-core-model
-
Transcendentaltrait 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_callbacksignature changed fromFn()toFn(f32)— each backend passes its actual negotiated sample rate to the process callback.ClockTick.sample_ratenow always reflects the true device rate.rill-io/jack: readsclient.sample_rate()after activation, passes to callback.rill-io/alsa: querieshw.get_rate()afterset_rate(Nearest), enforces exact period match (hw.get_period_size() == BUF_SIZE), rejects mismatches. Fixedwrite()— was hardcoded for stereo, now handles N channels with proper interleaving.rill-io/pipewire: output chunk no longer hardcoded to 512 samples — usesbuf_frames * out_channelsfor correct mono timing.write()fixed for N channels.rill-lofi/emulators: removedunsafe impl Send/Sync— backends run exclusively in the hard-RT audio thread.rill-core/io:IoBackendandIoControltraits no longer requireSend + Sync.rill-core-actor:ActorCellno longer requiresSend.rill-adrift/chiptune:step()usesf64timing (no millisecond quantization),Ay38910Backendlazily created with actual sample rate,lofi.init(sr)called for correct processor configuration.rill-adrift/record_mic: graph built inside audio thread spawn (noSendneeded).
✨ New
rill-io/portaudio— cross-platform PortAudio backend (portaudiofeature). Exact buffer size, noBufferSize::Defaultissues, simpler API. Default backend replacing CPAL.
🧹 Removed
rill-io/cpal— replaced byrill-io/portaudio(cross-platform, cleaner API)Ay38910Emulator,NesEmulator— replaced byChip+Backend+LofiInputrill-analog-effects::OperationalAmplifier— replaced byrill_core_model::OpAmp
📖 Documentation
- New guide: Chip Emulators (
docs/src/guides/chip-emulators.md) - Examples section added to root
README.md— all 5rill-adriftexamples described withcargo runcommands - Spec + plan for IoBackend-based emulator architecture in
docs/superpowers/
[0.5.0-beta.3] — 2026-05-07
✨ New
-
rill-core-actorcrate — actor model infrastructure:ActorRef<M>— thread-safe handle, strongArcreference,send()is lock-free and RT-safeActorCelltrait — for types that own a mailbox and process messagesMessageDispatcher<M>— dispatcher with dead letters supportActorSystem<M>— named mailbox registry,route(),broadcast(), dead letters
-
rill-adrift:serializationadded to default features —serde+tomlavailable out of the box -
rill-adrift:config.toml— new example config file withbackend_name,backend_params,sample_rate,block_size -
rill-adrift:RuntimeConfignow derivesserde::Deserialize(behindserializationfeature) -
Missing graph nodes registered:
rill/moog_ladder— digital Moog ladder filter (rill-digital-filters)rill/lofi— lo-fi processor (rill-lofi, gated behindlofi)rill/analog_moog_ladder— WDF Moog ladder filter (rill-analog-filters, gated behindanalog)rill/cassette_deck— cassette deck emulation (rill-analog-effects, gated behindanalog)rill/parametric_eq— parametric equalizer (rill-router)rill/graphic_eq— graphic equalizer (rill-router)- All router nodes (
dry_wet_mix,mixer, EQ) consolidated intoregister_router()
🧹 Removed
rill-core-dsp: removedunstablefeature (no code behind it, required nightly)rill-patchbay:PatchbayEngineremoved (folded intoEngine)rill-core:traits::actormodule removed (moved torill-core-actor)
🔧 Fixes
rill-io/pipewire: fixedAudioBackend::writestub returning0instead ofbuffer.len()rill-graph: removed redundantB as usizecast, pre-existing clippy warnings fixedrill-patchbay,rill-adrift: fixed redundant closures, unused imports, unused variablesrill-adrift:--no-default-featurescompilation fixed:register_all_nodesno longer gated behindio(oscillators, filters, effects available without I/O)register_backendscall inRuntime::new()gated behindiocfg_from_params()gated behindioPatchbayimport decoupled fromoscfeatureActorRefimport gated behindany(osc, serialization)- Dead
register_iostub removed
rill-adriftexamples:play_jsonrenamed toplayer— now readsconfig.tomlinstead of hardcoded paths- All examples have explicit
required-features(clear error with--no-default-features) play_wav: unusedregistrationimport 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,ActorRefnaming
[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 selection —
cargo run --example play_wav -- [backend] [file] - 24-bit WAV support —
rill-samplernow handles 24-bit PCM in addition to 16-bit
🔧 Improvements
- All 4 audio backends produce clean audio: CPAL, ALSA, PipeWire, JACK
- OutputWindow pattern —
write_output()writes directly into DMA buffer, eliminating intermediate ring buffers and associated sizing issues (CPAL, PW, JACK) - Lock-free
IoRingBuffer— rewritten withUnsafeCellinterior mutability, all methods take&self, noMutex/RwLockin the RT path - No
thread::sleepin any backend — all backends are event-driven or callback-driven - WDF macros accept bare expressions —
$pr:exprreplaces$pr:tt, no more unnecessary braces
🧹 Dependencies removed
parking_lot— removed fromrill-iodependencies (all uses replaced withstd::sync::Mutex/AtomicU32or lock-free patterns)crossbeam-channel— removed fromrill-iodependencies (start/stop viaAtomicBool+thread::park/unpark, MIDI events viastd::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_loopallowed at workspace level)- 491 tests — all passing, 0 clippy warnings (excluding intentional
needless_range_loopin 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, overhauledgetting-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.mdwritten from scratchrill-patchbay/README.mdrewritten with green thread architecturerill-adrift/README.mdexpanded with feature flags tableCHANGELOG.md,MANIFESTO.mdmoved 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
-
Audio→Signalrename across the entire API surface:AudioNode→SignalNodeAudioBuffer→SignalBufferAudioError/AudioResult→SignalError/SignalResultAudioGraph→SignalGraphAudioEngine→SignalEngine
All crates bumped to
0.4.0. Onlyrill-io::AudioBackendkeeps its name (genuinely audio-specific trait).
[0.4.1] — 2026-05-04
✨ Audio I/O backends — AudioIo trait
Реализован AudioIo для всех бэкендов:
| Бэкенд | Статус | Механизм вызова callback |
|---|---|---|
NullBackend | ✅ | Заглушка, callback не дёргается |
PipewireBackend | ✅ | RT callback (PW thread) |
JackBackend | ✅ | RT callback (JACK thread) |
AlsaBackend | ✅ | snd_pcm_wait() — event-driven, без thread::sleep |
CpalBackend | ✅ | Thread + thread::sleep(interval) — poll-driven |
AudioInput::init_backend(name, config)— узел сам создаёт бэкенд по имени (null,alsa,cpal,pipewire,jack), каждый под feature gateAudioOutput::set_active(source_idx)+start()— pull model (active Sink). Sink хранит ссылку на Source и дёргаетgenerate()+propagate()при каждом цикле обработки. Callback идентичен push-модели.AudioOutput::consume()— читает из собственных входных портов (self.inputs), а не из параметраsignal_inputs(пуст при вызове черезprocess_block→propagate)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-sampler | 0.3.1 | Сэмплер + time-series reader (Source-узлы графа) |
✨ rill-core (0.3.2)
Interpolatetrait — дробно-индексное чтение&[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→ отображение времени на дробный индекс →Interpolatetrait. Три стратегии: 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,value→TimeSeriesReader<f64>. Группировка по каналам, сортировка по времени, пропуск непарсируемых строк.
🏗️ Инфраструктура
rill-samplerдобавлен в workspace иrill-adrift(feature"sampler", включён в default). Обновлёнscripts/publish.sh.
📦 Публикации на crates.io
| Крейт | Версия |
|---|---|
rill-core | 0.3.2 |
rill-core-dsp | 0.3.1 |
rill-sampler | 0.3.1 |
📊 Статистика
| Метрика | Значение |
|---|---|
| Крейтов в workspace | 17 активных |
| Добавлено тестов | +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, диод), адаптеры (последовательный, параллельный), анализ, MoogLadderrill-analog-filters— аналоговые фильтры на WDF (WdfMoogLadder, WdfRcPole)rill-analog-effects— аналоговые эффекты (операционный усилитель, кассетный декастер)
Граф и управление
rill-graph— аудиограф с топологической сортировкой, Source/Processor/Sinkrill-patchbay— мир автоматов: LFO, огибающие, случайные блуждания, сенсоры, серво, маппингrill-router— EQ (графический, параметрический) + микшер (каналы, посылы, мастер)
Обработка
rill-digital-filters— цифровые фильтры как Processor-узлыrill-digital-effects— Delay, Distortion, Limiterrill-oscillators— Sine, Noise, LFO, Envelope как Processor-узлыrill-lofi— lo-fi процессор (bitcrush, downsampling, noise, wow&flutter)
Ввод/вывод
rill-io— аудио-бекенды: NullBackend, CpalBackend, ALSA, PipeWire, JACKrill-telemetry— пробники и коллекторы телеметрииrill-server— OSC-сервер для удалённого управления (UDP, encode/decode, диспетчеризация по паттернам)
🆕 Новые крейты
| Крейт | Описание |
|---|---|
rill-core | Единое ядро (трейты, очереди, математика, макросы) |
rill-core-dsp | DSP-алгоритмы (фильтры, генераторы, векторные операции) |
rill-core-model | WDF-ядро (элементы, адаптеры, анализ) |
rill-patchbay | Автоматы, сенсоры, серво |
rill-router | EQ + микшер |
rill-telemetry | Пробники и коллекторы |
rill-analog-filters | Аналоговые фильтры на WDF |
rill-analog-effects | Аналоговые эффекты |
rill-server | OSC-сервер |
🗑️ Удалённые крейты
| Крейт | Замена |
|---|---|
rill-core-traits | rill-core |
rill-signal | rill-core::queues |
rill-buffers | rill-core::buffer + rill-core-dsp::buffer |
rill-automation | rill-patchbay |
rill-control | rill-patchbay |
rill-eq | rill-router::eq |
rill-mixer | rill-router::mixer |
rill-hp | rill-core-dsp (f64) |
📊 Статистика
| Метрика | Значение |
|---|---|
| Крейтов в workspace | 15 активных |
| Тестов | 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