A sequencer helper firmware for the Sol MIDI to CV converter module from Winterbloom
| """ | |
| ## Sol Seqpal v0.4.1 ## | |
| ## Leonora Tindall <nora@nora.codes> ## | |
| ## Licensed GPLv.3 ## | |
| A configurable sequencer helper for the Sol. | |
| Defaults to: | |
| 3 CV/Gate channels on a/1, b/2, and c/3, | |
| plus two percussion channels on 4 and d (abused as a trigger) | |
| Each channel is re-triggered if a NOTE_ON message is recieved for that channel. | |
| If using with ORCA, please recall that what is often called MIDI Ch 1 is really Ch 0, | |
| and ORCA uses the real MIDI number. | |
| Throughout the source code, I will refer to MIDI channels starting at 1. | |
| """ | |
| # ----------------- Config ---------------------------------------------------# | |
| # Keys (numbers on the left) are MIDI channels starting at 1. | |
| # Values (strings on the right) are pairs or singlets of CV channel and gate output, | |
| # or just gate output. CV outputs (letters) can act as gate outputs but not vice versa. | |
| # Appending "p", like "d4p", means scaling for the MODEL input of Plaits. | |
| CHANNEL_CONFIG = { | |
| 1: "a1", | |
| 2: "b2", | |
| 3: "c3", | |
| 4: "d", | |
| 5: "4" | |
| } | |
| # How many NOTE ON messages to buffer for multipress, so that keyboard players | |
| # can more easily transition between notes as one might on a piano | |
| # or polyphonic synthesizer. | |
| # Set to 1 to disable this feature. | |
| MULTIPRESS_LIMIT = 4 | |
| # The base MIDI note from which model changes are computed for Plaits. | |
| # Based on the Beatstep Pro, check with your controller. | |
| PLAITS_MODEL_BASE_NOTE = 36 | |
| # ----------------- Script ---------------------------------------------------# | |
| import winterbloom_sol as sol | |
| from winterbloom_sol import trigger | |
| import winterbloom_smolmidi as midi | |
| CHANNEL_NUMBERS = [1, 2, 3, 4] | |
| CHANNEL_NAMES = ["a", "b", "c", "d"] | |
| # Map of channel number to CV channel name. | |
| CHANNEL_CV_NAME = {1: "a", 2: "b", 3: "c", 4: "d"} | |
| # V/Oct multiplier for Plaits model voltage (1 note = 1 model) | |
| PLAITS_MODEL_VOLTAGE_STEP = 5.0/16.0 | |
| class CvAsGateState: | |
| "Contains the state of a CV channel's trigger value, to be updated by Trigger.step()" | |
| def __init__(self): | |
| self.value = False | |
| self.trigger = trigger.Trigger(self) | |
| self.retrigger = trigger.Retrigger(self) | |
| def step(self): | |
| self.trigger.step() | |
| self.retrigger.step() | |
| def voltage(self): | |
| if self.value: | |
| return 5.0 | |
| else: | |
| return 0.0 | |
| # Channel correspondance setup | |
| channel_to_cv_channel = {} | |
| channel_to_gate_channel = {} | |
| scaled_channels = [] | |
| noteoff_buffers_by_channel = {} | |
| pseudogates = {} | |
| def exclusive(x): | |
| "Ensure x was not used in an existing channel" | |
| try: | |
| return not (x in channel_to_gate_channel \ | |
| or x in channel_to_cv_channel) \ | |
| or int(x) in channel_to_gate_channel | |
| except ValueError as e: | |
| return True | |
| def valid_gate_channel(x): | |
| "Ensure x is valid as a trigger/gate channel" | |
| try: | |
| return x in CHANNEL_NAMES or int(x) in CHANNEL_NUMBERS | |
| except ValueError as e: | |
| return False | |
| def int_if_possible(x): | |
| "Convert x to an int if possible, or leave as is" | |
| try: | |
| return int(x) | |
| except ValueError as e: | |
| return x | |
| for k, v in CHANNEL_CONFIG.items(): | |
| if 0 > len(v) > 3: | |
| raise ValueError("Channel config must be 1, 2, or 3 characters"); | |
| if type(k) is not int or 1 > k > 16: | |
| raise ValueError("Channel number must be an integer between 1 and 16, inclusive.") | |
| if len(v) is 1: | |
| print(f"MIDI {k}: gate-only channel on {v}") | |
| if not valid_gate_channel(v): | |
| raise ValueError(f"Gate channel {v} not in {CHANNEL_NAMES} or {CHANNEL_NUMBERS}") | |
| if not exclusive(v): | |
| raise ValueError(f"Reuse of channel {v} in gate-only channel for {k}") | |
| channel_to_gate_channel[k] = int_if_possible(v) | |
| else: | |
| print(f"MIDI {k}: CV/gate channel on {v[0]} and {v[1]}") | |
| if v[0] not in CHANNEL_NAMES: | |
| raise ValueError(f"CV channel must be in {CHANNEL_NAMES}") | |
| if not valid_gate_channel(v[1]): | |
| raise ValueError(f"Gate channel {v[1]} not in {CHANNEL_NAMES} or {CHANNEL_NUMBERS}") | |
| if not exclusive(v): | |
| raise ValueError(f"Reuse of channel {v} in CV/gate channel for {k}") | |
| channel_to_cv_channel[k] = v[0] | |
| noteoff_buffers_by_channel[k] = 0 | |
| channel_to_gate_channel[k] = int_if_possible(v[1]) | |
| if len(v) is 3 and v[2] == "p": | |
| print(f"MIDI {k}: CV scaled to Plaits model voltage") | |
| scaled_channels += [k] | |
| for k, gate_channel in channel_to_gate_channel.items(): | |
| if not type(gate_channel) is int: | |
| pseudogates[gate_channel] = CvAsGateState() | |
| def loop(last, state, outputs): | |
| # Always update the state of the pseudogates | |
| global channel_to_gate_channel, channel_to_cv_channel, pseudogates | |
| for channel_name, pseudogate in pseudogates.items(): | |
| pseudogate.step() | |
| outputs.set_cv(channel_name, pseudogate.voltage()) | |
| # Check whether or not there's a new MIDI message to process. | |
| if state.message is last.message or state.message is None: | |
| return # No change! | |
| # Messages without a channel are useless to us. | |
| if state.message.channel is None: | |
| return # Don't care. | |
| ch = state.message.channel + 1 # MIDI channels are indexed from 0 | |
| # MIDI handling for melodic channels. | |
| # Only NOTE_ON messages are used, in order to know the appropriate CV value. | |
| if ch in channel_to_cv_channel: | |
| if state.message.type is midi.NOTE_ON: | |
| noteoff_buffers_by_channel[ch] = min([ | |
| MULTIPRESS_LIMIT, noteoff_buffers_by_channel[ch] + 1 | |
| ]) | |
| if ch in scaled_channels: | |
| offset = state.message.data[0] - PLAITS_MODEL_BASE_NOTE | |
| voltage = PLAITS_MODEL_VOLTAGE_STEP * offset - (0.05) | |
| outputs.set_cv(channel_to_cv_channel[ch], voltage) | |
| else: | |
| note = sol.note_to_volts_per_octave(state.message.data[0]) | |
| outputs.set_cv(channel_to_cv_channel[ch], note) | |
| # MIDI handling for melodic and percussion channels. | |
| if ch in channel_to_gate_channel: | |
| if state.message.type is midi.NOTE_ON: | |
| if channel_to_gate_channel[ch] in pseudogates: | |
| pseudogates[channel_to_gate_channel[ch]].retrigger.retrigger() | |
| else: | |
| outputs.retrigger_gate(channel_to_gate_channel[ch]) | |
| elif state.message.type is midi.NOTE_OFF: | |
| # Decrement noteoff buffer and check if we should proceed | |
| actually_detrigger = True | |
| if ch in noteoff_buffers_by_channel: | |
| noteoff_buffers_by_channel[ch] = max([ | |
| 0, noteoff_buffers_by_channel[ch] - 1 | |
| ]) | |
| if noteoff_buffers_by_channel[ch] > 0: | |
| actually_detrigger = False | |
| if actually_detrigger: | |
| # De-trigger any associated gate, pseudo or real | |
| if channel_to_gate_channel[ch] in pseudogates: | |
| pseudogates[channel_to_gate_channel[ch]].value = False | |
| else: | |
| outputs.set_gate(channel_to_gate_channel[ch], False) | |
| # MIDI handling for "all notes off" | |
| if (state.message.type is midi.CC and state.message.data[0] is 0x7B): | |
| print("all notes off") | |
| for ch in channel_to_gate_channel: | |
| if ch in noteoff_buffers_by_channel: | |
| noteoff_buffers_by_channel[ch] = 0 | |
| if channel_to_gate_channel[ch] in pseudogates: | |
| pseudogates[channel_to_gate_channel[ch]].value = False | |
| else: | |
| outputs.set_gate(channel_to_gate_channel[ch], False) | |
| sol.run(loop) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment