How to Create a Custom Virtual Device (Neuron) in Python with Nallely

To create a custom virtual device (neuron) in Python with Nallely, subclass VirtualDevice, declare ports using VirtualParameter objects, and implement reactive logic with the @on decorator or continuous processing in a main() coroutine.

Nallely is a Python framework for MIDI-based modular synthesis where each processing block is treated as a virtual device (or neuron). Creating custom neurons allows you to extend the ecosystem with your own signal processing logic, from simple pitch shifters to complex LFOs, using the core abstractions defined in nallely/core/virtual_device.py.

Step 1: Define the Neuron Interface by Subclassing VirtualDevice

Every neuron begins by inheriting from VirtualDevice and declaring its input and output ports as class-level VirtualParameter objects. These descriptors automatically create ParameterInstance objects for each device instance, handling value storage, range checking, and conversion policies.

from nallely import VirtualDevice, VirtualParameter, on

class MyNeuron(VirtualDevice):
    """
    MyNeuron – a simple example that adds an offset to incoming CV.

    inputs:
    * input_cv   [0, 127] <any>: incoming value
    * offset_cv  [-48, 48] init=0 round: amount to add

    outputs:
    * (default output_cv) will carry the result

    type: reactive
    category: demo
    """
    input_cv  = VirtualParameter(name="input",  range=(0, 127))
    offset_cv = VirtualParameter(name="shift", range=(-48, 48),
                               conversion_policy="round", default=0)

The docstring DSL shown above is optional. When present, it is parsed by the code generator in nallely/codegen/virtual_module_autogen.py to produce the same skeleton automatically.

Step 2: Implement the Neuron Logic

Nallely supports two execution models: reactive (edge-triggered) and continuous (cyclical). Choose the pattern that fits your signal processing needs.

Reactive Processing with the @on Decorator

For neurons that respond to changes on specific inputs (e.g., a pitch shifter), use the @on decorator defined in nallely/core/virtual_device.py (line 57). This registers edge-triggered callbacks that execute when the associated port receives new data.

    @on(input_cv, edge="any")                 # registers a callback

    def on_input_any(self, value, ctx):
        # `value` is the new CV, `self.offset` is the current offset param.

        # Return the value that should be emitted on the default output.

        if value == 0:
            return 0  # Preserve note-off to avoid spurious low notes

        return value + self.offset

Edge names (any, rising, falling) map to condition functions in OnChange.conditions within the same file.

Continuous Processing with main()

For generators like LFOs that produce output continuously regardless of input changes, implement a main() coroutine. The framework invokes main() after processing any reactive callbacks in each cycle.

    def main(self, ctx):
        # Called each cycle even when no input changes.

        # `ctx` holds mutable per-cycle state (e.g., tick counter).

        ctx.ticks += 1
        return self.offset * ctx.ticks   # example: ramp output

Step 3: Generate or Instantiate the Neuron

Once the class is defined, you can either instantiate it manually or use the code generator to bootstrap the boilerplate.

Manual Instantiation

Create an instance, optionally set parameters via the constructor, and start the internal thread:

from my_neuron import MyNeuron

# Create an instance with initial offset

neuron = MyNeuron(offset=5)
neuron.start()                 # launches the internal thread

# Patch ports to other devices (e.g., an LFO)

lfo = nallely.LFO(waveform="sine", speed=1)
lfo.start()
neuron.input_cv = lfo          # connects LFO output to our input

Using the gencode Decorator (Auto-Generation)

For a DSL-first workflow, keep the docstring and add the @gencode decorator from nallely/codegen/virtual_module_autogen.py:

from nallely.codegen import gencode

@gencode(keep_decorator=True)      # generates the full class at import time

class MyNeuron:
    """<same DSL as above>"""

Running python my_neuron.py produces a file similar to the PitchShifter example in docs/develop-neuron.md. The generated class automatically inherits from VirtualDevice, defines the VirtualParameters, and creates the @on callbacks.

Complete Working Examples

Example 1: Pitch Shifter (Reactive)

This full implementation demonstrates a reactive neuron that transposes incoming MIDI notes while preserving note-off events:


# my_pitch_shifter.py

from nallely import VirtualDevice, VirtualParameter, on

