Patchbay Rack

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

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

Domain model

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

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

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

PatchbayDef — single configuration document

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

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

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

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

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

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

Sensors are configured through ModuleDef::Sensor:

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

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

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

MidiInputDef (legacy, superseded by SensorDef)

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

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

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

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

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

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

    Ok(())
}
}

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

One instance or two? — analysis

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

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

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

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

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

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

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

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

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

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

Recommendation

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

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

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

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

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

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

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

Runtime::stop() — single exit point:

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

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

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

Summary of changes

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

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

Future: feature-gated modules (Eurorack model)

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

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

Usage in downstream crates:

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

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

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

Implementation pattern (follows rill-io backend model):

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

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

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