Browse Source

Support factor notes on the apcmini

master
Nils 12 months 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)
callbacks._dataChanged()
def _removeStep(self, track, index, pitch):
def _removeStep(self, track, index, pitch, factor):
"""Opposite of _stepChanged"""
self._exportCacheChanged(track)
stepDict = {"index": index,
"factor": 1, #wrong, but step off doesn't matter.
"factor": factor,
"pitch": pitch,
"velocity": 0, #it is off.
}
@ -615,12 +615,20 @@ def currentTrackBy(currentTrackId, value:int):
currentTrack = session.data.trackById(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
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))
def addTrack(scale=None):
@ -1364,7 +1372,7 @@ def removeStep(trackId, index, pitch):
track.pattern.buildExportCache()
track.buildTrack()
updatePlayback()
callbacks._removeStep(track, index, pitch)
callbacks._removeStep(track, index, pitch, oldNote["factor"])
def toggleStep(trackId, index, pitch, factor=1, velocity=None):
"""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.mute = None #Make music when you press buttons
self.activeNoteOns = set() # midiPitchInt
self.stepsNotAvailableBecauseOverlayedByFactor = set()
self.viewAreaLeftRightOffset = 0
self.viewAreaUpDownOffset = 0
@ -213,6 +216,7 @@ class ApcMiniInput(MidiInput):
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
@ -264,12 +268,12 @@ class ApcMiniInput(MidiInput):
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.
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 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 trackExport["id"] == self.currentTrack["id"]: return
@ -279,9 +283,18 @@ class ApcMiniInput(MidiInput):
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
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
@ -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']} ")
raise IndexError
def patroneoCoord2APCPitch(self, stepDict:dict)->int:
"""Patroneo 'Index' is the column, the time axis: X
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.
@ -313,23 +333,42 @@ class ApcMiniInput(MidiInput):
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
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 x+y > 63:
return None
if not result:
return None,None
else:
return y + x
#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).
@ -352,12 +391,24 @@ class ApcMiniInput(MidiInput):
#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)
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["shift"]:
@ -403,7 +454,6 @@ class ApcMiniInput(MidiInput):
else:
api.undo()
elif pitch == APCmini_MAP["unlabeled_lower"]: #Reset View Area and Reset Pattern(!) with shift
if self.shiftIsActive: #Clear current pattern:
api.patternOffAllSteps(self.currentTrack["id"])
@ -426,6 +476,39 @@ class ApcMiniInput(MidiInput):
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["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):
if value == 0: return
wasZero = self.viewAreaUpDownOffset == 0
@ -468,26 +551,6 @@ class ApcMiniInput(MidiInput):
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):
@ -529,16 +592,29 @@ class ApcMiniInput(MidiInput):
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)
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"""
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)
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:

Loading…
Cancel
Save