VirtualDevice vs MidiDevice in Nallely-MIDI: Core Architectural Differences Explained

VirtualDevice is a software-only thread-based generator that produces control values internally, while MidiDevice is a hardware-aware adapter that bridges the Nallely link system with physical MIDI interfaces via the mido library.

The nallely-midi framework provides two fundamental abstractions in its nallely/core/ module for building modular MIDI ecosystems. Understanding the difference between VirtualDevice and MidiDevice is essential for deciding whether to create algorithmic software generators or hardware controller wrappers. Both expose parameters that participate in the Nallely runtime's linking system, but they represent fundamentally different execution models, I/O strategies, and lifecycle requirements.

Base Class and Threading Architecture

The primary distinction lies in how each device manages execution. VirtualDevice inherits from Python's threading.Thread class, as implemented in nallely/core/virtual_device.py. It runs a continuous cycle through its run() method, processing input queues, handling pause states, and executing the internal link engine. Device logic is implemented in a user-defined main() generator method that yields values at each cycle.

MidiDevice, defined in nallely/core/midi_device.py, is a regular Python class that does not inherit from Thread. Instead of managing its own execution thread, it relies on the mido library's callback mechanism to react to incoming MIDI messages. This architectural choice reflects its role as a passive adapter rather than an active generator.

Parameter Types and Data Flow

Each device type utilizes distinct parameter descriptors optimized for their respective domains.

VirtualDevice uses VirtualParameter instances declared with VirtualParameter(...). These represent virtual control values that can operate in streaming mode (stream=True) or as discrete triggers. Parameters are linked through Nallely's internal routing system using send_out, stream_links, and nonstream_links attributes.

MidiDevice employs hardware-oriented descriptors including ModuleParameter, ModulePadsOrKeys, and ModulePitchwheel. These map directly to MIDI protocol elements:

  • ModuleParameter handles Continuous Controller (CC) and Program Change messages
  • ModulePadsOrKeys manages Note On/Off and Velocity data
  • ModulePitchwheel captures pitch bend information

Incoming MIDI messages are parsed and routed to these parameters via self.links, while outgoing values are transmitted through self.outport.send(msg).

Lifecycle and Initialization Patterns

VirtualDevice instances are instantiated with VirtualDevice(..., autoconnect=False) and must be explicitly started using device.start(). This launch sequence creates the background thread and begins executing the main() generator. The class must be decorated with @register_virtual_device_class from nallely/core/world.py to participate in the device registry.

MidiDevice concrete subclasses (such as NTS1 or MPD32) automatically register via midi_device_classes.append(cls) in their __post_init_subclass__ method. Initialization requires calling self.connect() to open MIDI ports via mido, followed by self.listen() to attach the input callback (self.inport.callback = self._sync_state). Unlike VirtualDevice, there is no thread to start— hardware communication begins immediately upon connection.

Practical Implementation Examples

Creating a Software-Only VirtualDevice

The following example implements a simple low-frequency oscillator entirely in software:

from nallely.core.virtual_device import VirtualDevice, VirtualParameter, on

class LFO(VirtualDevice):
    """Simple low-frequency oscillator generating sawtooth waves."""
    freq_cv = VirtualParameter('freq', range=(0.1, 10.0), default=1.0)
    out_cv = VirtualParameter('out', range=(0, 127))

    def main(self, ctx):
        """Generate a saw-tooth wave based on frequency."""
        while True:
            t = ctx.get('t', 0) + self.freq_cv / self.target_cycle_time
            value = int((t % 1) * 127)
            ctx['t'] = t
            return value

    @on(freq_cv, edge='rising')
    def on_freq_change(self, value, ctx):
        """Optional handler for frequency parameter changes."""
        print(f'Frequency changed to {value}')

Usage requires starting the thread:

lfo = LFO(target_cycle_time=0.01)  # 10ms cycle time

lfo.start()  # Launches threading.Thread.run()

Relevant source: nallely/core/virtual_device.py lines 70-80 (class definition) and lines 170-190 (parameter handling).

Integrating Hardware with MidiDevice

Concrete MidiDevice subclasses interface with physical hardware through the mido library:

from nallely.devices.nts1 import NTS1  # Concrete subclass in nallely/devices/

# Instantiate and connect to physical hardware

synth = NTS1(device_name='Korg NTS-1')
synth.channel = 0        # Optional: force MIDI channel

synth.connect()          # Opens output port via mido

synth.listen()           # Starts input callback

# Send direct MIDI CC message (e.g., filter cutoff)

synth.control_change(control=74, value=100)

# Route virtual device output to hardware parameter

from nallely.core.links import Link
Link.create(synth.modules.filter.cutoff_cv, lfo.out_cv)

Relevant source: nallely/core/midi_device.py lines 82-94 (base class definition) and lines 370-420 (I/O methods).

Summary

  • VirtualDevice inherits from threading.Thread and executes a user-defined generator in a background thread, while MidiDevice uses mido callbacks without threading for hardware communication.
  • VirtualParameter instances handle internal software values with streaming support, whereas ModuleParameter and related classes map directly to MIDI protocol messages.
  • VirtualDevice communicates exclusively through Nallely's internal link system, while MidiDevice bridges to physical MIDI ports via outport.send() and input callbacks.
  • VirtualDevice requires explicit start() to launch the thread, while MidiDevice uses connect() and listen() to initialize hardware interfaces.

Frequently Asked Questions

Can a VirtualDevice send MIDI messages directly to hardware?

No. VirtualDevice instances have no external I/O capabilities and cannot access MIDI ports directly. To control hardware, create a Link between the VirtualDevice's VirtualParameter and a MidiDevice's ModuleParameter, allowing the internal value to drive MIDI CC or note messages sent by the MidiDevice.

Why does MidiDevice not inherit from Thread like VirtualDevice?

MidiDevice relies on the mido library's callback-based I/O model rather than a polling loop or generator pattern. Since mido handles asynchronous message reception through port callbacks, and transmission occurs on-demand via method calls, a background thread would be redundant and potentially conflict with mido's internal threading model.

Can the same device class inherit from both VirtualDevice and MidiDevice?

No, these classes represent mutually exclusive architectural patterns. A device cannot simultaneously be a background thread generator and a mido-based hardware adapter. For hybrid functionality, create separate device instances and link them using the Nallely Link API in nallely/core/links.py.

Which device type should I use for a custom clock or sequencer?

Use VirtualDevice for algorithmic generation (LFOs, envelope followers, probability sequencers) that produces control data internally. Use MidiDevice when you need to send that sequenced data to external synthesizers or when wrapping hardware that generates its own clock signals. The VirtualDevice generates the pattern; the MidiDevice transmits it to hardware.

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 →