How VoiceAllocator Handles Polyphonic Signal Routing in Nallely MIDI

The VoiceAllocator splits a single monophonic control-voltage (CV) stream into up to four independent voices using a round-robin allocation algorithm with automatic voice stealing.

The dr-schlange/nallely-midi library provides a modular framework for MIDI and CV processing in Python. Understanding how the VoiceAllocator manages polyphonic signal routing is essential for building multi-voice synthesizer patches within this virtual device ecosystem.

Architecture and Core Components

The VoiceAllocator implementation resides in nallely/shifter.py and consists of several coordinated components that manage voice state and signal distribution.

VoiceAllocator Class Definition

The core class is defined at lines 61-78 in nallely/shifter.py, where it declares the device metadata, inputs, and outputs. The class uses the meta: disable default output flag (line 78) to suppress the generic output channel that virtual devices normally provide, ensuring only the four explicit voice outputs are exposed.

Virtual Parameters and I/O Configuration

The device defines five critical VirtualParameters at lines 81-95:

  • input_cv: The monophonic CV input stream
  • out0_cv through out3_cv: The four independent voice outputs

These parameters establish the routing endpoints that connect to downstream processors in the synthesis chain.

Allocation State Initialization

The __post_init__ method (lines 96-100) initializes the allocation tracking mechanism:

def __post_init__(self):
    self.allocated = [None, None, None, None]  # Track note values per voice

    self.voices = [self.out0_cv, self.out1_cv, self.out2_cv, self.out3_cv]
    self.idx = 0  # Round-robin index

This setup creates the self.allocated list to track which note value occupies each voice slot and stores the output parameter objects in self.voices for quick access during signal routing.

The Polyphonic Routing Algorithm

The on_input_any method (lines 108-127) implements the core routing logic that processes every incoming CV change according to a deterministic allocation strategy.

Note-On and Note-Off Detection

The allocator distinguishes between note-on and note-off events by checking the ctx.raw_value against the current allocation table:

  • Note-off: If the incoming value exists in self.allocated, the allocator treats this as a release command. The slot is cleared, a zero value is yielded to the corresponding voice output, and self.idx is set to the freed position to favor recycling recently released voices.

  • Note-on: If the value is not present in the allocation table, the allocator treats it as a new note requiring voice assignment.

Round-Robin Voice Selection

For new note events, the allocator uses self.idx to select the next available voice slot in round-robin fashion. When the index reaches the end of the four-slot allocation list, it wraps to 0 (lines 118-121):

if self.idx >= len(self.allocated):
    self.idx = 0

This cycling behavior ensures even distribution of voice assignments across the available outputs.

Voice Stealing and Assignment

If the selected slot is already occupied (线条122-124), the allocator implements voice stealing:

  1. First, it silences the currently occupied voice by yielding 0 to that output
  2. Then it assigns the new note value to the slot
  3. Finally, it yields the new CV value to the voice output and increments self.idx

This hard-stealing approach prioritizes new notes over sustaining old ones, which is typical for polyphonic synth implementations where voice count is limited.

Implementation Details in nallely/shifter.py

The complete routing logic in on_input_any handles the full state machine:

@on("input_cv")
def on_input_any(self, ctx):
    value = ctx.raw_value
    # Check if this is a note-off (value already allocated)

    if value in self.allocated:
        idx = self.allocated.index(value)
        self.allocated[idx] = None
        yield self.voices[idx], 0
        self.idx = idx
        return
    
    # Round-robin voice selection with stealing

    start_idx = self.idx
    while self.allocated[self.idx] is not None:
        self.idx += 1
        if self.idx >= len(self.allocated):
            self.idx = 0
        if self.idx == start_idx:
            break  # All slots full, steal current

    
    # Steal if necessary

    if self.allocated[self.idx] is not None:
        yield self.voices[self.idx], 0
    
    # Assign new voice

    self.allocated[self.idx] = value
    yield self.voices[self.idx], value
    self.idx += 1

The method uses Python generators (yield) to emit value changes to specific output parameters, integrating with the event-driven execution model defined in nallely/core/virtual_device.py.

Practical Examples

Basic Voice Allocation Setup

To route MIDI notes through the polyphonic splitter:

from nallely.shifter import VoiceAllocator
from nallely.core.world import World

world = World()
voice_alloc = VoiceAllocator()
world.add_device(voice_alloc)

# Simulate incoming CV values from a MIDI-to-CV converter

voice_alloc.input_cv.set(60)   # Note-on C4 → routed to out0_cv

voice_alloc.input_cv.set(64)   # Note-on E4 → routed to out1_cv  

voice_alloc.input_cv.set(60)   # Note-off C4 → out0_cv goes to 0

voice_alloc.input_cv.set(67)   # Note-on G4 → routed to out2_cv

Connecting Voices to Downstream Processors

Each voice output can drive independent processing chains:

from nallely.shifter import VoiceAllocator
from nallely.core.world import World
from nallely.filters import LowPassFilter

world = World()
alloc = VoiceAllocator()
world.add_device(alloc)

# Create four independent filter chains

filters = [LowPassFilter() for _ in range(4)]
for f in filters:
    world.add_device(f)

# Wire voice outputs to individual filters

alloc.out0_cv >> filters[0].input_cv
alloc.out1_cv >> filters[1].input_cv  
alloc.out2_cv >> filters[2].input_cv
alloc.out3_cv >> filters[3].input_cv

# Incoming notes are distributed round-robin

alloc.input_cv.set(72)   # → filters[0] receives 72

alloc.input_cv.set(75)   # → filters[1] receives 75

alloc.input_cv.set(79)   # → filters[2] receives 79

alloc.input_cv.set(82)   # → filters[3] receives 82

alloc.input_cv.set(85)   # → filters[0] receives 85 (steals voice 0)

Summary

  • The VoiceAllocator in nallely/shifter.py transforms monophonic CV streams into four independent polyphonic voices
  • It uses a round-robin allocation strategy with self.idx cycling through voice slots 0-3
  • Voice stealing occurs when all slots are occupied; the selected voice is immediately cleared (set to 0) and reassigned
  • Note-off detection relies on value matching against the self.allocated tracking list
  • The device explicitly disables the default output channel to ensure clean four-voice routing

Frequently Asked Questions

How many voices does VoiceAllocator support?

The VoiceAllocator supports exactly four voices as defined by the out0_cv through out3_cv parameters in the class definition (lines 81-95 of nallely/shifter.py). This fixed voice count is initialized in __post_init__ and is not configurable without modifying the source code.

What happens when all voice slots are occupied?

When all four slots contain active notes and a new note-on arrives, the allocator performs voice stealing at the current round-robin index position. It first yields a value of 0 to silence the existing note, then immediately assigns the new note value to that same output. This is implemented in lines 122-124 of the on_input_any method.

How does VoiceAllocator distinguish between note-on and note-off?

The allocator checks if the incoming ctx.raw_value exists in the self.allocated list (line 108). If present, it treats the event as a note-off, clears that slot, and outputs 0 to the corresponding voice. If the value is not in the allocation table, it processes it as a new note-on event requiring voice assignment.

Can VoiceAllocator be used with any CV source?

Yes, the VoiceAllocator accepts any parameter that produces numeric CV values as its input_cv source. While commonly used with MIDI-to-CV converters, it can process outputs from LFOs, envelope generators, or sequencer modules within the nallely-midi framework. The device operates on raw scalar values and maintains no dependency on specific MIDI message types beyond the value-matching logic.

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 →