Skip to content

Instantly share code, notes, and snippets.

@NoraCodes
Last active August 29, 2021 17:38
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save NoraCodes/d85da000ff1353865703e18ecfdacc38 to your computer and use it in GitHub Desktop.
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
"""
## 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