# How VoiceAllocator Handles Polyphonic Signal Routing in Nallely MIDI

> Discover how Nallely MIDI's VoiceAllocator expertly routes polyphonic signals using round-robin allocation and voice stealing for efficient CV stream management.

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

---

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

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

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

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

## Practical Examples

### Basic Voice Allocation Setup

To route MIDI notes through the polyphonic splitter:

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

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