Browse Source

Support factor notes on the apcmini

master
Nils 3 years ago
parent
commit
344a653ad6
  1. 20
      engine/api.py
  2. 166
      engine/input_apcmini.py

20
engine/api.py

@ -196,11 +196,11 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
func(stepDict) func(stepDict)
callbacks._dataChanged() callbacks._dataChanged()
def _removeStep(self, track, index, pitch): def _removeStep(self, track, index, pitch, factor):
"""Opposite of _stepChanged""" """Opposite of _stepChanged"""
self._exportCacheChanged(track) self._exportCacheChanged(track)
stepDict = {"index": index, stepDict = {"index": index,
"factor": 1, #wrong, but step off doesn't matter. "factor": factor,
"pitch": pitch, "pitch": pitch,
"velocity": 0, #it is off. "velocity": 0, #it is off.
} }
@ -615,12 +615,20 @@ def currentTrackBy(currentTrackId, value:int):
currentTrack = session.data.trackById(currentTrackId) currentTrack = session.data.trackById(currentTrackId)
assert currentTrack, currentTrackId assert currentTrack, currentTrackId
currentIndex = session.data.tracks.index(currentTrack) onlyVisibleTracks = [track for track in session.data.tracks if track.visible]
if not onlyVisibleTracks: return; #all tracks are hidden
if not currentTrack in onlyVisibleTracks:
changeCurrentTrack(id(onlyVisibleTracks[0]))
return #an impossible situation.
currentIndex = onlyVisibleTracks.index(currentTrack)
if value == -1 and currentIndex == 0: return #already first track if value == -1 and currentIndex == 0: return #already first track
elif value == 1 and len(session.data.tracks) == currentIndex+1: return #already last track elif value == 1 and len(onlyVisibleTracks) == currentIndex+1: return #already last track
newCurrentTrack = session.data.tracks[currentIndex+value] newCurrentTrack = onlyVisibleTracks[currentIndex+value]
changeCurrentTrack(id(newCurrentTrack)) changeCurrentTrack(id(newCurrentTrack))
def addTrack(scale=None): def addTrack(scale=None):
@ -1364,7 +1372,7 @@ def removeStep(trackId, index, pitch):
track.pattern.buildExportCache() track.pattern.buildExportCache()
track.buildTrack() track.buildTrack()
updatePlayback() updatePlayback()
callbacks._removeStep(track, index, pitch) callbacks._removeStep(track, index, pitch, oldNote["factor"])
def toggleStep(trackId, index, pitch, factor=1, velocity=None): def toggleStep(trackId, index, pitch, factor=1, velocity=None):
"""Checks the current state of a step and decides if on or off. """Checks the current state of a step and decides if on or off.

166
engine/input_apcmini.py

@ -130,6 +130,9 @@ class ApcMiniInput(MidiInput):
self.armRecord = None #Toggle self.armRecord = None #Toggle
self.mute = None #Make music when you press buttons self.mute = None #Make music when you press buttons
self.activeNoteOns = set() # midiPitchInt
self.stepsNotAvailableBecauseOverlayedByFactor = set()
self.viewAreaLeftRightOffset = 0 self.viewAreaLeftRightOffset = 0
self.viewAreaUpDownOffset = 0 self.viewAreaUpDownOffset = 0
@ -213,6 +216,7 @@ class ApcMiniInput(MidiInput):
velocity 0. velocity 0.
Here we only clear the pattern, not other status buttons.""" Here we only clear the pattern, not other status buttons."""
self.outputScene.play_pattern(self.resetMusicLEDPattern, 150.0) #150 tempo self.outputScene.play_pattern(self.resetMusicLEDPattern, 150.0) #150 tempo
self.stepsNotAvailableBecauseOverlayedByFactor = set()
def sendOtherButtonsClear(self): def sendOtherButtonsClear(self):
"""There is a bug in the device. You cannot send the vertical control buttons at the same """There is a bug in the device. You cannot send the vertical control buttons at the same
@ -264,12 +268,12 @@ class ApcMiniInput(MidiInput):
def sendApcNotePattern(self, trackExport:dict): def sendApcNotePattern(self, trackExport:dict):
"""Convert the notes from a trackExport to an APC midi pattern and send it. """Convert the notes from a trackExport to an APC midi pattern and send it.
This is the important "send the current state" function. 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 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. 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. trackExport["numberOfSteps"] is NOT the max index but how many pitches we have in a pattern.
""" """
if not self.currentTrack: return if not self.currentTrack: return
if not trackExport["id"] == self.currentTrack["id"]: return if not trackExport["id"] == self.currentTrack["id"]: return
@ -279,9 +283,18 @@ class ApcMiniInput(MidiInput):
pblobNotes = bytes() pblobNotes = bytes()
for stepDict in trackExport["pattern"]: for stepDict in trackExport["pattern"]:
apcPitch = self.patroneoCoord2APCPitch(stepDict)
if not apcPitch is None: #outside the current viewArea? startsAtBeginning, apcPitches = self.patroneoCoord2APCPitch(stepDict)
pblobNotes += cbox.Pattern.serialize_event(1, 0x90, apcPitch, self.getColor(stepDict) ) #tick in pattern, midi, pitch, velocity 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 # Create a new pattern object using events from the blob
patNotes = cbox.Document.get_song().pattern_from_blob(pblobNotes, 0) #0 ticks. patNotes = cbox.Document.get_song().pattern_from_blob(pblobNotes, 0) #0 ticks.
self.outputScene.play_pattern(patNotes, 150.0) #150 tempo self.outputScene.play_pattern(patNotes, 150.0) #150 tempo
@ -297,8 +310,15 @@ class ApcMiniInput(MidiInput):
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']} ") 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 raise IndexError
def patroneoCoord2APCPitch(self, stepDict:dict)->int: def patroneoCoord2APCPitch(self, stepDict:dict):
"""Patroneo 'Index' is the column, the time axis: X """
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 Patroneo 'Pitch' is the row, the tonal axis: Y
The APC Mini has an 8x8 button matrix. The APC Mini has an 8x8 button matrix.
@ -313,23 +333,42 @@ class ApcMiniInput(MidiInput):
16 16
8 8
0 0
So it is a simple 2d->1d transformation except we substract from 56. So it is a simple 2d->1d transformation except we substract from 56.
""" """
result = []
x = stepDict["index"] + self.viewAreaLeftRightOffset
if x < 0 or x > 7: return None startsAtBeginning = True
y = 56 - (stepDict["pitch"] + self.viewAreaUpDownOffset) * 8 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) #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. #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. #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. #The user just clicked in a field that is outside of our current APC-Viewarea.
if x+y > 63: if not result:
return None return None,None
else: else:
return y + x #return y + x
return startsAtBeginning,result
def APCPitch2patroneoCoord(self, apcPitch:int): def APCPitch2patroneoCoord(self, apcPitch:int):
"""apc pitch looks like a midi note from 0 (bottom left) to 63 (top right). """apc pitch looks like a midi note from 0 (bottom left) to 63 (top right).
@ -352,12 +391,24 @@ class ApcMiniInput(MidiInput):
#Receive midi from the device #Receive midi from the device
def receiveNoteOn(self, timestamp, channel, pitch, velocity): def receiveNoteOn(self, timestamp, channel, pitch, velocity):
if pitch <= 63 and self.midiInIsActive: #actual musical notes if pitch <= 63 and self.midiInIsActive: #actual musical notes
factor = 1
patroneoIndex, patroneoPitch = self.APCPitch2patroneoCoord(pitch) patroneoIndex, patroneoPitch = self.APCPitch2patroneoCoord(pitch)
#Is the scale 7 notes or less? Did we received the 8th pitch from the APC? #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"] inRange = patroneoPitch < self.currentTrack["numberOfSteps"]# and patroneoIndex < self.currentTrack["patternBaseLength"] * self.currentTrack["patternLengthMultiplicator"]
if self.armRecord and inRange: if self.armRecord and inRange:
api.toggleStep(self.currentTrack["id"], patroneoIndex, patroneoPitch, factor, self.prevailingVelocity) 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: if not self.mute:
api.noteOn(self.currentTrack["id"], patroneoPitch) api.noteOn(self.currentTrack["id"], patroneoPitch)
elif pitch == APCmini_MAP["shift"]: elif pitch == APCmini_MAP["shift"]:
@ -403,7 +454,6 @@ class ApcMiniInput(MidiInput):
else: else:
api.undo() api.undo()
elif pitch == APCmini_MAP["unlabeled_lower"]: #Reset View Area and Reset Pattern(!) with shift elif pitch == APCmini_MAP["unlabeled_lower"]: #Reset View Area and Reset Pattern(!) with shift
if self.shiftIsActive: #Clear current pattern: if self.shiftIsActive: #Clear current pattern:
api.patternOffAllSteps(self.currentTrack["id"]) api.patternOffAllSteps(self.currentTrack["id"])
@ -426,6 +476,39 @@ class ApcMiniInput(MidiInput):
else: else:
logging.info(f"Unhandled APCmini noteOn. Pitch {pitch}, Velocity {velocity}") 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["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)
def viewAreaUpDown(self, value:int): def viewAreaUpDown(self, value:int):
if value == 0: return if value == 0: return
wasZero = self.viewAreaUpDownOffset == 0 wasZero = self.viewAreaUpDownOffset == 0
@ -468,26 +551,6 @@ class ApcMiniInput(MidiInput):
self.sendApcNotePattern(self.currentTrack) 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 #Receive from callbacks and send to the device to make its LED light up
def callback_cacheExportDict(self, exportTrack:dict): def callback_cacheExportDict(self, exportTrack:dict):
@ -529,16 +592,29 @@ class ApcMiniInput(MidiInput):
06 - yellow blink 06 - yellow blink
""" """
if self.currentTrack: if self.currentTrack:
apcPitch = self.patroneoCoord2APCPitch(stepDict) startsAtBeginning, apcPitches = self.patroneoCoord2APCPitch(stepDict)
if not apcPitch is None: #could be None, which is outside the current viewArea if not apcPitches is None: #could be None, which is outside the current viewArea
cbox.send_midi_event(0x90, apcPitch, self.getColor(stepDict), output=self.cboxMidiOutUuid) 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): def callback_removeStep(self, stepDict):
"""LED off is note on 0x90 with velocity 0. """LED off is note on 0x90 with velocity 0.
See docstring for callbac_stepChanged""" See docstring for callbac_stepChanged"""
apcPitch = self.patroneoCoord2APCPitch(stepDict) startsAtBeginning, apcPitches = self.patroneoCoord2APCPitch(stepDict)
if not apcPitch is None: #could be None, which is outside the current viewArea if not apcPitches is None: #could be None, which is outside the current viewArea
cbox.send_midi_event(0x90, apcPitch, 0, output=self.cboxMidiOutUuid) 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): def callback_currentTrackChanged(self, exportTrack:dict):
if self._blockCurrentTrackSignal: if self._blockCurrentTrackSignal:

Loading…
Cancel
Save