Browse Source

Initial support for the AKAI APCmini hardware controller

master
Nils 2 years ago
parent
commit
ebc1a61f85
  1. 99
      engine/api.py
  2. 519
      engine/input_apcmini.py
  3. 6
      engine/track.py
  4. 21
      qtgui/mainwindow.py
  5. 26
      qtgui/pattern_grid.py
  6. 10
      template/engine/input_midi.py

99
engine/api.py

@ -34,6 +34,9 @@ from template.engine.api import *
from template.engine.duration import baseDurationToTraditionalNumber
from template.helper import compress
#Our Modules
from engine.input_apcmini import apcMiniInput
DEFAULT_FACTOR = 1 #for the GUI.
@ -77,6 +80,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
self.patternLengthMultiplicatorChanged = []
self.swingChanged = []
self.swingPercentChanged = []
self.currentTrackChanged = []
def _quarterNotesPerMinuteChanged(self):
"""There is one tempo for the entire song in quarter notes per mintue.
@ -118,6 +122,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
self._patternChanged(track)
self._subdivisionsChanged() #update subdivisions. We do not include them in the score or track export on purpose.
callbacks._dataChanged()
def _subdivisionsChanged(self):
@ -194,8 +199,13 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
def _removeStep(self, track, index, pitch):
"""Opposite of _stepChanged"""
self._exportCacheChanged(track)
for func in self.stepChanged:
func(index, pitch)
stepDict = {"index": index,
"factor": 1, #wrong, but step off doesn't matter.
"pitch": pitch,
"velocity": 0, #it is off.
}
for func in self.removeStep:
func(stepDict)
callbacks._dataChanged()
def _trackStructureChanged(self, track):
@ -227,6 +237,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
for func in self.patternLengthMultiplicatorChanged:
func(export)
self._patternChanged(track) #includes dataChanged
self._subdivisionsChanged()
callbacks._dataChanged()
def _swingChanged(self):
@ -239,6 +250,23 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
func(_swingToPercent_Table[export])
callbacks._dataChanged()
def _currentTrackChanged(self, track):
"""The engine has no concept of a current track. This is a callback to sync different
GUIs and midi controllers that use the system of a current track.
Therefore this callback will never get called on engine changes.
We send this once after load (always track 0) and then non-engine is responsible for calling
the api function api.changeCurrentTrack(trackId)
"""
export = track.export()
for func in self.currentTrackChanged:
func(export)
#no data changed.
#Inject our derived Callbacks into the parent module
template.engine.api.callbacks = ClientCallbacks()
from template.engine.api import callbacks
@ -254,10 +282,16 @@ def startEngine(nsmClient):
session.inLoopMode = None # remember if we are in loop mode or not. Positive value is None or a tuple with start and end
#Activate apc mini controller
apcMiniInput.start()
apcMiniInput.setMidiInputActive(True) #MidiInput is just the general "activate processing"
#Send the current track, to at least tell apcMini where we are.
changeCurrentTrack(id(session.data.tracks[0])) # See callback docstring. This is purely for GUI and midi-controller convenience. No engine data is touched.
#Send initial Callbacks to create the first GUI state.
#The order of initial callbacks must not change to avoid GUI problems.
#For example it is important that the tracks get created first and only then the number of measures
logger.info("Sending initial callbacks to GUI")
logger.info("Sending initial callbacks to GUI and APCmini")
callbacks._numberOfTracksChanged()
callbacks._timeSignatureChanged()
callbacks._numberOfMeasuresChanged()
@ -273,6 +307,7 @@ def startEngine(nsmClient):
session.data.buildAllTracks(buildSongDuration=True) #will set to max track length, we always have a song duration.
updatePlayback()
logger.info("Patroneo api startEngine complete")
@ -430,12 +465,12 @@ def convert_subdivisions(value, errorHandling):
oldValue = session.data.subdivisions
result = session.data.convertSubdivisions(value, errorHandling)
if result: #bool for success
session.history.register(lambda v=oldValue: convert_subdivisions(v, "delete"), descriptionString="Convert Grouping") #the error handling = delete should not matter at all. We are always in a position where this is possible because we just converted to the current state.
session.history.register(lambda v=oldValue: convert_subdivisions(v, "delete"), descriptionString="Convert Grouping") #the error handling = delete should not matter at all. We are always in a position where this is possible because we just converted to the current state from a valid one.
session.data.buildAllTracks()
updatePlayback()
callbacks._timeSignatureChanged() #includes subdivisions
for tr in session.data.tracks:
callbacks._patternChanged(tr)
callbacks._timeSignatureChanged() #includes pattern changed for all tracks and subdivisions
#for tr in session.data.tracks:
# callbacks._patternChanged(tr)
else:
callbacks._subdivisionsChanged() #to reset the GUI value back to the working one.
if session.inLoopMode:
@ -547,7 +582,12 @@ def changeTrackRepeatDiminishedPatternInItself(trackId, newState:bool):
callbacks._trackMetaDataChanged(track)
#But not the actual callback because nothing in a gui needs redrawing. Everything looks the same.
def changeCurrentTrack(trackId):
"""This is for communication between the GUI and APCmini controller. The engine has no concept
of a current track."""
track = session.data.trackById(trackId)
assert track
callbacks._currentTrackChanged(track)
def addTrack(scale=None):
if scale:
@ -1249,10 +1289,19 @@ def setStep(trackId, stepExportDict):
already changed the step on their side. Only useful for parallel
views.
format: {'index': 0, 'pitch': 7, 'factor': 1, 'velocity': 90}
This is also for velocity!
This function checks if the new step is within the limits of the current sounding pattern
and will prevent changes or additions outside the current limits.
"""
track = session.data.trackById(trackId)
if not track: return
inRange = stepExportDict["index"] < session.data.howManyUnits and stepExportDict["pitch"] < track.pattern.numberOfSteps
if not inRange: return
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Change Step"), descriptionString="Change Step")
oldNote = track.pattern.stepByIndexAndPitch(index=stepExportDict["index"], pitch=stepExportDict["pitch"])
if oldNote: #modify existing note
@ -1271,6 +1320,10 @@ def removeStep(trackId, index, pitch):
"""Reverse of setStep. e.g. GUI-Click on an existing step."""
track = session.data.trackById(trackId)
if not track: return
inRange = index < session.data.howManyUnits and pitch < track.pattern.numberOfSteps
if not inRange: return
session.history.register(lambda trId=trackId, v=track.pattern.copyData(): setPattern(trId, v, "Remove Step"), descriptionString="Remove Step")
oldNote = track.pattern.stepByIndexAndPitch(index, pitch)
track.pattern.data.remove(oldNote)
@ -1279,6 +1332,21 @@ def removeStep(trackId, index, pitch):
updatePlayback()
callbacks._removeStep(track, index, pitch)
def toggleStep(trackId, index, pitch, factor=1, velocity=None):
"""Checks the current state of a step and decides if on or off.
Toggled Notes have average velocity and factor 1. If you need more fine control use setStep
directly"""
track = session.data.trackById(trackId)
if not track: return
maybeNote = track.pattern.stepByIndexAndPitch(index, pitch)
if maybeNote is None:
if velocity is None:
velocity = getAverageVelocity(trackId)
setStep(trackId, {'index': index, 'pitch': pitch, 'factor': factor, 'velocity': velocity})
else:
removeStep(trackId, index, pitch)
def setScale(trackId, scale, callback = True):
"""Expects a scale list or tuple from lowest index to highest.
Actual pitches don't matter."""
@ -1536,12 +1604,17 @@ def resizePatternWithoutScale(trackId, steps):
def noteOn(trackId, row):
track = session.data.trackById(trackId)
if not track: return
midipitch = track.pattern.scale[row]
cbox.send_midi_event(0x90+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutAbstraction)
try:
midipitch = track.pattern.scale[row]
cbox.send_midi_event(0x90+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutAbstraction)
except IndexError: #We got a note that is not in the current scale and pattern. E.g. because numberOfSteps is 7 but we received 8.
pass
def noteOff(trackId, row):
track = session.data.trackById(trackId)
if not track: return
midipitch = track.pattern.scale[row]
cbox.send_midi_event(0x80+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutAbstraction)
try:
midipitch = track.pattern.scale[row]
cbox.send_midi_event(0x80+track.midiChannel, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutAbstraction)
except IndexError: #We got a note that is not in the current scale and pattern. E.g. because numberOfSteps is 7 but we received 8.
pass

