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 streamout0_cvthroughout3_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, andself.idxis 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:
- First, it silences the currently occupied voice by yielding 0 to that output
- Then it assigns the new note value to the slot
- 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.pytransforms monophonic CV streams into four independent polyphonic voices - It uses a round-robin allocation strategy with
self.idxcycling 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.allocatedtracking 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →