#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), more specifically its template base application. This application is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ import logging; logging.info("import {}".format(__file__)) #Python Standard Library import atexit #Template Modules from template.calfbox import cbox from template.engine.input_midi import MidiInput #Our Modules import engine.api as api """ This file is used directly. It is a global part of the engine (see bottom of the file) that uses the api directly, which in turn triggers the GUI. """ APCmini_MAP_NOTES = { 64: "arrow_up", #Move the pattern-viewing area. +Shift: Move Track Up 65: "arrow_down", #Move the pattern-viewing area. +Shift: Move Track Down 66: "arrow_left", #Move the pattern-viewing area. +Shift: Measure Left 67: "arrow_right", #Move the pattern-viewing area. +Shift: Measure Right #Fader Controls 68: "volume", 69: "pan", 70: "send", 71: "device", #Scene Launch 82: "clip_stop", #Start / Pause 83: "solo", #Loop 84: "rec_arm", #Send Buttons to Patroneo. Default on. 85: "mute", #Don't make sounds to midi-thru when you press a button. Default on (no sound) 86: "select", 87: "unlabeled_upper", #Undo. Redo with shift 88: "unlabeled_lower", #Reset pattern-viewing area to 0,0. Clear current pattern with shift. 89: "stop_all_clips", #Rewind and stop 98: "shift", #This is just a button. There is no shift layer. } #Also put in the reverse: APCmini_MAP_NOTES.update({value:key for key,value in APCmini_MAP_NOTES.items()}) #The CCs have no labels on the hardware. We use the function as value directly APCmini_MAP_CC = { 48: "", 49: "", 50: "", 51: "", 52: "volume", #because this is below the button with "volume". 53: "", 54: "", 55: "", 56: "swing", } APCmini_MAP_CC.update({value:key for key,value in APCmini_MAP_CC.items()}) #APC Color Mappings are velocity: #The colors are only for the note buttons #The horizontal buttons are only red and controlled by 0,1,2 #The vertical buttons are only green and controlled by 0,1,2 #The shift button has no color APCmini_COLOR = { 0:"off", 1:"green", 2:"green_blink", 3:"red", 4:"red_blink", 5:"yellow", 6:"yellow_blink", #All higher are green } #Also put in the reverse: APCmini_COLOR.update({value:key for key,value in APCmini_COLOR.items()}) class ApcMiniInput(MidiInput): """We initialize with everything switched off. This makes it easier for a complex system of engine, GUI and midiinput to start up and get the order of operations right The apcmini is controlled by note on and note off. Musical Notes are an 8x8 grid: 56 57 58 59 60 61 62 63 48 49 50 51 52 53 54 55 40 ... 32 24 16 8 0 For all other buttons please see APCmini_MAP_NOTES Because we only have 8*8 buttons, but Patroneo has n*n we have a "ViewArea" for the apcMini that can be scrolled with it's arrow keys. """ def __init__(self): #No super init in here! This is delayed until self.start pass def start(self): """Call this manually after the engine and and event loop have started. For example from the GUI. It is currently started by api.startEngine() But it could be started from a simple command line interface as well.""" portname = "APCmini" logging.info(f"Creating APC midi input port") super().__init__(session=api.session, portName=portname) self.connectToHardware("APC MINI MIDI*") self.midiProcessor.active = False #This is just internal. We need it, but not used in this file self.mainStepMap_subdivisions = None #List of bools self._blockCurrentTrackSignal = False # to prevent recursive calls self._cacheSubdivisonValue = None #set in callback self.currentTrack = None #track export dict. Set on program start and on every GUI track change and also on our track change. self.prevailingVelocity = 90 # defaults to 90 by convention on startup. After that only the hardware fader counts. self.shiftIsActive = None #For shift momentary press self.armRecord = None #Toggle self.mute = None #Make music when you press buttons self.activeNoteOns = set() # midiPitchInt self.stepsNotAvailableBecauseOverlayedByFactor = set() self.viewAreaLeftRightOffset = 0 self.viewAreaUpDownOffset = 0 #Connect the template midi input with api calls. #self.midiProcessor.notePrinter(True) self.midiProcessor.register_NoteOn(self.receiveNoteOn) #this also includes note off, which is 0x90 velocity 0 self.midiProcessor.register_NoteOff(self.receiveNoteOff) self.midiProcessor.register_CC(self.receiveCC) #api.callbacks.setCursor.append(self._setMidiThru) #When the track changes re-route cbox RT midi thru #api.callbacks._setCursor(destroySelection = False) #Force once to trigger a cursor export which calls our midi thru setter api.callbacks.patternChanged.append(self.callback_patternChanged) api.callbacks.stepChanged.append(self.callback_stepChanged) api.callbacks.removeStep.append(self.callback_removeStep) api.callbacks.currentTrackChanged.append(self.callback_currentTrackChanged) api.callbacks.subdivisionsChanged.append(self.callback_subdivisionsChanged) api.callbacks.exportCacheChanged.append(self.callback_cacheExportDict) api.callbacks.playbackStatusChanged.append(self.callback_playbackStatusChanged) api.callbacks.loopChanged.append(self.callback_loopChanged) #Prepare various patterns. #"clear music button leds" pattern: # Create a binary blob that contains the MIDI events pblob = bytes() for pitch in range(0,64): # note on pblob += cbox.Pattern.serialize_event(1, 0x90, pitch, 0) #tick in pattern, midi, pitch, velocity # Create a new pattern object using events from the blob self.resetMusicLEDPattern = cbox.Document.get_song().pattern_from_blob(pblob, 2) #2 ticks. # Clear all non-music buttons - pattern: # Create a binary blob that contains the MIDI events pblob = bytes() for pitch in range(64,72): # note on pblob += cbox.Pattern.serialize_event(1, 0x90, pitch, 0) #tick in pattern, midi, pitch, velocity # Create a new pattern object using events from the blob self.resetNonMusicLEDPattern_Horizontal = cbox.Document.get_song().pattern_from_blob(pblob, 2) #2 ticks. pblob = bytes() for pitch in range(82,90): # note on pblob += cbox.Pattern.serialize_event(1, 0x90, pitch, 0) #tick in pattern, midi, pitch, velocity # Create a new pattern object using events from the blob self.resetNonMusicLEDPattern_Vertical = cbox.Document.get_song().pattern_from_blob(pblob, 2) #2 ticks. #Create Midi Out and Connect logging.info(f"Creating APC midi output port") outportname = "APCmini Feedback" self.cboxMidiOutUuid = cbox.JackIO.create_midi_output(outportname) cbox.JackIO.rename_midi_output(self.cboxMidiOutUuid, outportname) self.outputScene = cbox.Document.get_engine().new_scene() self.outputScene.clear() self.outputScene.add_new_midi_layer(self.cboxMidiOutUuid) potentialHardwareOuts = set(cbox.JackIO.get_ports("APC MINI MIDI*", cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SINK | cbox.JackIO.PORT_IS_PHYSICAL)) for hp in potentialHardwareOuts: cbox.JackIO.port_connect(cbox.JackIO.status().client_name + ":" + outportname, hp) self.sendDefaultConfig() atexit.register(self.sendPatternClear) atexit.register(self.sendOtherButtonsClear) #We need the following three files for the interface, but they are not used in this file except switching on once. @property def midiInIsActive(self): try: return self.midiProcessor.active except AttributeError: #during startup return False def setMidiInputActive(self, state:bool): self.midiProcessor.active = state def toggleMidiIn(self): self.setMidiInputActive(not self.midiInIsActive) def sendPatternClear(self): """The APCmini has no "clear all" function. We need to loop over all buttons and send velocity 0. Here we only clear the pattern, not other status buttons.""" self.outputScene.play_pattern(self.resetMusicLEDPattern, 150.0) #150 tempo self.stepsNotAvailableBecauseOverlayedByFactor = set() def sendOtherButtonsClear(self): """There is a bug in the device. You cannot send the vertical control buttons at the same time as the other buttons. The device will lock up or ignore your buttons. We send in multiple passes.""" self.outputScene.play_pattern(self.resetNonMusicLEDPattern_Horizontal, 150.0) #150 tempo self.outputScene.play_pattern(self.resetNonMusicLEDPattern_Vertical, 150.0) #150 tempo def sendDefaultConfig(self): """Setup our default setup. This is not saved but restored on every program startup.""" pblob = bytes() pblob += cbox.Pattern.serialize_event(1, 0x90, APCmini_MAP_NOTES["mute"], 1) pblob += cbox.Pattern.serialize_event(1, 0x90, APCmini_MAP_NOTES["rec_arm"], 1) self.armRecord = True pat = cbox.Document.get_song().pattern_from_blob(pblob, 0) #0 ticks. self.outputScene.play_pattern(pat, 150.0) #150 tempo def sendCompleteState(self, velocities:list): """Reset the device entirely. Actively clear unused buttons, activate others. This is not only a last-resort function but useful for various scenarios. #BUG BUG BUG! We cannot send the buttons higher than 82 at the same time as the lower values. #Either only the lower part works or it shuts down completely and nothing happens. #This is a timing problem in the APCmini. We need to send it in multiple passes. """ #Here comes the C loop: pblobNotes = bytes() for i in range(64): pblobNotes += cbox.Pattern.serialize_event(1, 0x90, i, velocities[i] ) #tick in pattern, midi, pitch, velocity # Create a new pattern object using events from the blob patNotes = cbox.Document.get_song().pattern_from_blob(pblobNotes, 0) #0 ticks. self.outputScene.play_pattern(patNotes, 150.0) #150 tempo pblobHorizontalControls = bytes() for j in range(64, 72): pblobHorizontalControls += cbox.Pattern.serialize_event(1, 0x90, j, velocities[j] ) #tick in pattern, midi, pitch, velocity patHorizontalControls = cbox.Document.get_song().pattern_from_blob(pblobHorizontalControls, 0) #0 ticks. self.outputScene.play_pattern(patHorizontalControls, 150.0) #150 tempo pblobVerticalControls = bytes() for j in range(82, 90): pblobHorizontalControls += cbox.Pattern.serialize_event(1, 0x90, j, velocities[j] ) #tic-k in pattern, midi, pitch, velocity patVerticalControls = cbox.Document.get_song().pattern_from_blob(pblobVerticalControls, 0) #0 ticks. self.outputScene.play_pattern(patVerticalControls, 150.0) #150 tempo def sendApcNotePattern(self, trackExport:dict): """Convert the notes from a trackExport to an APC midi pattern and send it. This is the important "send the current state" function that makes the LEDs go bling! It takes into account our local offsets of the pattern grid because patroneo is n*n but APCmini is only 8x8 and must be shifted with it's button keys. trackExport["numberOfSteps"] is NOT the max index but how many pitches we have in a pattern. """ if not self.currentTrack: return if not trackExport["id"] == self.currentTrack["id"]: return if not self.mainStepMap_subdivisions: return self.sendPatternClear() pblobNotes = bytes() for stepDict in trackExport["pattern"]: startsAtBeginning, apcPitches = self.patroneoCoord2APCPitch(stepDict) if not apcPitches is None: #could be None, which is outside the current viewArea first = True and startsAtBeginning for apcPitch in apcPitches: #most of the time this is just len one. We draw factor > 1 as special yellow notes. if first: color = self.getColor(stepDict) #red or green for main beat or other beat. first = False else: color = APCmini_COLOR["yellow"] #for notes longer than one step / factor > 1 self.stepsNotAvailableBecauseOverlayedByFactor.add(apcPitch) try: pblobNotes += cbox.Pattern.serialize_event(1, 0x90, apcPitch, color ) #tick in pattern, midi, pitch, velocity except: logging.warning(f"Note out of range for APC Mini: {stepDict}. This is safe to ignore, especially if you don't use the device") pass # Create a new pattern object using events from the blob patNotes = cbox.Document.get_song().pattern_from_blob(pblobNotes, 0) #0 ticks. self.outputScene.play_pattern(patNotes, 150.0) #150 tempo def getColor(self, stepDict:dict): try: if self.mainStepMap_subdivisions[stepDict["index"]]: #list of bools. True if on a main beat. return APCmini_COLOR["red"] else: return APCmini_COLOR["green"] except IndexError: logging.error(f"mainStepMap is too short for index {stepDict['index']} to get color. Is: {self.mainStepMap_subdivisions}. Pattern length is {self.currentTrack['patternBaseLength']} with multiplier {self.currentTrack['patternLengthMultiplicator']} ") raise IndexError def patroneoCoord2APCPitch(self, stepDict:dict): """ Returns a list of midi pitches for the apcMini. Most of the time it is of length 1, just one note. If there is a note factor > 1 it will return more notes which will be rendered yellow LED. Or returns None if outside of the current view area. Patroneo 'Index' is the column, the time axis: X Patroneo 'Pitch' is the row, the tonal axis: Y The APC Mini has an 8x8 button matrix. It starts with pitch 56 in the top left, incrementing 57, 58, 59... to 63. Second row is lower! Matrix: 56 57 58 59 60 61 62 63 48 49 50 51 52 53 54 55 40 ... 32 24 16 8 0 So it is a simple 2d->1d transformation except we substract from 56. """ result = [] startsAtBeginning = True ra = int(stepDict["factor"]) if ra < stepDict["factor"]: ra += 1 #round up, but only for non-integer numbers. first = True for r in range(ra): #round up. Don't do half steps x = stepDict["index"] + r + self.viewAreaLeftRightOffset if x < 0 or x > 7: if first: startsAtBeginning = False first = False continue y = 56 - (stepDict["pitch"] + self.viewAreaUpDownOffset) * 8 if x+y > 63: if first: startsAtBeginning = False first = False continue first = False result.append(x+y) #assert x+y < 64, (x, y, x+y, stepDict) #If the index is > 7 the pattern has more than 8 columns. That is perfectly fine in Patroneo. #However we cannot display y+x > 63, after taking the arrow-shifting into consideration. #The user just clicked in a field that is outside of our current APC-Viewarea. if not result: return None,None else: #return y + x return startsAtBeginning,result def APCPitch2patroneoCoord(self, apcPitch:int): """apc pitch looks like a midi note from 0 (bottom left) to 63 (top right). See also docstring of patroneoCoord2APCPitch. We do a 1d->2d transformation for patroneo row/pitch and column/index Patroneo 'Index' is the column, the time axis: X Patroneo 'Pitch' is the row, the tonal axis: Y The rows begin at 0 at the top and increment down. The highest possible note in a pattern is pitch 0. The earlist one index 0. """ index = apcPitch % 8 - self.viewAreaLeftRightOffset #because the apcmini has a fixed width of 8 buttons pitch = 7 - int(apcPitch / 8) - self.viewAreaUpDownOffset return index, pitch #Receive midi from the device def receiveNoteOn(self, timestamp, channel, pitch, velocity): if pitch <= 63 and self.midiInIsActive: #actual musical notes patroneoIndex, patroneoPitch = self.APCPitch2patroneoCoord(pitch) #Is the scale 7 notes or less? Did we received the 8th pitch from the APC? inRange = patroneoPitch < self.currentTrack["numberOfSteps"]# and patroneoIndex < self.currentTrack["patternBaseLength"] * self.currentTrack["patternLengthMultiplicator"] if self.armRecord and inRange: d = self._pitchesToPatroneoCoordDict(self.activeNoteOns) if patroneoPitch in d: patroneoIndexOther, originalApcPitch = d[patroneoPitch] self.activeNoteOns.remove(originalApcPitch) difference = patroneoIndex - patroneoIndexOther if difference > 0: api.toggleStep(self.currentTrack["id"], patroneoIndexOther, patroneoPitch, difference+1, self.prevailingVelocity) else: api.toggleStep(self.currentTrack["id"], patroneoIndex, patroneoPitch, -1*(difference-1), self.prevailingVelocity) else: #Note will be activated on note off if not pitch in self.stepsNotAvailableBecauseOverlayedByFactor: self.activeNoteOns.add(pitch) #2d. contains index. if not self.mute: api.noteOn(self.currentTrack["id"], patroneoPitch) elif pitch == APCmini_MAP_NOTES["shift"]: self.shiftIsActive = True #The shift button has no color. We just remember the momentary state. elif pitch == APCmini_MAP_NOTES["rec_arm"]: self.armRecord = not self.armRecord if self.armRecord: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["rec_arm"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid) else: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["rec_arm"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) elif pitch == APCmini_MAP_NOTES["mute"]: self.mute = not self.mute if self.mute: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["mute"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid) else: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["mute"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) elif pitch == APCmini_MAP_NOTES["arrow_up"]: if self.shiftIsActive: api.currentTrackBy(self.currentTrack["id"], -1) else: self.viewAreaUpDown(+1) elif pitch == APCmini_MAP_NOTES["arrow_down"]: if self.shiftIsActive: api.currentTrackBy(self.currentTrack["id"], 1) else: self.viewAreaUpDown(-1) elif pitch == APCmini_MAP_NOTES["arrow_left"]: if self.shiftIsActive: api.seekMeasureLeft() else: self.viewAreaLeftRight(+1) #Yes, it is inverted scrolling elif pitch == APCmini_MAP_NOTES["arrow_right"]: if self.shiftIsActive: api.seekMeasureRight() else: self.viewAreaLeftRight(-1) elif pitch == APCmini_MAP_NOTES["unlabeled_upper"]: #Undo if self.shiftIsActive: #redo api.redo() else: api.undo() elif pitch == APCmini_MAP_NOTES["unlabeled_lower"]: #Reset View Area and Reset Pattern(!) with shift if self.shiftIsActive: #Clear current pattern: api.patternOffAllSteps(self.currentTrack["id"]) else: self.viewAreaUpDownOffset = 0 self.viewAreaLeftRightOffset = 0 self.sendApcNotePattern(self.currentTrack) cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_up"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_down"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_left"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_right"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) elif pitch == APCmini_MAP_NOTES["clip_stop"]: api.playPause() elif pitch == APCmini_MAP_NOTES["solo"]: api.toggleLoop() elif pitch == APCmini_MAP_NOTES["stop_all_clips"]: api.stop() api.rewind() else: logging.info(f"Unhandled APCmini noteOn. Pitch {pitch}, Velocity {velocity}") def _pitchesToPatroneoCoordDict(self, pitches:set): result = {} for pitch in pitches: patroneoIndex, patroneoPitch = self.APCPitch2patroneoCoord(pitch) result[patroneoPitch] = (patroneoIndex, pitch) return result def receiveNoteOff(self, timestamp, channel, pitch, velocity): if pitch <= 63 and self.midiInIsActive: #actual musical notes #if not self.mute: Potential of hanging notes. patroneoIndex, patroneoPitch = self.APCPitch2patroneoCoord(pitch) api.noteOff(self.currentTrack["id"], patroneoPitch) if pitch in self.activeNoteOns: #Just a single, standard step on/off factor = 1 api.toggleStep(self.currentTrack["id"], patroneoIndex, patroneoPitch, factor, self.prevailingVelocity) self.activeNoteOns.remove(pitch) #else: this is just midi hardware. Button was pressed while connecting and then released, or something like that. elif pitch == APCmini_MAP_NOTES["shift"]: self.shiftIsActive = False #The shift button has no color. We just remember the momentary state. else: logging.info(f"Unhandled APCmini noteOff. Pitch {pitch}, Velocity {velocity}") def receiveCC(self, timestamp, channel, ccnumber, value): if ccnumber == APCmini_MAP_CC["swing"]: if value == 0: api.setSwingPercent(0) else: v = int(value/127 * 100) api.setSwingPercent(v) elif ccnumber == APCmini_MAP_CC["volume"]: self.prevailingVelocity = value def chooseCurrentTrack(self, exportTrack): """This gets called either by the callback or by an APCmini button. The callback has a signal blocker that prevents recursive calls.""" if self.currentTrack and exportTrack["id"] == self.currentTrack["id"]: return #already current track self.currentTrack = exportTrack api.changeCurrentTrack(exportTrack["id"]) self.sendApcNotePattern(exportTrack) def viewAreaUpDown(self, value:int): if value == 0: return wasZero = self.viewAreaUpDownOffset == 0 self.viewAreaUpDownOffset += value if self.viewAreaUpDownOffset > 0: self.viewAreaUpDownOffset = 0 if wasZero: return #no visible change. User tried to go too far. elif value < 0 and abs(self.viewAreaUpDownOffset - 8) > self.currentTrack["numberOfSteps"]: self.viewAreaUpDownOffset -= value #take it back return # there is no need for further scrolling. if self.viewAreaUpDownOffset: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_up"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid) cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_down"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid) else: #Indicate that the origin is not 0,0 cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_up"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_down"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) self.sendApcNotePattern(self.currentTrack) def viewAreaLeftRight(self, value:int): if value == 0: return wasZero = self.viewAreaLeftRightOffset == 0 self.viewAreaLeftRightOffset += value if self.viewAreaLeftRightOffset > 0: self.viewAreaLeftRightOffset = 0 if wasZero: return #no visible change. User tried to go too far. elif value < 0 and abs(self.viewAreaLeftRightOffset - 8) > self.currentTrack["patternBaseLength"] * self.currentTrack["patternLengthMultiplicator"]: self.viewAreaLeftRightOffset -= value #take it back return # there is no need for further scrolling. if self.viewAreaLeftRightOffset: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_left"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid) cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_right"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid) else: #Indicate that the origin is not 0,0 cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_left"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) cbox.send_midi_event(0x90, APCmini_MAP_NOTES["arrow_right"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) self.sendApcNotePattern(self.currentTrack) #Receive from callbacks and send to the device to make its LED light up def callback_cacheExportDict(self, exportTrack:dict): if self.currentTrack and exportTrack["id"] == self.currentTrack["id"]: self.currentTrack = exportTrack def callback_patternChanged(self, exportTrack:dict): """We receive the whole track as exportDict. exportDict["pattern"] is the data structure example in the class docstring. This happens on track changes that are not pitch active/deactive. For example invert row, copy pattern etc. So: actual musical changes. We also receive this for every track, no matter if this our current working track. So we check if we are the current track. However, that prevents setting up or steps on a track change because during the track change the currenTrackId value is still the old track. There we introduce a force switch that enables self.guicallback_chooseCurrentTrack to trigger a redraw even during the track change. """ if self.mainStepMap_subdivisions: self.mainStepMap_subdivisions = [not stepNo % self._cacheSubdivisonValue for stepNo in range(self.currentTrack["patternBaseLength"] * self.currentTrack["patternLengthMultiplicator"]) ] if self.currentTrack and exportTrack["id"] == self.currentTrack["id"]: self.currentTrack = exportTrack if self.mainStepMap_subdivisions and self.currentTrack["patternBaseLength"] * self.currentTrack["patternLengthMultiplicator"] == len(self.mainStepMap_subdivisions): #If not the redraw command will arrive shortly through subdivisions callback self.sendApcNotePattern(exportTrack) def callback_stepChanged(self, stepDict): """Patroneo step activated or velocity changed. APC Color Mappings are velocity: 0 - off 01 - green 02 - green blink 03 - red 04 - red blink 05 - yellow 06 - yellow blink """ if self.currentTrack: startsAtBeginning, apcPitches = self.patroneoCoord2APCPitch(stepDict) if not apcPitches is None: #could be None, which is outside the current viewArea first = True and startsAtBeginning for apcPitch in apcPitches: #most of the time this is just len one. We draw factor > 1 as special yellow notes. if first: color = self.getColor(stepDict) #red or green for main beat or other beat. first = False else: color = APCmini_COLOR["yellow"] #for notes longer than one step / factor > 1 self.stepsNotAvailableBecauseOverlayedByFactor.add(apcPitch) cbox.send_midi_event(0x90, apcPitch, color, output=self.cboxMidiOutUuid) def callback_removeStep(self, stepDict): """LED off is note on 0x90 with velocity 0. See docstring for callbac_stepChanged""" startsAtBeginning, apcPitches = self.patroneoCoord2APCPitch(stepDict) if not apcPitches is None: #could be None, which is outside the current viewArea first = True and startsAtBeginning for apcPitch in apcPitches: #most of the time this is just len one. We draw factor > 1 as special yellow notes. cbox.send_midi_event(0x90, apcPitch, 0, output=self.cboxMidiOutUuid) if apcPitch in self.stepsNotAvailableBecauseOverlayedByFactor: self.stepsNotAvailableBecauseOverlayedByFactor.remove(apcPitch) def callback_currentTrackChanged(self, exportTrack:dict): if self._blockCurrentTrackSignal: return self._blockCurrentTrackSignal = True self.chooseCurrentTrack(exportTrack) self._blockCurrentTrackSignal = False def callback_subdivisionsChanged(self, subdivisions): """On program start and load and on every change.""" if not self.currentTrack: return #program start if self._cacheSubdivisonValue == subdivisions: return self._cacheSubdivisonValue = subdivisions self.mainStepMap_subdivisions = [not stepNo % subdivisions for stepNo in range(self.currentTrack["patternBaseLength"] * self.currentTrack["patternLengthMultiplicator"]) ] self.sendApcNotePattern(self.currentTrack) def callback_playbackStatusChanged(self, status:bool): if status: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["clip_stop"], APCmini_COLOR["green_blink"], output=self.cboxMidiOutUuid) else: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["clip_stop"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) def callback_loopChanged(self, measureNumber:int): if measureNumber is None: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["solo"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) else: cbox.send_midi_event(0x90, APCmini_MAP_NOTES["solo"], APCmini_COLOR["green_blink"], output=self.cboxMidiOutUuid) apcMiniInput = ApcMiniInput() #global to use in other parts of the program