From 1b0eb5f65498f9f5c71f608d6d893de28aa8a3df Mon Sep 17 00:00:00 2001 From: Dan Ellis Date: Mon, 27 Jan 2025 10:54:29 -0500 Subject: [PATCH 1/4] midi.config.add_synth now subsumes add_synth_object. --- docs/music.md | 2 +- tulip/shared/py/midi.py | 31 ++++++++++++++++++------------- tulip/shared/py/voices.py | 2 +- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/music.md b/docs/music.md index 3a71497b..37e01b95 100644 --- a/docs/music.md +++ b/docs/music.md @@ -171,7 +171,7 @@ You may want to programatically change the MIDI to synth mapping. One example wo You can change the parameters of channel synths like this: ```python -midi.config.add_synth(channel=c, patch_number=p, num_voices=n) +midi.config.add_synth(channel=c, synth=midi.Synth(patch_number=p, num_voices=n)) ``` Note that `add_synth` will stop any running Synth on that channel and boot a new one in its place. diff --git a/tulip/shared/py/midi.py b/tulip/shared/py/midi.py index f1d5250c..1af062d7 100644 --- a/tulip/shared/py/midi.py +++ b/tulip/shared/py/midi.py @@ -19,7 +19,7 @@ def __init__(self, voices_per_channel={}, patch_per_channel={}, show_warnings=Tr self.arpeggiator_per_channel = {} for channel, num_voices in voices_per_channel.items(): patch = patch_per_channel[channel] if channel in patch_per_channel else None - self.add_synth(channel, patch, num_voices) + self.add_synth(channel=channel, synth=Synth(patch_number=patch, num_voices=num_voices)) def release_synth_for_channel(self, channel): if channel in self.synth_per_channel: @@ -29,18 +29,23 @@ def release_synth_for_channel(self, channel): if channel in self.arpeggiator_per_channel: self.arpeggiator_per_channel[channel].synth = None - def add_synth_object(self, channel, synth_object): + def add_synth(self, synth=None, patch_number=None, channel=1, num_voices=None): + if synth is None and patch_number is None: + raise ValueError('No synth (or patch_number) specified') self.release_synth_for_channel(channel) - self.synth_per_channel[channel] = synth_object + if synth is None: + print('add_synth(patch_number=..) is deprecated and will be removed. Use add_synth(Synth(patch_number=..)) instead.') + if num_voices is None: + num_voices = 6 # Default + synth = Synth(num_voices=num_voices, patch_number=patch_number) + elif patch_number is not None or num_voices is not None: + raise ValueError('You cannot specify both synth and patch_number/num_voices') + # .. because we can't reconfigure num_voices which you might be expecting. + self.synth_per_channel[channel] = synth if channel in self.arpeggiator_per_channel: - self.arpeggiator_per_channel[channel].synth = synth_object - - def add_synth(self, channel=1, patch_number=0, num_voices=6): - self.release_synth_for_channel(channel) - synth_object = Synth(num_voices=num_voices, patch_number=patch_number) - self.add_synth_object(channel, synth_object) + self.arpeggiator_per_channel[channel].synth = synth # Return the newly-created synth object so client can tweak it. - return synth_object + return synth def insert_arpeggiator(self, channel, arpeggiator): if channel in self.synth_per_channel: @@ -495,11 +500,11 @@ def ensure_midi_config(): # utility sine wave bleeper on channel 16 config = MidiConfig(show_warnings=True) # The "system bleep" synth - config.add_synth_object(channel=16, synth_object=OscSynth(num_voices=1)) + config.add_synth(channel=16, synth=OscSynth(num_voices=1)) # GeneralMidi Drums. - config.add_synth_object(channel=10, synth_object=DrumSynth(num_voices=10)) + config.add_synth(channel=10, synth=DrumSynth(num_voices=10)) # Default Juno synth on Channel 1. - config.add_synth(channel=1, patch_number=0, num_voices=6) + config.add_synth(channel=1, synth=Synth(patch_number=0, num_voices=6)) config.insert_arpeggiator(channel=1, arpeggiator=arpeggiator) diff --git a/tulip/shared/py/voices.py b/tulip/shared/py/voices.py index 8ffff5de..27465332 100644 --- a/tulip/shared/py/voices.py +++ b/tulip/shared/py/voices.py @@ -265,7 +265,7 @@ def update_map(): channel_patch, amy_voices = midi.config.channel_info(channel) channel_polyphony = 0 if amy_voices is None else len(amy_voices) if (channel_patch, channel_polyphony) != (patch_no, polyphony): - midi.config.add_synth(channel=channel, patch_number=patch_no, num_voices=polyphony) + midi.config.add_synth(channel=channel, synth=midi.Synth(patch_number=patch_no, num_voices=polyphony)) # populate the patches dialog from patches.py From 572a8d866890183e7d3703986d816dc92ece6cb6 Mon Sep 17 00:00:00 2001 From: Dan Ellis Date: Mon, 27 Jan 2025 11:42:18 -0500 Subject: [PATCH 2/4] midi.Synth: Defers voice allocation to allow clean substitution of midi channel synths. --- tulip/shared/py/midi.py | 48 ++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/tulip/shared/py/midi.py b/tulip/shared/py/midi.py index 1af062d7..94f7350d 100644 --- a/tulip/shared/py/midi.py +++ b/tulip/shared/py/midi.py @@ -225,27 +225,35 @@ def reset(cls): amy.reset() def __init__(self, num_voices=6, patch_number=None, patch_string=None): - self.voice_objs = self._get_new_voices(num_voices) - self.released_voices = Queue(num_voices, name='Released') - for voice_index in range(num_voices): - self.released_voices.put(voice_index) - self.active_voices = Queue(num_voices, name='Active') - # Dict to look up active voice from note number, for note-off. - self.voice_of_note = {} - self.note_of_voice = [None] * num_voices - self.sustaining = False - self.sustained_notes = set() - # Fields used by UI - #self.num_voices = num_voices - self.patch_number = None - self.patch_state = None if patch_number is not None and patch_string is not None: raise ValueError('You cannot specify both patch_number and patch_string.') if patch_string is not None: patch_number = Synth.next_amy_patch_number Synth.next_amy_patch_number = patch_number + 1 amy.send(store_patch='%d,%s' % (patch_number, patch_string)) - self.program_change(patch_number) + self._pre_init_num_voices = num_voices + self._pre_init_patch_number = patch_number + self._initialized = False + + def deferred_init(self): + if not self._initialized: + self._initialized = True + num_voices = self._pre_init_num_voices + self.voice_objs = self._get_new_voices(num_voices) + self.released_voices = Queue(num_voices, name='Released') + for voice_index in range(num_voices): + self.released_voices.put(voice_index) + self.active_voices = Queue(num_voices, name='Active') + # Dict to look up active voice from note number, for note-off. + self.voice_of_note = {} + self.note_of_voice = [None] * num_voices + self.sustaining = False + self.sustained_notes = set() + # Fields used by UI + #self.num_voices = num_voices + self.patch_number = None + self.patch_state = None + self.program_change(self._pre_init_patch_number) def _get_new_voices(self, num_voices): new_voices = [] @@ -264,10 +272,12 @@ def _get_new_voices(self, num_voices): @property def amy_voices(self): + self.deferred_init() return [o.amy_voice for o in self.voice_objs] @property def num_voices(self): + self.deferred_init() return len(self.voice_objs) # send an AMY message to the voices in this synth @@ -294,6 +304,7 @@ def _voice_off(self, voice, time=None, sequence=None): self.note_of_voice[voice] = None def note_off(self, note, time=None, sequence=None): + self.deferred_init() if self.sustaining: self.sustained_notes.add(note) return @@ -306,6 +317,7 @@ def note_off(self, note, time=None, sequence=None): self.released_voices.put(old_voice) def all_notes_off(self): + self.deferred_init() self.sustain(False) while not self.active_voices.empty(): voice = self.active_voices.get() @@ -314,6 +326,7 @@ def all_notes_off(self): def note_on(self, note, velocity=1, time=None, sequence=None): + self.deferred_init() if not self.amy_voice_nums: # Note on after synth.release()? raise ValueError('Synth note on with no voices - synth has been released?') @@ -335,6 +348,7 @@ def note_on(self, note, velocity=1, time=None, sequence=None): def sustain(self, state): """Turn sustain on/off.""" + self.deferred_init() if state: self.sustaining = True else: @@ -344,12 +358,15 @@ def sustain(self, state): self.sustained_notes = set() def get_patch_state(self): + self.deferred_init() return self.patch_state def set_patch_state(self, state): + self.deferred_init() self.patch_state = state def program_change(self, patch_number): + self.deferred_init() if patch_number != self.patch_number: self.patch_number = patch_number # Reset any modified state due to previous patch modifications. @@ -362,6 +379,7 @@ def control_change(self, control, value): def release(self): """Called to terminate this synth and release its amy_voice resources.""" + self.deferred_init() # Turn off any active notes self.all_notes_off() # Return all the amy_voices From 6813f52647b2395c617e33a5a39c6f8e65133ada Mon Sep 17 00:00:00 2001 From: Dan Ellis Date: Mon, 27 Jan 2025 11:49:12 -0500 Subject: [PATCH 3/4] midi.py: Add comments to explain Synth.deferred_init(). --- tulip/shared/py/midi.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tulip/shared/py/midi.py b/tulip/shared/py/midi.py index 94f7350d..bc109502 100644 --- a/tulip/shared/py/midi.py +++ b/tulip/shared/py/midi.py @@ -233,9 +233,17 @@ def __init__(self, num_voices=6, patch_number=None, patch_string=None): amy.send(store_patch='%d,%s' % (patch_number, patch_string)) self._pre_init_num_voices = num_voices self._pre_init_patch_number = patch_number + # The actual grabbing of AMY voices is deferred until the first time this + # synth is used. This is to cleanly handle the case of replacing a MIDI + # channel synth, when a new Synth object is constructed and passed to + # config.add_synth, but the AMY voices held by the existing synth on that + # channel are not released until add_synth() runs. This way, the new, + # replacement synth can use the same voice numbers when it eventually + # does its deferred_init(). self._initialized = False def deferred_init(self): + """Finish synth initialization once we can assume all voices are available.""" if not self._initialized: self._initialized = True num_voices = self._pre_init_num_voices From fd4968b622cf46258ea36315710044bb1355d47e Mon Sep 17 00:00:00 2001 From: Dan Ellis Date: Mon, 27 Jan 2025 12:09:25 -0500 Subject: [PATCH 4/4] midi.py: config.add_synth calls synth.deferred_init if it exists. --- tulip/shared/py/midi.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tulip/shared/py/midi.py b/tulip/shared/py/midi.py index bc109502..b325f7b3 100644 --- a/tulip/shared/py/midi.py +++ b/tulip/shared/py/midi.py @@ -44,6 +44,8 @@ def add_synth(self, synth=None, patch_number=None, channel=1, num_voices=None): self.synth_per_channel[channel] = synth if channel in self.arpeggiator_per_channel: self.arpeggiator_per_channel[channel].synth = synth + if hasattr(synth, 'deferred_init'): + synth.deferred_init() # Return the newly-created synth object so client can tweak it. return synth