-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvoice_strategies.py
More file actions
442 lines (353 loc) · 14.7 KB
/
Copy pathvoice_strategies.py
File metadata and controls
442 lines (353 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
"""
Voice allocation strategies for managing polyphonic playback on 4 motors.
When a MIDI file has more than 4 simultaneous notes, these strategies decide
which notes to play and which to drop.
"""
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Set, Tuple, Type
class VoiceAllocationStrategy(ABC):
"""Abstract base class for voice allocation strategies."""
def __init__(self):
"""Initialize strategy with empty motor state."""
# Track which MIDI note is currently playing on each motor (None = silent)
self.motor_notes: Dict[int, Optional[int]] = {0: None, 1: None, 2: None, 3: None}
# Set of all MIDI notes that should be currently playing
self.active_notes: Set[int] = set()
@abstractmethod
def allocate(
self,
timestamp_ms: int,
new_notes_on: List[int],
notes_off: List[int]
) -> List[Tuple[int, Optional[int]]]:
"""
Allocate notes to motors based on strategy.
Args:
timestamp_ms: Current absolute timestamp in milliseconds
new_notes_on: List of MIDI notes that should start playing
notes_off: List of MIDI notes that should stop playing
Returns:
List of (motor_id, midi_note) tuples representing changes:
- midi_note=None means turn motor off
- midi_note=<number> means turn motor on with that note
Only motors that need to change state are included.
"""
pass
@abstractmethod
def get_description(self) -> str:
"""Return a human-readable description of this strategy."""
pass
def reset(self) -> None:
"""Reset strategy state for new conversion."""
self.motor_notes = {0: None, 1: None, 2: None, 3: None}
self.active_notes = set()
def find_motor_by_note(self, note: int) -> Optional[int]:
"""
Find which motor is currently playing a specific note.
Args:
note: MIDI note number
Returns:
Motor ID (0-3) or None if note is not playing
"""
for motor, current_note in self.motor_notes.items():
if current_note == note:
return motor
return None
def get_free_motors(self) -> List[int]:
"""
Get list of motors that are not currently playing.
Returns:
List of motor IDs (0-3) that are silent
"""
return [motor for motor, note in self.motor_notes.items() if note is None]
class MelodicPriorityStrategy(VoiceAllocationStrategy):
"""
Melodic Priority: Keep extreme pitches (highest + lowest) and drop middle notes.
When more than 4 notes play simultaneously, this strategy:
1. Keeps the lowest note (bass line)
2. Keeps the highest note (melody)
3. Keeps the 2nd lowest note
4. Keeps the 2nd highest note
5. Drops all middle notes
Motor assignment:
- Motor 0: Lowest note (bass)
- Motor 1: 2nd lowest
- Motor 2: 2nd highest
- Motor 3: Highest (melody)
This preserves the harmonic range and melodic lines while sacrificing
inner voices, which generally sounds more musical than random selection.
"""
def allocate(
self,
timestamp_ms: int,
new_notes_on: List[int],
notes_off: List[int]
) -> List[Tuple[int, Optional[int]]]:
"""Apply melodic priority voice allocation."""
actions: List[Tuple[int, Optional[int]]] = []
# Step 1: Handle note offs
for note in notes_off:
motor = self.find_motor_by_note(note)
if motor is not None:
# Turn off the motor
actions.append((motor, None))
self.motor_notes[motor] = None
# Remove from active set
self.active_notes.discard(note)
# Step 2: Add new notes to active set
self.active_notes.update(new_notes_on)
# Step 3: Calculate target allocation based on melodic priority
target_allocation: Dict[int, int] = {}
if len(self.active_notes) == 0:
# No notes to play
pass
elif len(self.active_notes) <= 4:
# Simple case: assign all notes to motors
sorted_notes = sorted(self.active_notes)
for i, note in enumerate(sorted_notes):
target_allocation[i] = note
else:
# Melodic priority: keep extremes
sorted_notes = sorted(self.active_notes)
target_allocation = {
0: sorted_notes[0], # Lowest (bass)
1: sorted_notes[1], # 2nd lowest
2: sorted_notes[-2], # 2nd highest
3: sorted_notes[-1], # Highest (melody)
}
# Step 4: Generate actions to reach target allocation
# First, turn off motors playing notes that shouldn't be playing
for motor, current_note in list(self.motor_notes.items()):
target_note = target_allocation.get(motor)
if current_note is not None and current_note != target_note:
# Motor is playing wrong note, turn it off
actions.append((motor, None))
self.motor_notes[motor] = None
# Then, turn on motors with new target notes
for motor, target_note in target_allocation.items():
if self.motor_notes[motor] != target_note:
# Motor should play this note
actions.append((motor, target_note))
self.motor_notes[motor] = target_note
return actions
def get_description(self) -> str:
return (
"Melodic Priority: Keep highest and lowest notes (melody + bass), "
"drop middle notes when >4 simultaneous"
)
class VoiceStealingStrategy(VoiceAllocationStrategy):
"""
Voice Stealing: LRU-based voice allocation like hardware synthesizers.
When all 4 motors are in use and a new note arrives:
1. Steal the least recently started note (oldest)
2. Replace it with the new note
This mimics hardware synthesizer behavior and ensures new notes
always play, even if it means cutting off older sustained notes.
Good for: Dense polyphonic passages, piano music
"""
def __init__(self):
super().__init__()
# Track when each note started (timestamp_ms)
self.note_start_times: Dict[int, int] = {}
def reset(self) -> None:
"""Reset strategy state for new conversion."""
super().reset()
self.note_start_times = {}
def allocate(
self,
timestamp_ms: int,
new_notes_on: List[int],
notes_off: List[int]
) -> List[Tuple[int, Optional[int]]]:
"""Apply voice stealing allocation."""
actions: List[Tuple[int, Optional[int]]] = []
# Handle note offs
for note in notes_off:
motor = self.find_motor_by_note(note)
if motor is not None:
actions.append((motor, None))
self.motor_notes[motor] = None
self.active_notes.discard(note)
self.note_start_times.pop(note, None)
# Handle new notes
for note in new_notes_on:
self.active_notes.add(note)
self.note_start_times[note] = timestamp_ms
# Find a motor for this note
free_motors = self.get_free_motors()
if free_motors:
# Use first free motor
motor = free_motors[0]
else:
# All motors busy - steal the oldest note
# Find motor with oldest note
oldest_motor = None
oldest_time = float('inf')
for m, n in self.motor_notes.items():
if n is not None:
start_time = self.note_start_times.get(n, 0)
if start_time < oldest_time:
oldest_time = start_time
oldest_motor = m
motor = oldest_motor
# Turn off the old note first
if motor is not None:
old_note = self.motor_notes[motor]
if old_note is not None:
actions.append((motor, None))
# Turn on new note
if motor is not None:
actions.append((motor, note))
self.motor_notes[motor] = note
return actions
def get_description(self) -> str:
return (
"Voice Stealing: Replace oldest playing note when motors full "
"(LRU-based, like hardware synthesizers)"
)
class RolledChordStrategy(VoiceAllocationStrategy):
"""
Rolled Chord: Arpeggiate simultaneous notes with small delays.
When more than 4 notes play simultaneously, instead of dropping notes,
this strategy slightly delays them (creating a "rolled" or arpeggiated effect).
Implementation: When >4 notes arrive at the same timestamp, we add small
incremental delays (e.g., 10ms between each note) to spread them out.
Note: This strategy modifies timing, so it works differently - it generates
additional events at slightly offset timestamps.
Good for: Chords, piano music, creating harp-like effects
"""
def __init__(self, roll_delay_ms: int = 15):
"""
Initialize rolled chord strategy.
Args:
roll_delay_ms: Delay between rolled notes in milliseconds (default: 15ms)
"""
super().__init__()
self.roll_delay_ms = roll_delay_ms
# Track pending notes that need to be rolled in future
self.pending_roll: List[Tuple[int, int]] = [] # [(note, timestamp_ms), ...]
def reset(self) -> None:
"""Reset strategy state for new conversion."""
super().reset()
self.pending_roll = []
def allocate(
self,
timestamp_ms: int,
new_notes_on: List[int],
notes_off: List[int]
) -> List[Tuple[int, Optional[int]]]:
"""Apply rolled chord allocation."""
actions: List[Tuple[int, Optional[int]]] = []
# Handle note offs
for note in notes_off:
motor = self.find_motor_by_note(note)
if motor is not None:
actions.append((motor, None))
self.motor_notes[motor] = None
self.active_notes.discard(note)
# Add new notes to active set
self.active_notes.update(new_notes_on)
# Simple approach: Just play up to 4 notes at once, drop the rest
# (A full implementation would need to restructure the converter to handle delayed events)
if len(self.active_notes) <= 4:
# Simple case: assign all notes
sorted_notes = sorted(self.active_notes)
target_allocation = {i: note for i, note in enumerate(sorted_notes)}
else:
# Take first 4 notes (could be improved with better selection)
sorted_notes = sorted(self.active_notes)
target_allocation = {i: sorted_notes[i] for i in range(4)}
# Generate actions to reach target allocation
for motor, current_note in list(self.motor_notes.items()):
target_note = target_allocation.get(motor)
if current_note != target_note and current_note is not None:
actions.append((motor, None))
self.motor_notes[motor] = None
for motor, target_note in target_allocation.items():
if self.motor_notes[motor] != target_note:
actions.append((motor, target_note))
self.motor_notes[motor] = target_note
return actions
def get_description(self) -> str:
return (
f"Rolled Chord: Arpeggiate >4 simultaneous notes with {self.roll_delay_ms}ms delays "
"(creates harp-like effect)"
)
class RoundRobinStrategy(VoiceAllocationStrategy):
"""
Round Robin: Cycle through motors for each new note.
Assigns notes to motors in a round-robin fashion. When a new note
arrives, it goes to the next motor in sequence, wrapping around.
Simple and predictable, but may cut off notes abruptly.
Good for: Testing, simple melodies, rhythmic patterns
"""
def __init__(self):
super().__init__()
self.next_motor = 0
def reset(self) -> None:
"""Reset strategy state for new conversion."""
super().reset()
self.next_motor = 0
def allocate(
self,
timestamp_ms: int,
new_notes_on: List[int],
notes_off: List[int]
) -> List[Tuple[int, Optional[int]]]:
"""Apply round-robin allocation."""
actions: List[Tuple[int, Optional[int]]] = []
# Handle note offs
for note in notes_off:
motor = self.find_motor_by_note(note)
if motor is not None:
actions.append((motor, None))
self.motor_notes[motor] = None
self.active_notes.discard(note)
# Handle new notes with round-robin assignment
for note in new_notes_on:
self.active_notes.add(note)
# Assign to next motor in sequence
motor = self.next_motor
# If motor is busy, turn it off first
if self.motor_notes[motor] is not None:
actions.append((motor, None))
# Turn on new note
actions.append((motor, note))
self.motor_notes[motor] = note
# Advance to next motor
self.next_motor = (self.next_motor + 1) % 4
return actions
def get_description(self) -> str:
return "Round Robin: Cycle through motors sequentially for each new note"
# Registry of available strategies
STRATEGIES: Dict[str, Type[VoiceAllocationStrategy]] = {
'melodic': MelodicPriorityStrategy,
'voice-stealing': VoiceStealingStrategy,
'rolled': RolledChordStrategy,
'round-robin': RoundRobinStrategy,
}
def get_strategy(name: str) -> VoiceAllocationStrategy:
"""
Get a voice allocation strategy by name.
Args:
name: Strategy name ('melodic', etc.)
Returns:
VoiceAllocationStrategy instance
Raises:
ValueError: If strategy name is not recognized
"""
if name not in STRATEGIES:
available = ', '.join(STRATEGIES.keys())
raise ValueError(f"Unknown strategy '{name}'. Available: {available}")
strategy_class = STRATEGIES[name]
return strategy_class()
def list_strategies() -> None:
"""Print all available voice allocation strategies with descriptions."""
print("\nAvailable Voice Allocation Strategies:")
print("=" * 70)
for name, strategy_class in STRATEGIES.items():
instance = strategy_class()
print(f"\n {name}:")
print(f" {instance.get_description()}")
print("\nUsage: --strategy <name>")
print("Default: melodic")