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

> Learn how to create a custom virtual device in Python using Nallely. Subclass VirtualDevice, declare ports, and implement reactive logic for your MIDI projects.

- Repository: [dr-schlange/nallely-midi](https://github.com/dr-schlange/nallely-midi)
- Tags: how-to-guide
- Published: 2026-02-28

---

**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`](https://github.com/dr-schlange/nallely-midi/blob/main/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.

```python
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`](https://github.com/dr-schlange/nallely-midi/blob/main/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`](https://github.com/dr-schlange/nallely-midi/blob/main/nallely/core/virtual_device.py) (line 57). This registers edge-triggered callbacks that execute when the associated port receives new data.

```python
    @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.

```python
    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:

```python
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`](https://github.com/dr-schlange/nallely-midi/blob/main/nallely/codegen/virtual_module_autogen.py):

```python
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`](https://github.com/dr-schlange/nallely-midi/blob/main/docs/develop-neuron.md). The generated class automatically inherits from `VirtualDevice`, defines the `VirtualParameter`s, 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:

```python

# 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:

```bash
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:

```python

# 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:

```python
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`](https://github.com/dr-schlange/nallely-midi/blob/main/nallely/core/virtual_device.py) | Core runtime: `VirtualDevice`, `VirtualParameter`, `@on`, event loop | [source](https://github.com/dr-schlange/nallely-midi/blob/main/nallely/core/virtual_device.py) |
| [`docs/develop-neuron.md`](https://github.com/dr-schlange/nallely-midi/blob/main/docs/develop-neuron.md) | Step-by-step tutorial, DSL reference, example neurons | [source](https://github.com/dr-schlange/nallely-midi/blob/main/docs/develop-neuron.md) |
| [`nallely/codegen/virtual_module_autogen.py`](https://github.com/dr-schlange/nallely-midi/blob/main/nallely/codegen/virtual_module_autogen.py) | Implements the `gencode` decorator that parses the docstring DSL and emits the skeleton | [source](https://github.com/dr-schlange/nallely-midi/blob/main/nallely/codegen/virtual_module_autogen.py) |
| [`tests/test_virtual_devices.py`](https://github.com/dr-schlange/nallely-midi/blob/main/tests/test_virtual_devices.py) | Test suite that validates creation, wiring, and execution of custom neurons | [source](https://github.com/dr-schlange/nallely-midi/blob/main/tests/test_virtual_devices.py) |
| [`nallely/utils.py`](https://github.com/dr-schlange/nallely-midi/blob/main/nallely/utils.py) | Helper utilities for conversion policies (`round_cv_property`, etc.) used during device init | [source](https://github.com/dr-schlange/nallely-midi/blob/main/nallely/utils.py) |

## Summary

- **Subclass `VirtualDevice`** from [`nallely/core/virtual_device.py`](https://github.com/dr-schlange/nallely-midi/blob/main/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`](https://github.com/dr-schlange/nallely-midi/blob/main/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.