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.
678 lines
32 KiB
678 lines
32 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)
|
|
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
|
|
|