You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

674 lines
31 KiB

#! /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 <http://www.gnu.org/licenses/>.
"""
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)
pblobNotes += cbox.Pattern.serialize_event(1, 0x90, apcPitch, color ) #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
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