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
VirtualDevicefromnallely/core/virtual_device.pyto create a new neuron, declaring inputs and outputs asVirtualParameterclass attributes. - Choose your execution model: use the
@ondecorator for reactive, edge-triggered logic that responds to specific input changes, or implementmain()for continuous, cyclical processing like LFOs. - Leverage the code generator by adding the
@gencodedecorator and a structured docstring DSL to auto-generate boilerplate, or manually wire instances usingstart()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 inVirtualParameterconstructors.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →