From a1bff529caaba88435cd3937c877d86a3c3607c3 Mon Sep 17 00:00:00 2001 From: Nils <> Date: Tue, 14 Dec 2021 19:36:48 +0100 Subject: [PATCH] More apcmini functions --- engine/api.py | 36 ++++++++++++++++- engine/input_apcmini.py | 85 +++++++++++++++++++++++++++++++++-------- qtgui/mainwindow.py | 15 ++++---- qtgui/songeditor.py | 14 +++---- template/engine/api.py | 3 ++ 5 files changed, 123 insertions(+), 30 deletions(-) diff --git a/engine/api.py b/engine/api.py index f5b8497..771feeb 100644 --- a/engine/api.py +++ b/engine/api.py @@ -383,6 +383,19 @@ def seek(value): cbox.Transport.seek_ppqn(value) +def seekMeasureLeft(): + """This skips one base measure, not the multiplicator one""" + now = cbox.Transport.status().pos_ppqn + oneMeasureInTicks = (session.data.howManyUnits * session.data.whatTypeOfUnit) / session.data.subdivisions + seek(now - oneMeasureInTicks) + +def seekMeasureRight(): + """This skips one base measure, not the multiplicator one""" + now = cbox.Transport.status().pos_ppqn + oneMeasureInTicks = (session.data.howManyUnits * session.data.whatTypeOfUnit) / session.data.subdivisions + seek(now + oneMeasureInTicks) + + def getGlobalOffset(): """Return the current offsets in full measures + free tick value 3rd: Cached abolute tick value gets updated everytime the time signature changes or setGlobalOffset is called""" @@ -586,9 +599,30 @@ 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 + assert track, trackId callbacks._currentTrackChanged(track) +def currentTrackBy(currentTrackId, value:int): + """Convenience for the apcMiniController or a GUI that wants shortcuts. + Only use +1 and -1 for value for stepping. + We do NOT test for other values if the overshoot the session.data.tracks index!! + We need to know what the current track is because the engine doesn't know it. + + Ignores invisible tracks, aka tracks in a GUI-folded group. + """ + + assert value in (-1, 1), value + + currentTrack = session.data.trackById(currentTrackId) + assert currentTrack, currentTrackId + currentIndex = session.data.tracks.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 + + newCurrentTrack = session.data.tracks[currentIndex+value] + changeCurrentTrack(id(newCurrentTrack)) + def addTrack(scale=None): if scale: assert type(scale) == tuple diff --git a/engine/input_apcmini.py b/engine/input_apcmini.py index 85b1690..302a205 100644 --- a/engine/input_apcmini.py +++ b/engine/input_apcmini.py @@ -40,10 +40,10 @@ 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 + 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", @@ -52,14 +52,14 @@ APCmini_MAP = { 71: "device", #Scene Launch - 82: "clip_stop", - 83: "solo", + 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_1", - 88: "unlabeled_2", - 89: "stop_all_clips", + 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. } @@ -147,6 +147,8 @@ class ApcMiniInput(MidiInput): 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: @@ -374,13 +376,53 @@ class ApcMiniInput(MidiInput): cbox.send_midi_event(0x90, APCmini_MAP["mute"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) elif pitch == APCmini_MAP["arrow_up"]: - self.viewAreaUpDown(+1) + if self.shiftIsActive: + api.currentTrackBy(self.currentTrack["id"], -1) + else: + self.viewAreaUpDown(+1) + elif pitch == APCmini_MAP["arrow_down"]: - self.viewAreaUpDown(-1) + if self.shiftIsActive: + api.currentTrackBy(self.currentTrack["id"], 1) + else: + self.viewAreaUpDown(-1) + elif pitch == APCmini_MAP["arrow_left"]: - self.viewAreaLeftRight(+1) #Yes, it is inverted scrolling + if self.shiftIsActive: + api.seekMeasureLeft() + else: + self.viewAreaLeftRight(+1) #Yes, it is inverted scrolling elif pitch == APCmini_MAP["arrow_right"]: - self.viewAreaLeftRight(-1) + if self.shiftIsActive: + api.seekMeasureRight() + else: + self.viewAreaLeftRight(-1) + elif pitch == APCmini_MAP["unlabeled_upper"]: #Undo + if self.shiftIsActive: #redo + api.redo() + 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"]) + else: + self.viewAreaUpDownOffset = 0 + self.viewAreaLeftRightOffset = 0 + self.sendApcNotePattern(self.currentTrack) + 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) + 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) + + elif pitch == APCmini_MAP["clip_stop"]: + api.playPause() + elif pitch == APCmini_MAP["solo"]: + api.toggleLoop() + elif pitch == APCmini_MAP["stop_all_clips"]: + api.stop() + api.rewind() else: logging.info(f"Unhandled APCmini noteOn. Pitch {pitch}, Velocity {velocity}") @@ -391,7 +433,7 @@ class ApcMiniInput(MidiInput): 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"]: + 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. @@ -412,7 +454,7 @@ class ApcMiniInput(MidiInput): 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"]: + 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. @@ -516,4 +558,17 @@ class ApcMiniInput(MidiInput): 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["clip_stop"], APCmini_COLOR["green_blink"], output=self.cboxMidiOutUuid) + else: + cbox.send_midi_event(0x90, APCmini_MAP["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["solo"], APCmini_COLOR["off"], output=self.cboxMidiOutUuid) + else: + cbox.send_midi_event(0x90, APCmini_MAP["solo"], APCmini_COLOR["green_blink"], output=self.cboxMidiOutUuid) + + apcMiniInput = ApcMiniInput() #global to use in other parts of the program diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py index 4e40ebb..dfa3917 100644 --- a/qtgui/mainwindow.py +++ b/qtgui/mainwindow.py @@ -225,7 +225,7 @@ class MainWindow(TemplateMainWindow): #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. #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! + self.chooseCurrentTrack(api.session.data.tracks[0].export(), sendChangeToApi=False) #By Grabthar's hammer, by the suns of Worvan, what a hack! api.callbacks.currentTrackChanged.append(self.chooseCurrentTrack) self.ui.gridView.horizontalScrollBar().setSliderPosition(0) @@ -240,7 +240,7 @@ class MainWindow(TemplateMainWindow): api.setLoopMeasureFactor(self.ui.loopMeasureFactorSpinBox.value()) self.ui.loopMeasureFactorSpinBox.blockSignals(False) - def chooseCurrentTrack(self, exportDict): + def chooseCurrentTrack(self, exportDict, sendChangeToApi=False): """This is in mainWindow because we need access to different sections of the program. newCurrentTrack is a backend track ID @@ -259,7 +259,6 @@ class MainWindow(TemplateMainWindow): """ if self._blockCurrentTrackSignal: return - self._blockCurrentTrackSignal = True newCurrentTrackId = exportDict["id"] @@ -282,20 +281,22 @@ class MainWindow(TemplateMainWindow): #Functions depend on getting set after getting called. They need to know the old track! self.currentTrackId = newCurrentTrackId + if sendChangeToApi: + self._blockCurrentTrackSignal = True + api.changeCurrentTrack(newCurrentTrackId) + self._blockCurrentTrackSignal = False - 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. newTrackId = api.addTrack(scale) - self.chooseCurrentTrack(self.songEditor.tracks[newTrackId].exportDict) + self.chooseCurrentTrack(self.songEditor.tracks[newTrackId].exportDict, sendChangeToApi=True) def cloneSelectedTrack(self): """Add a new track via the template option. This is the best track add function""" newTrackExporDict = api.createSiblingTrack(self.currentTrackId) - self.chooseCurrentTrack(newTrackExporDict) + self.chooseCurrentTrack(newTrackExporDict, sendChangeToApi=True) def _populatePatternToolbar(self): """Called once at the creation of the GUI""" diff --git a/qtgui/songeditor.py b/qtgui/songeditor.py index 16167a8..af2330d 100644 --- a/qtgui/songeditor.py +++ b/qtgui/songeditor.py @@ -485,7 +485,7 @@ class TrackStructure(QtWidgets.QGraphicsRectItem): if event.button() == QtCore.Qt.MiddleButton and not self._mousePressOn: self.parentScene.parentView.parentMainWindow.patternGrid.createShadow(self.exportDict) else: - self.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.exportDict) + self.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.exportDict, sendChangeToApi=True) if event.button() == QtCore.Qt.LeftButton: #Create a switch or continue to hold down mouse button and drag to draw -> mouseMoveEvent assert not self._mousePressOn position = self.scenePos2switchPosition(event.scenePos().x()) #measure number 0 based @@ -539,7 +539,7 @@ class TrackStructure(QtWidgets.QGraphicsRectItem): self._highlightSwitch.show() self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Empty Measure: Left click to activate. Middle click to show as shadows in current pattern. Right click for measure group options.")) #Yes, this is the track. Empty measures are not objects. #This seemed to be a good idea but horrible UX. If you move the mouse down to edit a pattern you end up choosing the last track - #self.scene().parentView.parentMainWindow.chooseCurrentTrack(self.exportDict) + #self.scene().parentView.parentMainWindow.chooseCurrentTrack(self.exportDict, sendChangeToApi=True) def hoverLeaveEvent(self, event): self.statusMessage("") @@ -947,7 +947,7 @@ class TrackLabelEditor(QtWidgets.QGraphicsScene): del trackLabel if toDelete and self.parentView.parentMainWindow.currentTrackId in toDelete: #toDelete still exist if tracks were deleted above anyExistingTrack = next(iter(self.tracks.values())) - self.parentView.parentMainWindow.chooseCurrentTrack(anyExistingTrack.exportDict) + self.parentView.parentMainWindow.chooseCurrentTrack(anyExistingTrack.exportDict, sendChangeToApi=True) self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT + groupOffset - hiddenOffsetCounter self.setSceneRect(0,0,width-1,self.cachedCombinedTrackHeight + 3*SIZE_TOP_OFFSET) @@ -1131,7 +1131,7 @@ class TrackLabel(QtWidgets.QGraphicsRectItem): self.parentTrackLabel.statusMessage("") def spinBoxValueChanged(self): - self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict) + self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict, sendChangeToApi=True) api.setTrackPatternLengthMultiplicator(self.parentTrackLabel.exportDict["id"], self.spinBox.value()) @@ -1150,7 +1150,7 @@ class TrackLabel(QtWidgets.QGraphicsRectItem): def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: - self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict) + self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict, sendChangeToApi=True) event.accept() colorDialog = QtWidgets.QColorDialog() color = colorDialog.getColor(self.brush().color()) #blocks @@ -1240,7 +1240,7 @@ class TrackLabel(QtWidgets.QGraphicsRectItem): def mousePressEvent(self,event): """We also need to force this track as active""" event.accept() #we need this for doubleClick - self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict) + self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict, sendChangeToApi=True) #event.ignore() #send to parent instead #super().mousePressEvent(event) @@ -1279,7 +1279,7 @@ class TrackLabel(QtWidgets.QGraphicsRectItem): self.lengthMultiplicatorSpinBox.spinBox.blockSignals(False) def mousePressEvent(self,event): - self.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.exportDict) + self.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.exportDict, sendChangeToApi=True) #event.ignore() #don't add that here. Will destroy mouseMove and Release events of child widgets #def mouseReleaseEvent(self, event): diff --git a/template/engine/api.py b/template/engine/api.py index df252a5..6c976d6 100644 --- a/template/engine/api.py +++ b/template/engine/api.py @@ -381,6 +381,9 @@ def playPause(): cbox.Transport.play() #It takes a few ms for playbackStatus to catch up. If you call it here again it will not be updated. +def stop(): + cbox.Transport.stop() + def getPlaybackTicks()->int: return cbox.Transport.status().pos_ppqn