From 3b7c3ecccc238f1837767eb73ccd02f30028fb6f Mon Sep 17 00:00:00 2001 From: Nils Date: Sun, 15 May 2022 14:21:49 +0200 Subject: [PATCH] Use midi input as cursor pitch when step midi is not active --- CHANGELOG | 2 ++ engine/api.py | 29 ++++++++++++++++-- engine/cursor.py | 3 ++ engine/main.py | 12 ++++++++ engine/midiinput/stepmidiinput.py | 47 ++++++++++++++++++----------- qtgui/menu.py | 11 +++++-- template/engine/input_midi.py | 5 ++- template/qtgui/midiinquickwidget.py | 28 +++++++++++------ 8 files changed, 105 insertions(+), 32 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7f96220..dbcff30 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,6 +6,8 @@ External contributors notice at the end of the line: (LastName, FirstName / nick ## 2022-07-15 2.1.0 +When not in F4-StepInput Mode use midi keyboard as pitch-cursor. +Add midi-in selector drop down, as seen in Tembro and Fluajho. Rewrite grid, which was a performance drag in the past. Add functions to move block to start or end of track. Scroll view when dragging blocks and tracks with the mouse. diff --git a/engine/api.py b/engine/api.py index 76127c3..273cdee 100644 --- a/engine/api.py +++ b/engine/api.py @@ -331,6 +331,21 @@ def playFromBlockStart(): raise RuntimeError("reached end of blocks without matchin current block index") +def getMidiInputNameAndUuid()->(str, int): #tuple name:str, uuid + """Override template function. We access the stepMidi directly. + Used by the quick midi input widget + + Return name and cboxMidiPortUid. + name is Client:Port JACK format + + If not return None, None + """ + from engine.midiinput.stepmidiinput import stepMidiInput #singleton instance #avoid circular dependency. stepMidiInput import api + if stepMidiInput.ready: #startup delay + return stepMidiInput.fullName(), stepMidiInput.cboxMidiPortUid + else: + return None, None + topLevelFunction = None def simpleCommand(function, autoStepLeft = True, forceReturnToItem = None): """ @@ -1164,6 +1179,13 @@ def selectToTickindex(trackid, tickindex): session.data.setSelectionBeginning() toTickindex(trackid, tickindex, destroySelection = False) +#####Pitches + +def toPitch(pitchindex:int): + session.data.cursor.pitchindex = pitchindex + callbacks._setCursor(destroySelection = False) #does not modify the pitch to keysig etc. + + def up(): session.data.cursor.up() callbacks._setCursor(destroySelection = False) @@ -1261,8 +1283,11 @@ def insertChord(baseDuration, pitchToInsert): def insertCursorChord(baseDuration): """Insert a new chord with one note to the track. The intial note gets its pitch from the cursor position, to scale""" - keysig = session.data.currentTrack().state.keySignature() - pitchToInsert = pitchmath.toScale(session.data.cursor.pitchindex, keysig) + if session.data.cursor.cursorWasMovedAfterChoosingPitchViaMidi: #we had cursor arrow keys movement + keysig = session.data.currentTrack().state.keySignature() + pitchToInsert = pitchmath.toScale(session.data.cursor.pitchindex, keysig) + else: + pitchToInsert = session.data.cursor.pitchindex insertChord(baseDuration, pitchToInsert) def addNoteToChord(pitchToInsert): diff --git a/engine/cursor.py b/engine/cursor.py index 159401c..5bf025e 100644 --- a/engine/cursor.py +++ b/engine/cursor.py @@ -46,6 +46,7 @@ class Cursor: self.prevailingBaseDuration = duration.D4 self.persistentPrevailingDot = False #For the sake of simplicity this is only a bool. No "two prevailing dots" self.oneTimePrevailingDotInverter = False #effects persistentPrevailingDot + self.cursorWasMovedAfterChoosingPitchViaMidi = False #this flag enables the api to check if the cursor was set with cursor commands (=arrow keys) after it was set by midi. If not the api can insert the literal midi pitch def up(self): """Move the cursor/pitchindex up, +50. @@ -74,6 +75,7 @@ class Cursor: self.correctPitchindex() def correctPitchindex(self): + self.cursorWasMovedAfterChoosingPitchViaMidi = True if self.pitchindex >= pitch.MAX: self.pitchindex = pitch.MAX elif self.pitchindex <= pitch.MIN: @@ -117,6 +119,7 @@ class Cursor: item = trackState.track.currentItem() block = trackState.track.currentBlock() + return { "type": "Cursor", "trackIndex": trackState.index(), diff --git a/engine/main.py b/engine/main.py index 76e0f21..d991581 100644 --- a/engine/main.py +++ b/engine/main.py @@ -1052,6 +1052,18 @@ class Data(template.engine.sequencer.Score): data = {track:track.lilypond() for track in self.tracks} #processed in the lilypond module return fromTemplate(session = self.parentSession, templateFile = "default.ly", data = data, meta = self.metaData, tempoStaff = tempoStaff) + def getMidiInputNameAndUuid(self): + """ + Return name and cboxMidiPortUid. + name is Client:Port JACK format + + Reimplement this in your actual data classes like sf2_sampler and sfz_sampler and + sequencers. Used by the quick connect midi input widget. + If double None as return the widget in the GUI might hide and deactivate itself.""" + + return None, None + + #Save / Load / Export def serialize(self)->dict: dictionary = super().serialize() diff --git a/engine/midiinput/stepmidiinput.py b/engine/midiinput/stepmidiinput.py index 3025616..83a6c6d 100644 --- a/engine/midiinput/stepmidiinput.py +++ b/engine/midiinput/stepmidiinput.py @@ -46,12 +46,17 @@ class StepMidiInput(MidiInput): which means that as long as one of the chord notes would still be down the chord was still "on". That is not as robust and convenient as using the starting note, which is counter intuitive, therefore documented here. + + Up until 2022 we used the midi in active toggle just on/off. So the template bypass. + But now we never switch midi processing off, we just reroute input vs. setting the pitch cursor """ def __init__(self): #No super init in here! This is delayed until self.start self.firstActiveNote = None #for chord entry. self._currentlyActiveNotes = set() + self.ready = False #Program start + self.inputitemTrueCursorpitchFalse = False #start with just def start(self): """Call this manually after the engine and an event loop have started. @@ -59,7 +64,8 @@ class StepMidiInput(MidiInput): But it could be started from a simple command line interface as well.""" assert api.laborejoEngineStarted super().__init__(session=api.session, portName="in") - self.midiProcessor.active = False #specific to Laborejo + self.midiProcessor.active = True #never off. + self.ready = True #Program start #Connect the template midi input with Laborejo api calls. #self.midiProcessor.notePrinter(True) @@ -71,35 +77,42 @@ class StepMidiInput(MidiInput): @property def midiInIsActive(self): + """ try: return self.midiProcessor.active - except AttributeError: #during startupt + except AttributeError: #during startup return False + """ + return self.inputitemTrueCursorpitchFalse def _insertMusicItemFromMidi(self, timeStamp, channel, midipitch, velocity): - if self._currentlyActiveNotes: #Chord - api.left() - keysig = api.session.data.currentTrack().state.keySignature() - pitchToInsert = api.pitchmath.fromMidi(midipitch, keysig) - api.addNoteToChord(pitchToInsert) - api.right() - else: #Single note - baseDuration = api.session.data.cursor.prevailingBaseDuration - keysig = api.session.data.currentTrack().state.keySignature() - pitchToInsert = api.pitchmath.fromMidi(midipitch, keysig) - api.insertChord(baseDuration, pitchToInsert) - - self._currentlyActiveNotes.add(midipitch) + keysig = api.session.data.currentTrack().state.keySignature() + pitchToInsert = api.pitchmath.fromMidi(midipitch, keysig) + if self.inputitemTrueCursorpitchFalse: + if self._currentlyActiveNotes: #Chord + api.left() + api.addNoteToChord(pitchToInsert) + api.right() + else: #Single note + baseDuration = api.session.data.cursor.prevailingBaseDuration + api.insertChord(baseDuration, pitchToInsert) + else: + api.session.data.cursor.cursorWasMovedAfterChoosingPitchViaMidi = False + api.toPitch(pitchToInsert) + + self._currentlyActiveNotes.add(midipitch) #This needs to be at the end of the function for chord-detection to work def _pop(self, timeStamp, channel, midipitch, velocity): self._currentlyActiveNotes.remove(midipitch) def setMidiInputActive(self, state:bool): - self.midiProcessor.active = state + #self.midiProcessor.active = state + self.inputitemTrueCursorpitchFalse = state api.callbacks._prevailingBaseDurationChanged(api.session.data.cursor.prevailingBaseDuration) def toggleMidiIn(self): - self.setMidiInputActive(not self.midiInIsActive) + #self.setMidiInputActive(not self.midiInIsActive) + self.setMidiInputActive(not self.inputitemTrueCursorpitchFalse) def _setMidiThru(self, cursorExport): """We don't need to react to deleted tracks because that does reset the cursor. diff --git a/qtgui/menu.py b/qtgui/menu.py index 9eb728e..a75cc05 100644 --- a/qtgui/menu.py +++ b/qtgui/menu.py @@ -30,6 +30,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets translate = QtCore.QCoreApplication.translate #Template Modules +from template.qtgui.midiinquickwidget import QuickMidiInputComboController #Our modules @@ -373,7 +374,11 @@ class MenuActionDatabase(object): #Prepare non-designer widgets. Designer can't put normal widgets in a toolbar, but Qt can. #Eventhough this is a dict, which has no order, the CREATION has an order. So the first item in the dict will be the first item in the toolBar + + midiText = QtCore.QCoreApplication.translate("menu", "Step Midi Input") + self.extraToolBarWidgets = { + "midiInputWidget" : self.mainWindow.ui.toolBar.addWidget(QuickMidiInputComboController(self.mainWindow.ui.toolBar, text=midiText, stretch=False)), "snapToGrid" : self.mainWindow.ui.toolBar.addWidget(ToolBarSnapToGrid(mainWindow=self.mainWindow)), "metronome" : self.mainWindow.ui.toolBar.addWidget(ToolBarMetronome(mainWindow=self.mainWindow)), "playbackSpeed" : self.mainWindow.ui.toolBar.addWidget(ToolBarPlaybackSpeed(mainWindow=self.mainWindow)), @@ -381,9 +386,9 @@ class MenuActionDatabase(object): } self.toolbarContexts = { - "notation" : [self.extraToolBarWidgets["snapToGrid"], self.extraToolBarWidgets["metronome"], self.extraToolBarWidgets["playbackSpeed"], ], - "cc" : [self.extraToolBarWidgets["snapToGrid"], self.extraToolBarWidgets["metronome"], self.extraToolBarWidgets["ccType"], self.extraToolBarWidgets["playbackSpeed"]], - "block": [self.extraToolBarWidgets["snapToGrid"], self.extraToolBarWidgets["metronome"], self.extraToolBarWidgets["playbackSpeed"], ], + "notation" : [self.extraToolBarWidgets["midiInputWidget"], self.extraToolBarWidgets["snapToGrid"], self.extraToolBarWidgets["metronome"], self.extraToolBarWidgets["playbackSpeed"], ], + "cc" : [self.extraToolBarWidgets["midiInputWidget"], self.extraToolBarWidgets["snapToGrid"], self.extraToolBarWidgets["metronome"], self.extraToolBarWidgets["ccType"], self.extraToolBarWidgets["playbackSpeed"]], + "block": [self.extraToolBarWidgets["midiInputWidget"], self.extraToolBarWidgets["snapToGrid"], self.extraToolBarWidgets["metronome"], self.extraToolBarWidgets["playbackSpeed"], ], } #Now connect all actions to functions diff --git a/template/engine/input_midi.py b/template/engine/input_midi.py index fc7470a..4a1b946 100644 --- a/template/engine/input_midi.py +++ b/template/engine/input_midi.py @@ -109,6 +109,9 @@ class MidiInput(object): for hp in hardwareMidiPorts: cbox.JackIO.port_connect(hp, cbox.JackIO.status().client_name + ":" + self.portName) + def fullName(self)->str: + return cbox.JackIO.status().client_name + ":" + self.portName + class MidiProcessor(object): """ @@ -165,7 +168,7 @@ class MidiProcessor(object): This function gets called very often. So every optimisation is good. """ - events = cbox.JackIO.get_new_events(self.parentInput.cboxMidiPortUid) + events = cbox.JackIO.get_new_events(self.parentInput.cboxMidiPortUid) #We get the events even if not active. Otherwise they pile up. if not self.active: return if not events: diff --git a/template/qtgui/midiinquickwidget.py b/template/qtgui/midiinquickwidget.py index 9fe80e1..9dd37f7 100644 --- a/template/qtgui/midiinquickwidget.py +++ b/template/qtgui/midiinquickwidget.py @@ -34,9 +34,11 @@ import engine.api as api class QuickMidiInputComboController(QtWidgets.QWidget): """This widget breaks a bit with the convention of engine/gui. However, it is a pure convenience - fire-and-forget function with no callbacks.""" + fire-and-forget function with no callbacks. - def __init__(self, parentWidget): + If you give a text it must already be translated.""" + + def __init__(self, parentWidget, text=None, stretch=True): super().__init__(parentWidget) self.parentWidget = parentWidget self.layout = QtWidgets.QHBoxLayout() @@ -54,7 +56,10 @@ class QuickMidiInputComboController(QtWidgets.QWidget): self.wholePanel = parentWidget.ui.auditionerWidget """ self.label = QtWidgets.QLabel(self) - self.label.setText(QtCore.QCoreApplication.translate("QuickMidiInputComboController", "Midi Input. Use JACK to connect multiple inputs.")) + if text: + self.label.setText(text) #already translated by parent. + else: + self.label.setText(QtCore.QCoreApplication.translate("QuickMidiInputComboController", "Midi Input. Use JACK to connect multiple inputs.")) self.layout.addWidget(self.label) #if not api.isStandaloneMode(): @@ -66,9 +71,8 @@ class QuickMidiInputComboController(QtWidgets.QWidget): self.comboBox.showPopup = self.showPopup self.comboBox.activated.connect(self._newPortChosen) - self.cboxPortname, self.cboxMidiPortUid = api.getMidiInputNameAndUuid() #these don't change during runtime. - - self.layout.addStretch() + if stretch: + self.layout.addStretch() #api.callbacks.startLoadingAuditionerInstrument.append(self.callback_startLoadingAuditionerInstrument) #api.callbacks.auditionerInstrumentChanged.append(self.callback_auditionerInstrumentChanged) #api.callbacks.auditionerVolumeChanged.append(self.callback__auditionerVolumeChanged) @@ -130,17 +134,23 @@ class QuickMidiInputComboController(QtWidgets.QWidget): """externalPort is in the Client:Port JACK format If "" False or None disconnect all ports.""" + cboxPortname, cboxMidiPortUid = api.getMidiInputNameAndUuid() #these don't change during runtime. But if the system it not ready yet it returns None, None + + if cboxPortname is None and cboxMidiPortUid is None: + logging.info("engine is not ready yet to deliver midi input name and port id. This is normal during startup but a problem during normal runtime.") + return #startup delay + try: - currentConnectedList = cbox.JackIO.get_connected_ports(self.cboxMidiPortUid) + currentConnectedList = cbox.JackIO.get_connected_ports(cboxMidiPortUid) except: #port not found. currentConnectedList = [] for port in currentConnectedList: - cbox.JackIO.port_disconnect(port, self.cboxPortname) + cbox.JackIO.port_disconnect(port, cboxPortname) if externalPort: availablePorts = self.getAvailablePorts() if not (externalPort in availablePorts["hardware"] or externalPort in availablePorts["software"]): raise RuntimeError(f"QuickMidiInput was instructed to connect to port {externalPort}, which does not exist") - cbox.JackIO.port_connect(externalPort, self.cboxPortname) + cbox.JackIO.port_connect(externalPort, cboxPortname)