class PitchShifter(VirtualDevice):
    """Pitch Shifter

    inputs:
    * input_cv  [0, 127] <any>: incoming note
    * shift_cv  [-48, 48] init=0 round: semitone shift

    type: reactive
    category: shifter
    """
    input_cv = VirtualParameter(name="input", range=(0, 127))
    shift_cv = VirtualParameter(name="shift", range=(-48, 48),
                               conversion_policy="round", default=0)

    @on(input_cv, edge="any")
    def on_input_any(self, value, ctx):
        if value == 0:
            return 0
        return value + self.shift

Run it:

python my_pitch_shifter.py          # generates the full class

nallely run -i my_pitch_shifter.py  # starts a session with TrevorUI

Example 2: Simple LFO (Continuous)

This hybrid neuron implements a low-frequency oscillator using continuous processing:


# my_lfo.py

from nallely import VirtualDevice, VirtualParameter, on
import math

class SimpleLFO(VirtualDevice):
    """Simple LFO

    inputs:
    * speed_cv  [0.1, 20] init=1: Hz

    type: continuous
    category: lfo
    """
    speed_cv = VirtualParameter(name="speed", range=(0.1, 20), default=1)

    def setup(self):
        return ThreadContext({"t": 0.0})

    def main(self, ctx):
        dt = self.target_cycle_time
        ctx.t = (ctx.t + dt * self.speed) % 1.0
        return 63.5 + 63.5 * math.sin(2 * math.pi * ctx.t)

Patch it to a synth’s pitch CV:

from my_lfo import SimpleLFO
from nallely.devices.minilogue import Minilogue

lfo = SimpleLFO(speed=2.5)
lfo.start()

synth = Minilogue()
synth.start()
synth.input_cv = lfo   # default output_cv carries the sine wave

Key Implementation Files

File Role Link
nallely/core/virtual_device.py Core runtime: VirtualDevice, VirtualParameter, @on, event loop source
docs/develop-neuron.md Step-by-step tutorial, DSL reference, example neurons source
nallely/codegen/virtual_module_autogen.py Implements the gencode decorator that parses the docstring DSL and emits the skeleton source
tests/test_virtual_devices.py Test suite that validates creation, wiring, and execution of custom neurons source
nallely/utils.py Helper utilities for conversion policies (round_cv_property, etc.) used during device init source

Summary

  • Subclass VirtualDevice from nallely/core/virtual_device.py to create a new neuron, declaring inputs and outputs as VirtualParameter class attributes.
  • Choose your execution model: use the @on decorator for reactive, edge-triggered logic that responds to specific input changes, or implement main() for continuous, cyclical processing like LFOs.
  • Leverage the code generator by adding the @gencode decorator and a structured docstring DSL to auto-generate boilerplate, or manually wire instances using start() and direct port assignment for live patching.
  • Access ports via automatically created descriptor instances that handle range checking, conversion policies (like "round"), and default values defined in VirtualParameter constructors.

Frequently Asked Questions

What is the difference between reactive and continuous neurons?

Reactive neurons use the @on decorator to register callbacks that execute only when a specific input port receives a new value (on rising, falling, or any edge). This is efficient for event-driven processing like pitch shifting or gate detection. Continuous neurons implement a main() method that the framework calls every cycle regardless of input changes, making them suitable for generators like LFOs or envelope followers that must produce output continuously.

How do I handle note-off events in a custom neuron?

When processing MIDI note values (0-127), a value of 0 typically represents a note-off event. In your @on callback, explicitly check for if value == 0: return 0 to pass the note-off through unchanged, preventing transposition or processing that would convert the silence into an audible low note (like C-2).

Can I use the code generator without writing a full class definition?

Yes. By applying the @gencode decorator from nallely.codegen.virtual_module_autogen.py to a class containing only a structured docstring DSL, the framework auto-generates the full VirtualDevice subclass, including VirtualParameter declarations and @on method stubs. Set keep_decorator=True to preserve the decorator across re-generations during iterative development.

How do I debug a custom neuron during development?

Start your neuron in a Nallely session using nallely run -i your_file.py to launch the TrevorUI, which provides real-time visualization of port values and signal flow. You can also instantiate the device directly in a Python REPL, call start() to launch its internal thread, and manually assign values to input ports to verify main() or @on behavior before patching it to physical or virtual MIDI devices.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →