After testing with this far more convenient GUI, I’m not convinced the aftertouch handling is working after all, or maybe I broke it. I need to do further testing before packaging it into an executable. If you want to try it out, you’ll need to install Python and the modules: mido, PySimpleGui. There is obviously no warranty with this script and I am not responsible for any jankiness that explodes your expensive stereo monitors etc.
#By Ph0ton copyright lolol
#Libraries and Modules and Stuff
import PySimpleGUI as sg
import mido
import time
#Constants
VEL_UPDATE = 999
AFT_UPDATE = 998
MIDI_NOTEON = "note_on"
MIDI_NOTEOFF = "note_off"
MIDI_KEYAFTERTOUCH = "polytouch"
MIDI_CHANAFTERTOUCH = "aftertouch"
default_strikeTime = .01 #Time in seconds which correspond to notes played by the same strike
default_interFactor = .5 #Interference factor scales how much the last note played interferes with the next group velocity or aftertouch
default_strikeFactor = .3 #Strike to velocity factor scales velocity based on time interval
default_oldPressure = 30 #Used to filter old notes (in seconds)
default_valuePerNote = 15 #Baseline value lost from pressing note
default_lookbackTime = .1
default_lowerCeiling = 30 #Maximum value before it will interfere with other notes
default_maxVal = 80 #Default value if all else fails!
#Math stuff
def avg(values):
return sum(values)/len(values) if len(values) != 0 else 0
#Simple least squares fit
#Use lambda and map to get sums of values needed for least squares equation)
def sumsCalc(listX, listY):
return map(lambda values: sum(values), [listX, listY, map(lambda x: x ** 2, listX), map(lambda x,y: x * y, listX, listY)])
#Using sumsCalc values, get the slope of the least squares line
def leastSquaresSlope(n, sumX, sumY, sumX2, sumXY):
return ((n * sumXY) - (sumX * sumY))/((n * sumX2) - sumX ** 2)
#Using leastSquaresSlope value and sumsCalc values, get the intercept of the least squares line
def leastSquaresInter(n, slope, sumX, sumY):
return (sumY - (slope * sumX))/n
#Using the associated functions, predict the value on the least squares line
def leastSquaresPredict(listX, listY, xIn):
[n, [sumX, sumY, sumX2, sumXY]] = len(listX), sumsCalc(listX, listY)
slope = leastSquaresSlope(n, sumX, sumY, sumX2, sumXY)
intercept = leastSquaresInter(n, slope, sumX, sumY)
return slope * xIn + intercept
#Connect MIDI in port, MIDI out
#GUI stuff
paraLayout = [[sg.Text("Strike Time",size=(20,1)),sg.Input(key="strikeInput", size=(4,1))],
[sg.Text("Strike Factor (0-1)",size=(20,1)),sg.Input(key="strikeFactInput", size=(4,1))],
[sg.Text("Interference Factor (0-1)",size=(20,1)),sg.Input(key="interInput", size=(4,1))],
[sg.Text("Interference Value (1-127)",size=(20,1)),sg.Input(key="lowerValueInput", size=(4,1))],
[sg.Text("Interference Floor (1-127)",size=(20,1)),sg.Input(key="floorInput", size=(4,1))],
[sg.Text("Lookback Time",size=(20,1)),sg.Input(key="lookBackInput", size=(4,1))],
[sg.Button("Update Parameters", key="parameterUpdate")]]
optLayout = [[sg.Checkbox("Show MIDI in", key="midiIN", enable_events=True, default=False, size=(12,1))],
[sg.Checkbox("Show MIDI out", key="midiOUT", enable_events=True, default=False,size=(12,1))]]
handLayout = [[sg.Checkbox("MIDI", key="processMIDI", enable_events=True, default=True,size=(12,1))],
[sg.Checkbox("Velocity", key="processVel", enable_events=True, default=True,size=(12,1))],
[sg.Checkbox("Aftertouch", key="processAft", enable_events=True, default=True,size=(12,1))]]
def default_layout(inPorts, outPorts):
return [[sg.Frame("MIDI Port Options",layout=[
[sg.Text("Joue MIDI In Port",size=(20,1)),sg.Combo(inPorts, key="inPortCombo",size=(25,1))],
[sg.Text("Joue MIDI Out Port",size=(20,1)),sg.Combo(outPorts, key="outPortCombo",size=(25,1))],
[sg.Button("Open Ports", key="portsButton"),sg.Text("Status: Idle", key="statusText")]])],
[sg.Column(layout=[[sg.Frame("Parameters",layout=paraLayout)]]),
sg.Column(layout=[
[sg.Frame("Options",layout=optLayout)],
[sg.Frame("Handling",layout=handLayout)]])],
[sg.Output(size=(200,20))]]
default_title = "Joue MIDI Processor"
default_buttonState = {"portsButton":1}
class gui():
def __init__(self):
self.midi = MIDIparser()
self.handler = self.midi.getHandler()
self.MIDIports = self.midi.getMIDIports()
#print(MIDIports[0])
#print(MIDIports[1])
self.layout = default_layout(self.MIDIports[0], self.MIDIports[1])
self.window = sg.Window(default_title, self.layout, size=(400,500),finalize=True)
self.buttonState = default_buttonState
#Update input values
self.window["strikeInput"].Update(value=default_strikeTime)
self.window["strikeFactInput"].Update(value=default_strikeFactor)
self.window["interInput"].Update(value=default_interFactor)
self.window["lowerValueInput"].Update(value=default_valuePerNote)
self.window["floorInput"].Update(value=default_lowerCeiling)
self.window["lookBackInput"].Update(value=default_lookbackTime)
self.main()
def main(self):
while True: #UI loop
event, values = self.window.read()
if (event == "portsButton"): #Port button pressed selected
if ("" in [values["inPortCombo"],values["outPortCombo"]]): #Check port intent
sg.popup_timed("All ports must be selected",button_type=5, no_titlebar=True)
elif(self.buttonState[event] == 1):
if (self.midi.openPorts(values["inPortCombo"], values["outPortCombo"])):
self.window["statusText"].Update("MIDI transform running") #Update status
self.window[event].Update("Close Port") #Change text
self.window["inPortCombo"].Update(disabled = True) #Disable combobox
self.window["outPortCombo"].Update(disabled = True) #Disable combobox
self.buttonState[event] = 0 #Update state
else:
self.window["statusText"].Update("Error, ports in use") #Fallback state
else:
self.window["statusText"].Update("Idle") #Update status
self.midi.closePorts() #Close midi ports
self.window[event].Update("Open Port") #Change text
self.window["inPortCombo"].Update(disabled = False) #Enable combobox
self.window["outPortCombo"].Update(disabled = False) #Enable combobox
self.buttonState[event] = 1 #Update state
elif (event == "parameterUpdate"):
inputVals = list(values.values())[2:8]
try:
inputVals = list(map(float,inputVals))
except:
sg.popup_timed("Only input numbers",button_type=5, no_titlebar=True)
else:
#List of inputs: strikeTime, strikeFactor, interFactor, valuePerNote, maxVal, lookbackTime
print("Parameters updated")
self.handler.parameterUpdate(*inputVals)
elif (event in {"processMIDI","processVel","processAft","midiIN","midiOUT"}):
self.midi.updateProcessOptions(event, values[event]) #Update processing options
elif (event == sg.WIN_CLOSED): #Close window
break
else:
print("Event not handled") #Bad GUI event
self.window.close()
class MIDIparser():
def __init__(self):
self.handler = joueHandler()
self.lastUpdate = 0
self.procOpt = {"processMIDI":True,
"processVel":True,
"processAft":True,
"midiIN":False,
"midiOUT":False}
print("Init okay")
def getMIDIports(self):
MIDIinPorts = mido.get_input_names()
MIDIoutPorts = mido.get_output_names()
return [MIDIinPorts, MIDIoutPorts]
def getHandler(self):
return self.handler
def openPorts(self, inPortName, outPortName):
print(inPortName," ",outPortName)
try:
self.inPort = mido.open_input(inPortName)
print("In port okay")
try:
self.outPort = mido.open_output(outPortName)
except:
print("Out port problem")
self.inPort.close()
pass
else:
self.inPort.callback = self.transformMIDI
print("Out port okay")
return True
except:
return False
def closePorts(self):
self.inPort.close()
self.outPort.close()
def updateProcessOptions(self, option, value):
self.procOpt[option] = value
def transformMIDI(self, msg):
if (self.procOpt["midiIN"]): print("MIDI input | Type: ",msg.type," Props: ",msg)
if (self.procOpt["processMIDI"]):
if (msg.type in {"note_on", "note_off"}):
processedMIDI = self.handler.processMIDI(
{"type":msg.type,
"note": msg.note,
"channel":msg.channel,
"velocity": msg.velocity,
"pressure": 0,
"timestamp": time.time()})
msg.velocity = processedMIDI["velocity"] if self.procOpt["processVel"] else msg.velocity
elif (msg.type in {"aftertouch", "polytouch"}):
processedMIDI = self.handler.processMIDI(
{"type":msg.type,
"note": msg.note,
"channel":msg.channel,
"velocity": 0,
"pressure": msg.value,
"timestamp": time.time()})
msg.value = processedMIDI["pressure"] if self.procOpt["processAft"] else msg.value
if (self.procOpt["midiOUT"]): print("MIDI output | Type: ",msg.type," Props: ",msg)
self.outPort.send(msg)
def __del__(self):
self.inPort.close()
self.outPort.close()
class joueHandler():
class noteData():
def __init__(self, noteValue, channel, velocity, timestamp):
self.noteValue = noteValue
self.velocity = velocity
self.channel = channel
self.aftertouch = velocity
self.lastUpdate = timestamp
self.eventHistory = [{"eventType":MIDI_NOTEON,
"eventValue":velocity,
"timestamp":timestamp}]
def correctVelocity(self, correctedVel, timestamp):
self.velocity = correctedVel
self.eventHistory.insert(0, {"eventType":VEL_UPDATE,
"eventValue":correctedVel,
"timestamp":timestamp})
def updateAftertouch(self, aftertouchValue, timestamp):
self.aftertouch = aftertouchValue
self.lastUpdate = timestamp
self.eventHistory.insert(0, {"eventType": MIDI_KEYAFTERTOUCH,
"eventValue": aftertouchValue,
"timestamp": timestamp})
def correctAftertouch(self, correctedAT, timestamp):
self.aftertouch = correctedAT
self.lastUpdate = timestamp
self.eventHistory.insert(0, {"eventType": AFT_UPDATE,
"eventValue": correctedAT,
"timestamp": timestamp})
def __init__(self):
self.currentNotes = {}
self.noteGroups = []
self.currentTime = 0
self.callingNote = 0
self.callingChan = 0
self.callingNoteID = ""
self.callingEvent = ""
self.callingValue = 0
self.parameterUpdate(default_strikeTime,
default_strikeFactor,
default_interFactor,
default_valuePerNote,
default_maxVal,
default_lookbackTime)
self.defaultMax = default_maxVal
self.oldPressure =default_oldPressure
print("Handler started")
#Updates parameters
def parameterUpdate(self, strikeTime, strikeFactor, interFactor, valuePerNote, maxVal, lookbackTime):
self.strikeTime = strikeTime
self.strikeFactor = strikeFactor
self.interFactor = interFactor
self.valuePerNote = valuePerNote
self.maxVal = maxVal
self.lookbackTime = lookbackTime
#Strike interference, used for notes played within strike timescale, before aftertouch values can be used
def strikeInterference(self, inputVel):
strikeNotes = dict(filter(lambda note:
self.currentTime - self.strikeTime <
note[1].eventHistory[-1]["timestamp"],
self.currentNotes.items())) #Filter current notes by lookback time from event history
strikeNotes = dict(filter(lambda note:
note[1].eventHistory[0]["eventType"] == MIDI_NOTEON,
strikeNotes.items())) #Filter strike notes by note on events only, eventually check multiple events
maxVelocity = max(map(lambda note: note[1].velocity, strikeNotes.items())) #Get loudest note played
velFloor = maxVelocity #Loudest velocity serves as floor for following notes
firstNote = list(iter(strikeNotes.items()))[0] #Get first note
firstNoteTime = firstNote[1].eventHistory[-1]["timestamp"] #Get first note time for velocity calculation
strikeNotes = dict(filter(lambda note:
(note[1].velocity/self.interFactor <= 127 - velFloor)
or (note[0] == firstNote[0]),
self.currentNotes.items())) #Remove any notes likely not interfering based on interference factor
self.noteGroups.append(strikeNotes) #Add notes to note group for future processing of aftertouch
lastNoteTime = self.currentTime - firstNoteTime #Increase velocity based on average note time
outputVel = max(min(lastNoteTime/self.strikeTime
* self.strikeFactor, 127), velFloor) #Based on last note time vs look back time and scaling by strike factor, increase from the floor
return outputVel
#Pressure strike interference takes largest pressure values to find available space for correct reading. Velocity is then corrected to correct reading based on space. If
#there is no available space, then a fuzzed value based on aftertouch pressure is used
#Only affected by more than 3 notes?
def pressureStrikeInterference(self, inputVel):
#TODO Check which note group it might be in to narrow down false positives
pressureList = map(lambda note: note[1].eventHistory,
self.currentNotes.items()) #Get event history of all current notes
pressureList = map(lambda noteEvents:
list(filter(lambda event: event["eventType"] == MIDI_KEYAFTERTOUCH,
noteEvents)), pressureList) #Only select pressure
pressureList = filter(lambda eventHistory:
len(eventHistory) > 0,pressureList) #Remove empty event history
pressureList = map(lambda events: events[0], pressureList) #Select only last pressure value, todo, average last 4 values?
pressureList = filter(lambda event:
event["timestamp"] > (self.currentTime - self.oldPressure),
list(pressureList)) #Filter any old pressure values todo, filter 0 values
pressureValues = list(map(lambda eventDict: eventDict["eventValue"],
pressureList)) #Reduce list from dictionaries to just pressure values
if (len(list(pressureValues)) > 1 and inputVel == 1): #Use fallback if no presure values
if (sum(pressureValues)/self.interFactor < (127)): #Use sum as the interference factor if less than max, otherwise use largest combinations
strikeLost = min((sum(pressureValues) / (1 - self.interFactor), 127))#Assume the lost strike value is reduced within the range left from all aftertouch values
else:
largestSum = max(sum(sorted(pressureValues[-2:-1])), 127) #Sum largest two values, with max being 127
strikeLost = min(largestSum / (1 - self.interFactor), 127) #Use sum as the lost strike value
strikeLost = max(pressureValues) if strikeLost == 127 else strikeLost #Use largest of pressure list if value is 127
#Calculate probable strike value based on strike lost range, or use number of keys pressed if 1
if (inputVel== 1 and strikeLost < 10):
outputVel = self.fallbackValue()
else:
outputVel = min(int(max((inputVel / min(127 - strikeLost, .0001)) * 127, strikeLost)), self.defaultMax)
else:
outputVel = self.fallbackValue()
return outputVel
def fallbackValue(self):
return min(len(self.currentNotes)* 30, self.defaultMax)
#Pressure interference looks at all notes pressure data to find available space for correct reading. If there is no space left for any reading (e.g. values will always be 1-5)
#then it will use the average of the largest readings
def pressureInterference(self, inputPressure):
otherNotes = dict(filter(lambda note: note[0] != self.callingNoteID,
self.currentNotes.items())) #Filter out calling note from current notes
pressureNotes = dict(filter(lambda note:
MIDI_KEYAFTERTOUCH
in map(lambda event:
event["eventType"],
note[1].eventHistory),
otherNotes.items())) #Wrap result into list and map pressure values into average
pressureAvg = list(map(lambda note: #Process notes into filtered list
avg(list(map(lambda pressureEvents: #Map filtered list into average function
pressureEvents["eventValue"], #Return values of events
list(filter(lambda event: #List of events are filtered
event["eventType"] == MIDI_KEYAFTERTOUCH
and event["eventValue"] != 0, #Only return events of non-zero value and aftertouch
note[1].eventHistory))[0:4]))), #Return last 5 values matching the filter
pressureNotes.items())) #Process the map over the dictionary
pressureLost = 0 #Initialize the lost pressure value
if (len(pressureAvg) > 0): #Only proceed if list is more than 0 items and sum is more than value ceiling
for idx, pressureVal in enumerate(sorted(pressureAvg)[::-1]): #Loop through list largest to smallest
pressureLost += (pressureVal / self.interFactor) ** (idx + 1) #Calculate lost space for pressure values, increasing the exponent for smaller (cut off) values
outputPressure = min(int(inputPressure + pressureLost), 127)
else:
outputPressure = inputPressure
return outputPressure
#Pressure intertia looks at any notes with an aftertouch dropped flag and no note offs and returns an extrapolated aftertouch based on inertia weight, and a decay time. This is
#returned in an array of MIDIdata messages to be used by the device output function
def pressureIntertia(self, MIDIdata):
pressureList
#Simple moving average of corrected values
def smoothAftertouch(self, inputAft):
#Filter last events to aftertouch updates and look back time
aftUpdates = list(filter(lambda event:
event["eventType"] == MIDI_KEYAFTERTOUCH
and event["timestamp"] > (self.currentTime - self.lookbackTime),
self.currentNotes[self.callingNoteID].eventHistory))
#Filter last events to aftertouch corrections and look back time
aftCorrections = list(filter(lambda event:
event["eventType"] == AFT_UPDATE
and event["timestamp"] > (self.currentTime - self.lookbackTime),
self.currentNotes[self.callingNoteID].eventHistory))
#Use the largest list
usedValues = aftUpdates if len(aftUpdates) > len(aftCorrections) else aftCorrections
outputAft = int(avg([*list(map(lambda event: event["eventValue"], usedValues)), inputAft]))
return outputAft
def processMIDI(self, MIDIdata):
self.currentTime = MIDIdata["timestamp"]
self.callingNote = MIDIdata["note"]
self.callingChan = MIDIdata["channel"]
self.callingEvent = MIDIdata["type"]
self.callingNoteID = f'{MIDIdata["note"]}{MIDIdata["channel"]}'
if (self.callingEvent == MIDI_NOTEON):
self.callingValue = MIDIdata["velocity"]
self.currentNotes[self.callingNoteID] = self.noteData(self.callingNote, #Add to note list
self.callingChan,
self.callingValue,
self.currentTime)
if (len(self.currentNotes) > 1):
correctedVel = self.strikeInterference(MIDIdata["velocity"]) #Correct strike interference
if (len(self.currentNotes)>3):
correctedVel = self.pressureStrikeInterference(MIDIdata["velocity"])#Correct strike based on pressure
self.currentNotes[self.callingNoteID].correctVelocity(correctedVel,
self.currentTime)#Record corrected velocity
MIDIdata["velocity"] = correctedVel #Update MIDIdata
elif(self.callingEvent in {MIDI_KEYAFTERTOUCH, MIDI_CHANAFTERTOUCH}):
self.callingValue = MIDIdata["pressure"]
self.currentNotes[self.callingNoteID].updateAftertouch(self.callingValue,
self.currentTime)#Update note list
if (len(self.currentNotes) > 0):
#print("Pressure before",MIDIdata["pressure"])
correctedAft = self.pressureInterference(MIDIdata["pressure"]) #Get corrected aftertouch
correctedAft = self.smoothAftertouch(correctedAft) #Smooth corrected aftertouch
self.currentNotes[self.callingNoteID].correctAftertouch(correctedAft,
self.currentTime)#Record corrected aftertouch
MIDIdata["pressure"]= correctedAft #Update aftertouch in MIDI data
#MIDIdata["pressure"] = self.smoothCurrentPressure(MIDIdata)
#print("Pressure after",MIDIdata["pressure"])
#cc stuff here
elif(self.callingEvent == MIDI_NOTEOFF):
del self.currentNotes[self.callingNoteID] #Remove from note list
noteGroups = {} #Remove from note group
self.noteCount = len(self.currentNotes) #Update count of playing notes
else:
print("Unhandled MIDI message")
return MIDIdata
#Run Window
mainWindow = gui()