519
engine/input_apcmini.py

@ -0,0 +1,519 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2021, 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
#Third Party Modules
from calfbox import cbox
#Template Modules
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 = {
64: "arrow_up", #Move the pattern-viewing area
65: "arrow_down", #Move the pattern-viewing area
66: "arrow_left", #Move the pattern-viewing area
67: "arrow_right", #Move the pattern-viewing area
#Fader Controls
68: "volume",
69: "pan",
70: "send",
71: "device",
#Scene Launch
82: "clip_stop",
83: "solo",
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_1",
88: "unlabeled_2",
89: "stop_all_clips",
98: "shift", #This is just a button. There is no shift layer.
}
#Also put in the reverse:
APCmini_MAP.update({value:key for key,value in APCmini_MAP.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
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 = None # defaults to average track velocity. But can also be set by the hardware faders
self.shiftIsActive = None #For shift momentary press
self.armRecord = None #Toggle
self.mute = None #Make music when you press buttons
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)
#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)
#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
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["mute"], 1)
pblob += cbox.Pattern.serialize_event(1, 0x90, APCmini_MAP["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.
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 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"]:
apcPitch = self.patroneoCoord2APCPitch(stepDict)
if not apcPitch is None: #outside the current viewArea?
pblobNotes += cbox.Pattern.serialize_event(1, 0x90, apcPitch, self.getColor(stepDict) ) #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)->int:
"""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.
"""
x = stepDict["index"] + self.viewAreaLeftRightOffset
if x < 0 or x > 7: return None
y = 56 - (stepDict["pitch"] + self.viewAreaUpDownOffset) * 8
#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 x+y > 63:
return None
else:
return y + x
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
factor = 1
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:
api.toggleStep(self.currentTrack["id"], patroneoIndex, patroneoPitch, factor, self.prevailingVelocity)
if not self.mute:
api.noteOn(self.currentTrack["id"], patroneoPitch)
elif pitch == APCmini_MAP["shift"]:
self.shiftIsActive = True #The shift button has no color. We just remember the momentary state.
elif pitch == APCmini_MAP["rec_arm"]:
self.armRecord = not self.armRecord
if self.armRecord:
cbox.send_midi_event(0x90, APCmini_MAP["rec_arm"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid)
else:
cbox.send_midi_event(0x90, APCmini_MAP["rec_arm"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid)
elif pitch == APCmini_MAP["mute"]:
self.mute = not self.mute
if self.mute:
cbox.send_midi_event(0x90, APCmini_MAP["mute"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid)
else:
cbox.send_midi_event(0x90, APCmini_MAP["mute"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid)
elif pitch == APCmini_MAP["arrow_up"]:
self.viewAreaUpDown(+1)
elif pitch == APCmini_MAP["arrow_down"]:
self.viewAreaUpDown(-1)
elif pitch == APCmini_MAP["arrow_left"]:
self.viewAreaLeftRight(+1) #Yes, it is inverted scrolling
elif pitch == APCmini_MAP["arrow_right"]:
self.viewAreaLeftRight(-1)
else:
logging.info(f"Unhandled APCmini noteOn. Pitch {pitch}, Velocity {velocity}")
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 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["arrow_up"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid)
cbox.send_midi_event(0x90, APCmini_MAP["arrow_down"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid)
else: #Indicate that the origin is not 0,0
cbox.send_midi_event(0x90, APCmini_MAP["arrow_up"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid)
cbox.send_midi_event(0x90, APCmini_MAP["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 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["arrow_left"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid)
cbox.send_midi_event(0x90, APCmini_MAP["arrow_right"], APCmini_COLOR["green"], output=self.cboxMidiOutUuid)
else: #Indicate that the origin is not 0,0
cbox.send_midi_event(0x90, APCmini_MAP["arrow_left"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid)
cbox.send_midi_event(0x90, APCmini_MAP["arrow_right"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid)
self.sendApcNotePattern(self.currentTrack)
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)
elif pitch == APCmini_MAP["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 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.prevailingVelocity = self.currentTrack["averageVelocity"]
self.sendApcNotePattern(exportTrack)
#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:
apcPitch = self.patroneoCoord2APCPitch(stepDict)
if not apcPitch is None: #could be None, which is outside the current viewArea
cbox.send_midi_event(0x90, apcPitch, self.getColor(stepDict), output=self.cboxMidiOutUuid)
def callback_removeStep(self, stepDict):
"""LED off is note on 0x90 with velocity 0.
See docstring for callbac_stepChanged"""
apcPitch = self.patroneoCoord2APCPitch(stepDict)
if not apcPitch is None: #could be None, which is outside the current viewArea
cbox.send_midi_event(0x90, apcPitch, 0, output=self.cboxMidiOutUuid)
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)
apcMiniInput = ApcMiniInput() #global to use in other parts of the program

6
engine/track.py

@ -227,13 +227,15 @@ class Track(object): #injection at the bottom of this file!
return {
"id" : id(self),
"sequencerInterface" : self.sequencerInterface.export(),
"realCboxMidiOutUuid" : self.cboxMidiOutAbstraction,
"color" : self.color,
"structure" : sorted(self.structure),
"patternBaseLength" : self.parentData.howManyUnits, #for convenient access
"patternBaseLength" : self.parentData.howManyUnits, #for convenient access. How many steps are on the X axis?
"patternLengthMultiplicator" : self.patternLengthMultiplicator, #int
"pattern": self.pattern.exportCache,
"scale": self.pattern.scale,
"numberOfSteps": self.pattern.numberOfSteps, #pitches. Convenience for len(scale)
"averageVelocity": self.pattern.averageVelocity,
"numberOfSteps": self.pattern.numberOfSteps, #pitches. Convenience for len(scale). How many steps are on the Y axis?
"simpleNoteNames": self.pattern.simpleNoteNames,
"numberOfMeasures": self.parentData.numberOfMeasures,
"whichPatternsAreScaleTransposed": self.whichPatternsAreScaleTransposed,

21
qtgui/mainwindow.py

@ -168,6 +168,9 @@ class MainWindow(TemplateMainWindow):
#self.ui.centralwidget.addAction(self.ui.actionToStart) #no action without connection to a widget.
#self.ui.actionToStart.triggered.connect(self.ui.toStartButton.click)
self._blockCurrentTrackSignal = False # to prevent recursive calls
self.currentTrackId = None #this is purely a GUI construct. the engine does not know a current track. On startup there is no active track
##Song Editor
@ -221,7 +224,9 @@ class MainWindow(TemplateMainWindow):
self.start() #This shows the GUI, or not, depends on the NSM gui save setting. We need to call that after the menu, otherwise the about dialog will block and then we get new menu entries, which looks strange.
#There is always a track. Forcing that to be active is better than having to hide all the pattern widgets, or to disable them.
#However, we need the engine to be ready.
self.chooseCurrentTrack(api.session.data.tracks[0].export()) #By Grabthar's hammer, by the suns of Worvan, what a hack! #TODO: Access to the sessions data structure directly instead of api. Not good. Getter function or api @property is cleaner.
#Now in 2021-12-13 this is still necessary because of regressions! Eventhough the API sends a current track on startup as convenience for the GUI and hardware controllers
self.chooseCurrentTrack(api.session.data.tracks[0].export()) #By Grabthar's hammer, by the suns of Worvan, what a hack!
api.callbacks.currentTrackChanged.append(self.chooseCurrentTrack)
self.ui.gridView.horizontalScrollBar().setSliderPosition(0)
self.ui.gridView.verticalScrollBar().setSliderPosition(0)
@ -248,7 +253,14 @@ class MainWindow(TemplateMainWindow):
2 years later and I don't understand the docstring anymore.
In 2021 this changed. The api now knows the current track to communicate with hardware
button pad controllers like the APCmini.
"""
if self._blockCurrentTrackSignal:
return
self._blockCurrentTrackSignal = True
newCurrentTrackId = exportDict["id"]
if self.currentTrackId == newCurrentTrackId:
@ -260,7 +272,8 @@ class MainWindow(TemplateMainWindow):
d[self.currentTrackId].mark(False)
except KeyError: #track was deleted or never existed (load empty file)
pass
d[newCurrentTrackId].mark(True) #New one as active
d[newCurrentTrackId].mark(True) #New one as active
self.patternGrid.guicallback_chooseCurrentTrack(exportDict, newCurrentTrackId)
self.transposeControls.guicallback_chooseCurrentTrack(exportDict, newCurrentTrackId)
@ -269,6 +282,10 @@ class MainWindow(TemplateMainWindow):
#Functions depend on getting set after getting called. They need to know the old track!
self.currentTrackId = newCurrentTrackId
api.changeCurrentTrack(newCurrentTrackId)
self._blockCurrentTrackSignal = False
def addPatternTrack(self):
"""Add a new track and initialize it with some data from the current one"""
scale = api.session.data.trackById(self.currentTrackId).pattern.scale #TODO: Access to the sessions data structure directly instead of api. Not good. Getter function or api @property is cleaner.

26
qtgui/pattern_grid.py

@ -98,7 +98,8 @@ class PatternGrid(QtWidgets.QGraphicsScene):
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
api.callbacks.subdivisionsChanged.append(self.guicallback_subdivisionsChanged)
api.callbacks.patternLengthMultiplicatorChanged.append(self.callback_patternLengthMultiplicatorChanged)
api.callbacks.stepChanged.append(self.callback_stepChanged)
api.callbacks.removeStep.append(self.callback_removeChanged)
def callback_patternLengthMultiplicatorChanged(self, exportDict):
self._tracks[exportDict["id"]] = exportDict
@ -114,6 +115,29 @@ class PatternGrid(QtWidgets.QGraphicsScene):
self.ticksToPixelRatio = typeInTicks / SIZE_UNIT
self._redrawSteps(howMany)
def callback_stepChanged(self, stepDict:dict):
"""This callback reaction was introduced after support for the APCMini.
Before that we were the only ones to set and remove individual steps
(and not the entire pattern)
stepDict is {'index': 0, 'pitch': 0, 'factor': 1, 'velocity': 90}
"""
if (stepDict["index"], stepDict["pitch"]) in self._steps:
guiStep = self._steps[stepDict["index"], stepDict["pitch"]]
guiStep.on(velocityAndFactor = (stepDict["velocity"], stepDict["factor"]))
def callback_removeChanged(self, stepDict:dict):
"""This callback reaction was introduced after support for the APCMini.
Before that we were the only ones to set and remove individual steps
(and not the entire pattern)
stepDict is {'index': 0, 'pitch': 0, 'factor': 1, 'velocity': 90}
but factor and velocity don't matter"""
if (stepDict["index"], stepDict["pitch"]) in self._steps:
guiStep = self._steps[stepDict["index"], stepDict["pitch"]]
guiStep.off()
def _redrawSteps(self, howMany, forceId=None):
"""Draw the empty steps grid. This only happens if the pattern itself changes,
for example with the time signature or with a GUI subdivision change.

10
template/engine/input_midi.py

@ -101,6 +101,14 @@ class MidiInput(object):
raise ValueError("Channels are from 1 to 16 (inclusive). You sent " + str(channel))
self.realtimeMidiThroughLayer.set_out_channel(channel)
def connectToHardware(self, portPattern:str):
if not portPattern:
portPattern = ".*"
hardwareMidiPorts = set(cbox.JackIO.get_ports(portPattern, cbox.JackIO.MIDI_TYPE, cbox.JackIO.PORT_IS_SOURCE | cbox.JackIO.PORT_IS_PHYSICAL))
for hp in hardwareMidiPorts:
cbox.JackIO.port_connect(hp, cbox.JackIO.status().client_name + ":" + self.portName)
class MidiProcessor(object):
"""
@ -245,7 +253,7 @@ class MidiProcessor(object):
def notePrinter(self, state:bool):
if state:
def _printer(timestamp, channel, note, velocity):
print(f"[{timestamp}] Chan: {channel} Note: {pitch.midi_notenames_english[note]}: Vel: {velocity}")
print(f"[{timestamp}] Chan: {channel} Note: {note} -> {pitch.midi_notenames_english[note]}: Vel: {velocity}")
self.callbacks3[(MidiProcessor.SIMPLE_EVENT, MidiProcessor.M_NOTE_ON)] = _printer
else:
try:

Loading…
Cancel
Save