Sampled Instrument Player with static and monolithic design. All instruments are built-in.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

401 lines
16 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
3 years ago
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 <http://www.gnu.org/licenses/>.
"""
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
WIDTH = 200
STAFFLINEGAP = 20 #cannot be changed during runtime
SCOREHEIGHT = STAFFLINEGAP * 128 #notes
class VerticalPiano(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.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
self.pianoScene = _VerticalPianoScene(self)
self.setScene(self.pianoScene)
self.setSceneRect(QtCore.QRectF(0, 0, WIDTH/2, SCOREHEIGHT)) #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.verticalPianoFrame.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(0, 64*STAFFLINEGAP)
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 _VerticalPianoScene(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("#fdfdff")
self.backColor.setNamedColor("#999999") #grey
self.setBackgroundBrush(self.backColor)
self.linesHorizontal = []
self.highlights = {}
self.colorKeys = {}
self.blackKeys = []
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)
#Create two lines for the upper/lower boundaries first. They are just cosmetic
boldPen = QtGui.QPen(QtCore.Qt.SolidLine)
boldPen.setCosmetic(True)
boldPen.setWidth(1)
hlineUp = QtWidgets.QGraphicsLineItem(0, 0, WIDTH*2, 0) #x1, y1, x2, y2
hlineUp.setPen(boldPen)
self.addItem(hlineUp)
hlineUp.setPos(0, 0)
hlineUp.setEnabled(False)
hlineUp.setAcceptedMouseButtons(QtCore.Qt.NoButton)
hlineUp.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
self.linesHorizontal.append(hlineUp)
hlineDown = QtWidgets.QGraphicsLineItem(0, 0, WIDTH*2, 0) #x1, y1, x2, y2
hlineDown.setPen(boldPen)
self.addItem(hlineDown)
hlineDown.setPos(0, 128 * STAFFLINEGAP)
hlineDown.setEnabled(False)
hlineDown.setAcceptedMouseButtons(QtCore.Qt.NoButton)
hlineDown.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
self.linesHorizontal.append(hlineDown)
for i in range(128):
hline = QtWidgets.QGraphicsLineItem(0, 0, WIDTH*2, 0) #x1, y1, x2, y2
hline.setPen(self.gridPen)
self.addItem(hline)
hline.setPos(0, i * STAFFLINEGAP)
hline.setEnabled(False)
hline.setAcceptedMouseButtons(QtCore.Qt.NoButton) #Disabled items discard the mouse event unless mouseButtons are not accepted
self.linesHorizontal.append(hline)
blackKey = i % 12 in (1, 3, 6, 8, 10)
if blackKey:
bk = BlackKey(self)
self.blackKeys.append(bk)
self.addItem(bk)
bk.setPos(0, (127-i) * STAFFLINEGAP)
#Various purpose color keys. They are opaque and are on top of white/black keys
ck = ColorKey(self, i, QtGui.QColor("cyan"), blackKey)
self.addItem(ck)
self.colorKeys[i] = ck
ck.setPos(0, (127-i) * STAFFLINEGAP)
#Highlights on top of colors. Indication if note is played.
hl = Highlight(self)
self.addItem(hl)
self.highlights[i] = hl
hl.setPos(0, (127-i) * STAFFLINEGAP)
#Numbers last so they are on top.
numberLabel = NumberLabel(self, 127-i)
self.addItem(numberLabel)
self.numberLabels.append(numberLabel) #index is pitch
numberLabel.setPos(3, i * STAFFLINEGAP + 2)
numberLabel.setZValue(10)
self.numberLabels.reverse()
self.fakeDeactivationOverlay = QtWidgets.QGraphicsRectItem(0,0,WIDTH,SCOREHEIGHT)
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.show()
#Keyboard Creation Done
api.callbacks.instrumentMidiNoteOnActivity.append(self.highlightNoteOn)
api.callbacks.instrumentMidiNoteOffActivity.append(self.highlightNoteOff)
api.callbacks.instrumentStatusChanged.append(self.instrumentStatusChanged)
def clearVerticalPiano(self):
for colorKeyObj in self.colorKeys.values():
colorKeyObj.hide()
self.allHighlightsOff()
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.clearVerticalPiano()
if not instrumentStatus["state"]:
self.fakeDeactivationOverlay.show()
return
self.fakeDeactivationOverlay.hide()
for keyPitch, keyObject in self.colorKeys.items():
#self.numberLabels[keyPitch].show()
keyObject.show()
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]
self.numberLabels[keyPitch].setLabel(keyswitchLabel, keyswitch=True)
keyObject.setPlayable(True)
keyObject.setBrush(QtGui.QColor("orange"))
else:
#self.numberLabels[keyPitch].hide()
#keyObject.hide()
keyObject.setPlayable(keyPitch in instrumentStatus["playableKeys"])
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.clearVerticalPiano()
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:
highlight = self.highlights[pitch]
highlight.show()
def highlightNoteOff(self, idKey:tuple, pitch:int, velocity:int):
if self._selectedInstrument and self._selectedInstrument["idKey"] == idKey:
highlight = self.highlights[pitch]
highlight.hide()
def allHighlightsOff(self):
for pitch, highlight in self.highlights.items():
highlight.hide()
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)
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
pitch = 127 - int(event.scenePos().y() / STAFFLINEGAP)
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):
def __init__(self, parentPiano, number:int):
super().__init__()
self.parentPiano = parentPiano
self.number = number
self.currentLabel = ""
self.setText(str(number))
self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
self.setScale(1)
self.blackKey = number % 12 in (1, 3, 6, 8, 10)
def setTextColor(self, blackKey:bool, keyswitch:bool):
if blackKey and not keyswitch:
self.setBrush(QtGui.QColor("white"))
else:
self.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.setText(f"{self.number} {label}")
self.setTextColor(self.blackKey, keyswitch)
class Highlight(QtWidgets.QGraphicsRectItem):
def __init__(self, parentPiano):
super().__init__(0, 0, WIDTH, STAFFLINEGAP) #x, y, w, h
self.setEnabled(False) #Not clickable, still visible.
self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
self.setBrush(QtGui.QColor("cyan"))
self.setOpacity(0.5)
self.hide()
class ColorKey(QtWidgets.QGraphicsRectItem):
"""These are the actual, playable, keys. And key switches"""
def __init__(self, parentPiano, pitch, color:QtGui.QColor, blackKey:bool):
super().__init__(0, 0, WIDTH, STAFFLINEGAP) #x, y, w, h
self.parentPiano = parentPiano
self.pitch = pitch
self.setEnabled(True) #Not clickable, still visible.
self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
self.setAcceptHoverEvents(True)
self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity, True)
self.setBrush(color)
self.state = False
self.blackKey = blackKey
self.hide()
def setPlayable(self, state:bool):
c = QtGui.QColor()
self.state = state
if state:
if self.blackKey:
c.setNamedColor("#0c0c0c")
else:
c.setNamedColor("#fdfdff")
else:
#Only if the instrument is activated. Not loaded instruments are just dark black and white
if self.blackKey:
c.setNamedColor("#444444")
else:
c.setNamedColor("#999999")
self.setBrush(c)
def hoverEnterEvent(self, event):
if self.state:
l = self.parentPiano.numberLabels[self.pitch].currentLabel
if l:
self.parentPiano.parentView.mainWindow.statusBar().showMessage(f"[{self.pitch}] {l}")
def hoverLeaveEvent(self, event):
self.parentPiano.parentView.mainWindow.statusBar().showMessage("")
class BlackKey(QtWidgets.QGraphicsRectItem):
def __init__(self, parentPiano):
super().__init__(0, 0, WIDTH, STAFFLINEGAP) #x, y, w, h
self.parentPiano = parentPiano
self.setPen(QtGui.QPen(QtCore.Qt.NoPen))
c = QtGui.QColor()
c.setNamedColor("#444444")
self.setBrush(c)
self.setOpacity(1.0)
self.setEnabled(False)
self.setAcceptedMouseButtons(QtCore.Qt.NoButton)