Last active
August 29, 2021 17:38
-
-
Save NoraCodes/d85da000ff1353865703e18ecfdacc38 to your computer and use it in GitHub Desktop.
A sequencer helper firmware for the Sol MIDI to CV converter module from Winterbloom
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
## Sol Seqpal v0.4.1 ## | |
## Leonora Tindall <[email protected]> ## | |
## 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