#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), This 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 . """ import logging; logger = logging.getLogger(__name__); logger.info("import") #Standard Library Modules #Third Party Modules from PyQt5 import QtWidgets, QtCore, QtGui #Template Modules from template.engine.duration import baseDurationToTraditionalNumber #User modules import engine.api as api from .instrument import GuiInstrument, GuiLibrary #for the types from .verticalpiano import WIDTH as HEIGHT from .verticalpiano import STAFFLINEGAP as WIDTH WIDTH = WIDTH * 1.5 SCOREWIDTH = WIDTH * 75 #75 white keys. The vertical piano does have a linear layout while we have the inverleaved piano one. class HorizontalPiano(QtWidgets.QGraphicsView): def __init__(self, mainWindow): super().__init__(mainWindow) self.mainWindow = mainWindow self.setAlignment(QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) self.setDragMode(QtWidgets.QGraphicsView.NoDrag) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.pianoScene = _HorizontalPianoScene(self) self.setScene(self.pianoScene) self.setSceneRect(QtCore.QRectF(0, 0, SCOREWIDTH, HEIGHT)) #x,y,w,h #self.setFixedHeight(SCOREHEIGHT+3) # Don't set to scoreheight. Then we don't get a scrollbar. We need to set the sceneRect to 100%, not the view. #self.mainWindow.ui.horizontalPianoFrame.setFixedHeight(SCOREHEIGHT+3) #Don't. This makes the whole window a fixed size! #self.setFixedWidth(WIDTH) #Also done by parent widget in mainWindow self.setLineWidth(0) self.centerOn(SCOREWIDTH/2, 0) def wheelEvent(self, event): """Convert vertical scrolling to horizontal""" event.accept() #eat the event self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() + event.pixelDelta().y()) #y because it is the original vert. scroll def currentTreeItemChanged(self, currentTreeItem:QtWidgets.QTreeWidgetItem): """ Program wide GUI-only callback from widget.currentItemChanged->mainWindow.currentTreeItemChanged. We set the currentItem ourselves, so we need to block our signals to avoid recursion. Only one item can be selected at a time. The currentTreeItem we receive is not a global instance but from a widget different to ours. We need to find our local version of the same instrument/library/idKey first. """ isLibrary = type(currentTreeItem) is GuiLibrary idKey = currentTreeItem.idKey if isLibrary: self.pianoScene.selectedInstrumentChanged(None) else: self.pianoScene.selectedInstrumentChanged(currentTreeItem.cachedInstrumentStatus) class _HorizontalPianoScene(QtWidgets.QGraphicsScene): """Most of this is copy paste from piano grid""" def __init__(self, parentView): super().__init__() self.parentView = parentView #Set color, otherwise it will be transparent in window managers or wayland that want that. self.backColor = QtGui.QColor() self.backColor.setNamedColor("#999999") #grey self.setBackgroundBrush(self.backColor) self.linesHorizontal = [] self.allKeys = {} # pitch/int : BlackKey or WhiteKey self.numberLabels = [] #index is pitch self._selectedInstrument = None #instrumentStatus dict self._leftMouseDown = False #For note preview self.gridPen = QtGui.QPen(QtCore.Qt.SolidLine) self.gridPen.setCosmetic(True) boldPen = QtGui.QPen(QtCore.Qt.SolidLine) boldPen.setCosmetic(True) boldPen.setWidth(1) whitekeyCounter = 0 for i in range(128): blackKey = i % 12 in (1, 3, 6, 8, 10) if blackKey: key = BlackKey(self, i) x = whitekeyCounter * WIDTH - WIDTH/2 numberY = 0 #for later if i < 100: numberX = x + 4 else: numberX = x key.setPos(x, HEIGHT * -0.7) key.setZValue(4) else: key = WhiteKey(self, i) x = whitekeyCounter * WIDTH key.setPos(x, 0) key.setZValue(1) if i < 100: numberX = x + 5 else: numberX = x + 3 numberY = HEIGHT/2 -3 #100 #for later whitekeyCounter += 1 self.addItem(key) #we can setPos before adding to the scene. self.allKeys[i] = key if not blackKey: vline = QtWidgets.QGraphicsLineItem(0, 0, 0, HEIGHT) #x1, y1, x2, y2 vline.setPen(self.gridPen) self.addItem(vline) vline.setPos(key.x(), 0) vline.setEnabled(False) vline.setAcceptedMouseButtons(QtCore.Qt.NoButton) #Disabled items discard the mouse event unless mouseButtons are not accepted vline.setZValue(2) #above the white keys self.linesHorizontal.append(vline) #Numbers last so they are on top. numberLabel = NumberLabel(self, i) self.addItem(numberLabel) self.numberLabels.append(numberLabel) #index is pitch #numberLabel.setPos(i * WIDTH + 2, 0) numberLabel.setPos(numberX, numberY) numberLabel.setZValue(10) self.fakeDeactivationOverlay = QtWidgets.QGraphicsRectItem(0,0, SCOREWIDTH, HEIGHT) self.fakeDeactivationOverlay.setBrush(QtGui.QColor("black")) self.fakeDeactivationOverlay.setOpacity(0.6) self.fakeDeactivationOverlay.setEnabled(False) self.fakeDeactivationOverlay.setAcceptedMouseButtons(QtCore.Qt.NoButton) #Disabled items discard the mouse event unless mouseButtons are not accepted self.addItem(self.fakeDeactivationOverlay) self.fakeDeactivationOverlay.setPos(0,0) self.fakeDeactivationOverlay.setZValue(12) #above the numbers self.fakeDeactivationOverlay.show() #Keyboard Creation Done api.callbacks.instrumentMidiNoteOnActivity.append(self.highlightNoteOn) api.callbacks.instrumentMidiNoteOffActivity.append(self.highlightNoteOff) api.callbacks.instrumentStatusChanged.append(self.instrumentStatusChanged) def clearHorizontalPiano(self): for keyPitch, keyObject in self.allKeys.items(): keyObject.show() keyObject.highlightOff() keyObject.setPlayable(False) keyObject.setKeySwitch(False) for nl in self.numberLabels: nl.setLabel("") #reset to just number self.fakeDeactivationOverlay.show() def instrumentStatusChanged(self, instrumentStatus:dict): """GUI callback. Data is live""" #Is this for us? if instrumentStatus and self._selectedInstrument and not instrumentStatus["idKey"] == self._selectedInstrument["idKey"]: return #else: # print ("not for us", instrumentStatus["idKey"]) self.clearHorizontalPiano() if not instrumentStatus["state"]: self.fakeDeactivationOverlay.show() return self.fakeDeactivationOverlay.hide() for keyPitch, keyObject in self.allKeys.items(): if keyPitch in instrumentStatus["keyLabels"]: self.numberLabels[keyPitch].setLabel(instrumentStatus["keyLabels"][keyPitch], keyswitch=False) #can be overwritten by keyswitch label. otherwise on any key, no matter if deactivated or not if keyPitch in instrumentStatus["keySwitches"]: opcode, keyswitchLabel = instrumentStatus["keySwitches"][keyPitch] keyObject.setPlayable(True) keyObject.setKeySwitch(True) self.numberLabels[keyPitch].setLabel(keyswitchLabel, keyswitch=True) elif keyPitch in instrumentStatus["playableKeys"]: keyObject.setPlayable(True) #else: #self.numberLabels[keyPitch].hide() def selectedInstrumentChanged(self, instrumentStatus): """GUI click to different instrument. The arguments are cached GUI data If a library is clicked, and not an instrument, both parameters will be None. """ if instrumentStatus is None: self._selectedInstrument = None self.clearHorizontalPiano() self.fakeDeactivationOverlay.show() else: self._selectedInstrument = instrumentStatus self.instrumentStatusChanged(instrumentStatus) def highlightNoteOn(self, idKey:tuple, pitch:int, velocity:int): if self._selectedInstrument and self._selectedInstrument["idKey"] == idKey: self.allKeys[pitch].highlightOn() def highlightNoteOff(self, idKey:tuple, pitch:int, velocity:int): if self._selectedInstrument and self._selectedInstrument["idKey"] == idKey: self.allKeys[pitch].highlightOff() def allHighlightsOff(self): for keyPitch, keyObject in self.allKeys.items(): keyObject[pitch].highlightNoteOff() def mousePressEvent(self, event): self._leftMouseDown = False if event.button() == QtCore.Qt.LeftButton: self._leftMouseDown = True self._lastPlayPitch = None #this must not be in _play, otherwise you can't move the mouse while pressed down self._play(event) else: super().mousePressEvent(event) def wheelEvent(self, event): event.ignore() #let the view handle it, for the scrollbar def _off(self): if self._selectedInstrument and not self._lastPlayPitch is None: status = self._selectedInstrument libId, instrId = status["idKey"] api.sendNoteOffToInstrument(status["idKey"], self._lastPlayPitch) self._lastPlayPitch = None def _play(self, event): assert self._leftMouseDown potentialItems = self.items(event.scenePos() ) for potentialItem in potentialItems: if type(potentialItem) in (BlackKey, WhiteKey): break else: return #no item found. pitch = potentialItem.pitch if pitch < 0 or pitch > 127: pitch = None if self._selectedInstrument and not pitch == self._lastPlayPitch: #TODO: Play note on at a different instrument than note off? Possible? status = self._selectedInstrument if not self._lastPlayPitch is None: #Force a note off that is currently playing but not under the cursor anymore #User did some input tricks with keyboard and mouse combined etc. api.sendNoteOffToInstrument(status["idKey"], self._lastPlayPitch) if not pitch is None: #This is the normal note-on click api.sendNoteOnToInstrument(status["idKey"], pitch) self._lastPlayPitch = pitch def mouseMoveEvent(self, event): """Event button is always 0 in a mouse move event""" if self._leftMouseDown: self._play(event) super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if self._leftMouseDown: self._off() self._leftMouseDown = False if event.button() == QtCore.Qt.LeftButton: self._lastPlayPitch = None super().mouseReleaseEvent(event) class NumberLabel(QtWidgets.QGraphicsSimpleTextItem): """In opposite to verticalPiano the key label is a different childItem as the number""" def __init__(self, parentScene, number:int): super().__init__() self.parentScene = parentScene self.number = number self.labelItem = QtWidgets.QGraphicsSimpleTextItem("") self.labelItem.setParentItem(self) self.labelItem.setEnabled(False) self.currentLabel = "" self.setText(str(number)) self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True) self.setEnabled(False) #Ignored? self.setAcceptedMouseButtons(QtCore.Qt.NoButton) self.blackKey = number % 12 in (1, 3, 6, 8, 10) if self.blackKey: self.setScale(0.9) self.labelItem.setRotation(90) self.labelItem.setPos(15,15) else: self.setScale(1) self.labelItem.setRotation(-90) self.labelItem.setPos(-5,0) self.setTextColor(blackKey=self.blackKey, keyswitch=False) def setTextColor(self, blackKey:bool, keyswitch:bool): if blackKey and not keyswitch: self.setBrush(QtGui.QColor("white")) self.labelItem.setBrush(QtGui.QColor("white")) else: self.setBrush(QtGui.QColor("black")) self.labelItem.setBrush(QtGui.QColor("black")) def setLabel(self, label:str, keyswitch=False): """Each key can have an optional text label for keyswitches, percussion names etc. Use with empty string to reset to just the midi pitch number.""" self.currentLabel = label self.labelItem.setText(label) self.setTextColor(blackKey=self.blackKey, keyswitch=keyswitch) class BlackKey(QtWidgets.QGraphicsRectItem): def __init__(self, parentScene, pitch): super().__init__(0, 0, WIDTH * 0.8, HEIGHT) #x, y, w, h self.parentScene = parentScene self.pitch = pitch self.state = False self.setPen(QtGui.QPen(QtCore.Qt.NoPen)) self.setBrush(QtGui.QColor("black")) self.setAcceptHoverEvents(True) self.setAcceptedMouseButtons(QtCore.Qt.NoButton) self.decorationOverlay = QtWidgets.QGraphicsRectItem(0, 0, WIDTH * 0.8, HEIGHT) #x, y, w, h self.decorationOverlay.setEnabled(False) self.decorationOverlay.setAcceptedMouseButtons(QtCore.Qt.NoButton) self.decorationOverlay.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True) self.decorationOverlay.setParentItem(self) self.highlight = QtWidgets.QGraphicsRectItem(0, 0, WIDTH * 0.8, HEIGHT) #x, y, w, h self.highlight.setEnabled(False) self.highlight.setAcceptedMouseButtons(QtCore.Qt.NoButton) self.highlight.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True) self.highlight.setOpacity(0.5) self.highlight.setBrush(QtGui.QColor("cyan")) self.highlight.hide() self.highlight.setParentItem(self) def setPlayable(self, state:bool): c = QtGui.QColor() self.state = state if state: c.setNamedColor("#0c0c0c") else: #Only if the instrument is activated. Not loaded instruments are just dark black and white c.setNamedColor("#444444") self.setBrush(c) def hoverEnterEvent(self, event): if self.state: l = self.parentScene.numberLabels[self.pitch].currentLabel if l: self.parentScene.parentView.mainWindow.statusBar().showMessage(f"[{self.pitch}] {l}") def hoverLeaveEvent(self, event): self.parentScene.parentView.mainWindow.statusBar().showMessage("") def setKeySwitch(self, state:bool): self.state = state if state: self.decorationOverlay.show() self.decorationOverlay.setBrush(QtGui.QColor("orange")) else: self.decorationOverlay.hide() def highlightOn(self): self.highlight.show() def highlightOff(self): self.highlight.hide() class WhiteKey(QtWidgets.QGraphicsRectItem): def __init__(self, parentScene, pitch:int): super().__init__(0, 0, WIDTH, HEIGHT) #x, y, w, h self.parentScene = parentScene self.pitch = pitch self.state = False self.setPen(QtGui.QPen(QtCore.Qt.NoPen)) self.setBrush(QtGui.QColor("white")) self.setAcceptHoverEvents(True) self.setAcceptedMouseButtons(QtCore.Qt.NoButton) self.decorationOverlay = QtWidgets.QGraphicsRectItem(0, 0, WIDTH, HEIGHT) #x, y, w, h self.decorationOverlay.setParentItem(self) self.decorationOverlay.setEnabled(False) self.decorationOverlay.setAcceptedMouseButtons(QtCore.Qt.NoButton) self.decorationOverlay.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True) self.highlight = QtWidgets.QGraphicsRectItem(0, 0, WIDTH, HEIGHT) #x, y, w, h self.highlight.setParentItem(self) self.highlight.setEnabled(False) self.highlight.setAcceptedMouseButtons(QtCore.Qt.NoButton) self.highlight.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True) self.highlight.setOpacity(0.5) self.highlight.setBrush(QtGui.QColor("cyan")) self.highlight.hide() def hoverEnterEvent(self, event): if self.state: l = self.parentScene.numberLabels[self.pitch].currentLabel if l: self.parentScene.parentView.mainWindow.statusBar().showMessage(f"[{self.pitch}] {l}") def hoverLeaveEvent(self, event): self.parentScene.parentView.mainWindow.statusBar().showMessage("") def setPlayable(self, state:bool): c = QtGui.QColor() self.state = state if state: c.setNamedColor("#fdfdff") else: #Only if the instrument is activated. Not loaded instruments are just dark black and white c.setNamedColor("#999999") self.setBrush(c) def setKeySwitch(self, state:bool): self.state = state if state: self.decorationOverlay.show() self.decorationOverlay.setBrush(QtGui.QColor("orange")) else: self.decorationOverlay.hide() def highlightOn(self): self.highlight.show() def highlightOff(self): self.highlight.hide()