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.
1146 lines
54 KiB
1146 lines
54 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of Patroneo ( https://www.laborejo.org )
|
|
|
|
Laborejo 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")
|
|
|
|
from time import time
|
|
import engine.api as api #Session is already loaded and created, no duplication.
|
|
from template.engine import pitch
|
|
from PyQt5 import QtCore, QtGui, QtWidgets, QtOpenGL
|
|
|
|
SIZE_UNIT = 40
|
|
SIZE_TOP_OFFSET = 40
|
|
SIZE_BOTTOM_OFFSET = 35
|
|
SIZE_RIGHT_OFFSET = 80
|
|
|
|
_zValuesRelativeToScene = { #Only use for objects added directly to the scene, not children.
|
|
"off" : 1,
|
|
"shadow" : 4,
|
|
"step" :5,
|
|
"scale" : 20, #so the drop down menu is above the steps
|
|
}
|
|
|
|
class PatternGrid(QtWidgets.QGraphicsScene):
|
|
"""
|
|
data example for c'4 d'8 e' f'2 in a 4/4 timesig. Actually in any timesig.
|
|
[
|
|
{"index:0", "pitch": 60, "factor": 1 , "velocity":110},
|
|
{"index:1", "pitch": 62, "factor": 0.5 , "velocity":90},
|
|
{"index:1.5", "pitch": 64, "factor": 0.5 , "velocity":80},
|
|
{"index:2", "pitch": 65, "factor": 2 , "velocity":60},
|
|
]
|
|
|
|
We delete most of our content and redraw if the timesignature changes.
|
|
|
|
We draw all steps at once, even if hidden.
|
|
|
|
If the active track changes we only change the status (color) of steps but not the
|
|
steps themselves. We do not save any track state here but always react dynamically
|
|
and sent every change we do ourselves simply with the currentTrackId
|
|
|
|
"""
|
|
|
|
def __init__(self, parentView):
|
|
super().__init__()
|
|
self.parentView = parentView
|
|
self.statusMessage = self.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
|
|
self._steps = {} # (x,y):Step()
|
|
self._labels = [] #Step numbers
|
|
|
|
self._tracks = {} #tr-id:exportDict #kept up to date by various callbacks.
|
|
|
|
self._zoomFactor = 1 # no save.
|
|
|
|
#Set color, otherwise it will be transparent in window managers or wayland that want that.
|
|
self.backColor = QtGui.QColor(55, 61, 69)
|
|
self.setBackgroundBrush(self.backColor)
|
|
|
|
role = QtGui.QPalette.BrightText
|
|
self.textColor = self.parentView.parentMainWindow.fPalBlue.color(role)
|
|
self.labelColor = QtGui.QColor("black") #save for new step
|
|
|
|
self.trackName = QtWidgets.QGraphicsSimpleTextItem("")
|
|
self.trackName.setBrush(self.textColor)
|
|
#self.addItem(self.trackName)
|
|
self.trackName.setPos(0,0)
|
|
|
|
self.scale = Scale(parentScene=self)
|
|
self.addItem(self.scale)
|
|
self.scale.setPos(-20, SIZE_TOP_OFFSET)
|
|
|
|
self._middleMouseDown = False
|
|
|
|
self.currentHoverStep = None #used by the main window
|
|
|
|
#self.ticksToPixelRatio set by callback_timeSignatureChanged
|
|
self.playhead = Playhead(parentScene = self)
|
|
self.addItem(self.playhead)
|
|
self.playhead.setY(SIZE_TOP_OFFSET)
|
|
|
|
api.callbacks.timeSignatureChanged.append(self.callback_timeSignatureChanged)
|
|
api.callbacks.patternChanged.append(self.callback_patternChanged)
|
|
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
|
|
api.callbacks.subdivisionsChanged.append(self.guicallback_subdivisionsChanged)
|
|
api.callbacks.patternLengthMultiplicatorChanged.append(self.callback_patternLengthMultiplicatorChanged)
|
|
api.callbacks.stepChanged.append(self.callback_stepChanged)
|
|
api.callbacks.removeStep.append(self.callback_removeChanged)
|
|
|
|
def callback_patternLengthMultiplicatorChanged(self, exportDict):
|
|
self._tracks[exportDict["id"]] = exportDict
|
|
self._fullRedraw(exportDict["id"])
|
|
|
|
def callback_timeSignatureChanged(self, howMany, typeInTicks):
|
|
"""
|
|
This is the global base timesig, without patternLengthMultiplicator.
|
|
|
|
The typeInTicks actually changes nothing visually here.
|
|
We only care about howMany steps we offer."""
|
|
self.oneMeasureInTicks = howMany * typeInTicks
|
|
self.ticksToPixelRatio = typeInTicks / SIZE_UNIT
|
|
self._redrawSteps(howMany)
|
|
|
|
def callback_stepChanged(self, stepDict:dict):
|
|
"""This callback reaction was introduced after support for the APCMini.
|
|
Before that we were the only ones to set and remove individual steps
|
|
(and not the entire pattern)
|
|
|
|
stepDict is {'index': 0, 'pitch': 0, 'factor': 1, 'velocity': 90}
|
|
"""
|
|
if (stepDict["index"], stepDict["pitch"]) in self._steps:
|
|
guiStep = self._steps[stepDict["index"], stepDict["pitch"]]
|
|
guiStep.on(velocityAndFactorAndSplit = (stepDict["velocity"], stepDict["factor"], stepDict["split"]))
|
|
|
|
def callback_removeChanged(self, stepDict:dict):
|
|
"""This callback reaction was introduced after support for the APCMini.
|
|
Before that we were the only ones to set and remove individual steps
|
|
(and not the entire pattern)
|
|
|
|
stepDict is {'index': 0, 'pitch': 0, 'factor': 1, 'velocity': 90}
|
|
but factor and velocity don't matter"""
|
|
if (stepDict["index"], stepDict["pitch"]) in self._steps:
|
|
guiStep = self._steps[stepDict["index"], stepDict["pitch"]]
|
|
guiStep.off()
|
|
|
|
|
|
def _redrawSteps(self, howMany, forceId=None):
|
|
"""Draw the empty steps grid. This only happens if the pattern itself changes,
|
|
for example with the time signature or with a GUI subdivision change.
|
|
Normal step on/off is done incrementally.
|
|
"""
|
|
for existingStep in self._steps.values():
|
|
self.removeItem(existingStep)
|
|
self._steps = {} # (x,y):Step()
|
|
|
|
if forceId:
|
|
factor = self._tracks[forceId]["patternLengthMultiplicator"]
|
|
numberOfSteps = self._tracks[forceId]["numberOfSteps"]
|
|
else:
|
|
if not self.parentView.parentMainWindow.currentTrackId or not self.parentView.parentMainWindow.currentTrackId in self._tracks:
|
|
factor = 1 #program start. Purely internal. Will be overriden by a second callback before the user sees it.
|
|
numberOfSteps = 1
|
|
else:
|
|
factor = self._tracks[self.parentView.parentMainWindow.currentTrackId]["patternLengthMultiplicator"]
|
|
numberOfSteps = self._tracks[self.parentView.parentMainWindow.currentTrackId]["numberOfSteps"]
|
|
|
|
#Build a two dimensional grid
|
|
for column in range(howMany*factor):
|
|
for row in range(numberOfSteps):
|
|
x = column * SIZE_UNIT + SIZE_RIGHT_OFFSET
|
|
y = row * SIZE_UNIT + SIZE_TOP_OFFSET
|
|
|
|
step = Step(parentScene=self, column=column, row=row)
|
|
step.setPos(x, y)
|
|
self.addItem(step)
|
|
self._steps[(column, row)] = step
|
|
|
|
#there is always at least one column so we don't need to try step for AttributeError
|
|
w = step.x() + SIZE_UNIT + SIZE_RIGHT_OFFSET #the position of the last step plus one step width and one offset more for good measure
|
|
h = step.y() + SIZE_UNIT + SIZE_TOP_OFFSET + SIZE_BOTTOM_OFFSET #same as w
|
|
self.setSceneRect(0, 0, w, h)
|
|
|
|
def _fullRedraw(self, trackId):
|
|
"""Uses cached values"""
|
|
|
|
exportDict = self._tracks[trackId]
|
|
|
|
updateMode = self.parentView.viewportUpdateMode() #prevent iteration flickering from color changes.
|
|
self.parentView.setViewportUpdateMode(self.parentView.NoViewportUpdate)
|
|
|
|
self._redrawSteps(exportDict["patternBaseLength"], forceId=trackId)
|
|
|
|
self.callback_trackMetaDataChanged(exportDict, force=True) #we need the color when setting pattern changed. This needs to be called before patternChanged
|
|
self.callback_patternChanged(exportDict, force=True) #needs to be called after trackMetaDataChanged for the color.
|
|
self.guicallback_subdivisionsChanged(self._cacheSubdivisonValue)
|
|
self.removeShadows()
|
|
|
|
self.parentView.setViewportUpdateMode(updateMode)
|
|
|
|
def guicallback_chooseCurrentTrack(self, exportDict, newCurrentTrackId):
|
|
"""It is guaranteed that this only happens on a real track change, not twice the same.
|
|
During the track change the currenTrackId value is still the old
|
|
track. There we introduce a force switch that enables self.guicallback_chooseCurrentTrack
|
|
to trigger a redraw even during the track change.
|
|
"""
|
|
assert not exportDict["id"] == self.parentView.parentMainWindow.currentTrackId #this is still the old track.
|
|
self._tracks[exportDict["id"]] = exportDict
|
|
self._fullRedraw(newCurrentTrackId)
|
|
|
|
|
|
def callback_patternChanged(self, exportDict, force=False):
|
|
"""We receive the whole track as exportDict.
|
|
exportDict["pattern"] is the data structure example in the class docstring.
|
|
|
|
We also receive this for every track, no matter if this our current working track.
|
|
So we check if we are the current track. However, that prevents setting up or steps
|
|
on a track change because during the track change the currenTrackId value is still the old
|
|
track. There we introduce a force switch that enables self.guicallback_chooseCurrentTrack
|
|
to trigger a redraw even during the track change.
|
|
"""
|
|
|
|
#Check if the number of steps is different before overwriting the old cached value
|
|
if exportDict["id"] == self.parentView.parentMainWindow.currentTrackId and not exportDict["numberOfSteps"] == self._tracks[exportDict["id"]]["numberOfSteps"]:
|
|
#print ("new steps", exportDict["numberOfSteps"], len(self._steps))
|
|
redrawStepsNeeded = True
|
|
else:
|
|
redrawStepsNeeded = False
|
|
#print ("same steps", exportDict["numberOfSteps"], len(self._steps))
|
|
|
|
#Cache anyway.
|
|
self._tracks[exportDict["id"]] = exportDict
|
|
|
|
if force or exportDict["id"] == self.parentView.parentMainWindow.currentTrackId:
|
|
updateMode = self.parentView.viewportUpdateMode() #prevent iteration flickering from color changes.
|
|
self.parentView.setViewportUpdateMode(self.parentView.NoViewportUpdate)
|
|
|
|
if redrawStepsNeeded:
|
|
self._redrawSteps(exportDict["patternBaseLength"], forceId=exportDict["id"]) #first parameter howMany is rhtyhm, not pitches. redraw fetches pitches itself.
|
|
self.guicallback_subdivisionsChanged(self._cacheSubdivisonValue)
|
|
self.parentView.parentMainWindow.transposeControls.guicallback_chooseCurrentTrack(exportDict, exportDict["id"]) #update steps widget. needed for at least undo/redo
|
|
|
|
for step in self._steps.values():
|
|
step.off()
|
|
|
|
for noteDict in exportDict["pattern"]:
|
|
x = noteDict["index"]
|
|
y = noteDict["pitch"]
|
|
velocityAndFactorAndSplit = (noteDict["velocity"], noteDict["factor"], noteDict["split"])
|
|
self._steps[(x,y)].on(velocityAndFactorAndSplit=velocityAndFactorAndSplit, exceedsPlayback=noteDict["exceedsPlayback"])
|
|
|
|
self.scale.buildScale(exportDict["numberOfSteps"])
|
|
self.scale.setScale(exportDict["scale"])
|
|
self.scale.setNoteNames(exportDict["simpleNoteNames"])
|
|
|
|
self.parentView.setViewportUpdateMode(updateMode)
|
|
|
|
#else ignore. We fetch new data when we change the track anyway.
|
|
|
|
|
|
#Deprectated. We do incremental updates now. But who knows if we need it in the future. I doubt it...
|
|
#def sendCurrentPatternToEngine(self):
|
|
# pattern = [step.export() for step in self._steps.values() if step.status] #engine compatible backend dict of the current GUI state. Send the switched on values.
|
|
# api.setPattern(trackId=self.parentView.parentMainWindow.currentTrackId, patternList=pattern)
|
|
|
|
def callback_trackMetaDataChanged(self, exportDict, force=False):
|
|
"""
|
|
During the track change the currenTrackId value is still the old
|
|
track. There we introduce a force switch that enables self.guicallback_chooseCurrentTrack
|
|
to trigger a redraw even during the track change.
|
|
"""
|
|
|
|
self._tracks[exportDict["id"]] = exportDict
|
|
|
|
self.scale.buildScale(exportDict["numberOfSteps"])
|
|
self.scale.setScale(exportDict["scale"])
|
|
self.scale.setNoteNames(exportDict["simpleNoteNames"])
|
|
|
|
|
|
if force or self.parentView.parentMainWindow.currentTrackId == exportDict["id"]:
|
|
self.trackName.setText(exportDict["sequencerInterface"]["name"])
|
|
self.trackName.show()
|
|
c = QtGui.QColor(exportDict["color"])
|
|
self.currentColor = c
|
|
if c.lightness() > 127: #between 0 (for black) and 255 (for white)
|
|
labelColor = QtGui.QColor("black")
|
|
else:
|
|
labelColor = QtGui.QColor("white")
|
|
self.labelColor = labelColor #save for new events
|
|
|
|
for step in self._steps.values():
|
|
if step.status:
|
|
step.setBrush(c)
|
|
step.velocityNumber.setBrush(labelColor)
|
|
|
|
|
|
def guicallback_subdivisionsChanged(self, newValue):
|
|
"""handle measuresPerGroup"""
|
|
self._cacheSubdivisonValue = newValue
|
|
|
|
#Draw labels
|
|
for existinglabel in self._labels:
|
|
self.removeItem(existinglabel)
|
|
self._labels = []
|
|
|
|
for (x,y), step in self._steps.items():
|
|
step.main = not x % newValue # what magic is this?
|
|
step.setApperance()
|
|
groupCounter, beatNumber = divmod(x, newValue)
|
|
if not beatNumber:
|
|
label = QtWidgets.QGraphicsSimpleTextItem(str(groupCounter+1))
|
|
self.addItem(label)
|
|
label.setBrush(self.textColor)
|
|
x = x * SIZE_UNIT
|
|
x += SIZE_RIGHT_OFFSET
|
|
label.setPos(x+3, SIZE_TOP_OFFSET-13)
|
|
self._labels.append(label)
|
|
|
|
def showVelocities(self):
|
|
for patternStep in self._steps.values():
|
|
if patternStep.status:
|
|
patternStep.velocityNumber.show()
|
|
|
|
def hideVelocities(self):
|
|
if not self.parentView.parentMainWindow.ui.actionVelocitiesAlwaysVisible.isChecked():
|
|
for patternStep in self._steps.values():
|
|
patternStep.velocityNumber.hide()
|
|
|
|
def decideYourselfIToShowVelocities(self):
|
|
"""Called by the menu action to always show velocities"""
|
|
if self.parentView.parentMainWindow.ui.actionVelocitiesAlwaysVisible.isChecked():
|
|
self.showVelocities()
|
|
else:
|
|
self.hideVelocities()
|
|
|
|
|
|
def mousePressEvent(self, event):
|
|
self._middleMouseDown = False
|
|
if event.button() == QtCore.Qt.MiddleButton:
|
|
self._middleMouseDown = True
|
|
self._lastRow = None
|
|
self._play(event)
|
|
event.accept()
|
|
|
|
#Use hover velocity control instead
|
|
#if not type(self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())) is Step:
|
|
# self.showVelocities()
|
|
else:
|
|
event.ignore()
|
|
super().mousePressEvent(event)
|
|
|
|
def _off(self):
|
|
if not self._lastRow is None:
|
|
api.noteOff(self.parentView.parentMainWindow.currentTrackId, self._lastRow)
|
|
self._lastRow = None
|
|
|
|
def _play(self, event):
|
|
assert self._middleMouseDown
|
|
if not self.parentView.parentMainWindow.currentTrackId:
|
|
return
|
|
row = (event.scenePos().y() - SIZE_TOP_OFFSET) / SIZE_UNIT
|
|
if row >= 0:
|
|
row = int(row)
|
|
else:
|
|
row = -1
|
|
|
|
x = event.scenePos().x()
|
|
inside = x > SIZE_RIGHT_OFFSET and x < self.sceneRect().width() - SIZE_RIGHT_OFFSET
|
|
|
|
|
|
maxRow = self._tracks[self.parentView.parentMainWindow.currentTrackId]["numberOfSteps"]
|
|
|
|
if ( row < 0 or row >= maxRow ) or not inside :
|
|
row = None
|
|
|
|
if not row == self._lastRow:
|
|
if not self._lastRow is None:
|
|
api.noteOff(self.parentView.parentMainWindow.currentTrackId, self._lastRow)
|
|
|
|
if not row is None:
|
|
api.noteOn(self.parentView.parentMainWindow.currentTrackId, row)
|
|
|
|
self._lastRow = row
|
|
|
|
def mouseMoveEvent(self, event):
|
|
"""Event button is always 0 in a mouse move event"""
|
|
if self._middleMouseDown:
|
|
event.accept()
|
|
self._play(event)
|
|
else:
|
|
#Not for us, trigger, let other items decide.
|
|
event.ignore()
|
|
super().mouseMoveEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
if self._middleMouseDown:
|
|
self._off()
|
|
self._middleMouseDown = False
|
|
if event.button() == QtCore.Qt.MiddleButton:
|
|
event.accept()
|
|
self._lastRow = None
|
|
self.hideVelocities()
|
|
else:
|
|
event.ignore()
|
|
super().mousePressEvent(event)
|
|
|
|
|
|
def contextMenuEvent(self, event):
|
|
menu = QtWidgets.QMenu()
|
|
|
|
trackId = self.parentView.parentMainWindow.currentTrackId
|
|
|
|
potentialStep = self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())
|
|
potentialSteps = [st for st in self.items(event.scenePos()) if type(st) is Step]
|
|
|
|
#An over-long active step is stacked before the actual step. We need to either find the lowest
|
|
#or the one furthest to the right because active notes can't have negative duration
|
|
if potentialSteps:
|
|
potentialStep = max(potentialSteps, key=lambda ls: ls.column)
|
|
else:
|
|
potentialStep = None
|
|
|
|
listOfLabelsAndFunctions = [
|
|
(QtCore.QCoreApplication.translate("EventContextMenu", "Invert Steps"), lambda: api.patternInvertSteps(trackId)),
|
|
(QtCore.QCoreApplication.translate("EventContextMenu", "All Steps On"), lambda: api.patternOnAllSteps(trackId)),
|
|
(QtCore.QCoreApplication.translate("EventContextMenu", "All Steps Off"), lambda: api.patternOffAllSteps(trackId)),
|
|
]
|
|
|
|
if potentialStep:
|
|
listOfLabelsAndFunctions.insert(0, (QtCore.QCoreApplication.translate("EventContextMenu", "Repeat to step {} incl. to fill Row").format(potentialStep.column+1), lambda: api.patternRowRepeatFromStep(trackId, potentialStep.row, potentialStep.column)))
|
|
listOfLabelsAndFunctions.insert(0, (QtCore.QCoreApplication.translate("EventContextMenu", "Clear Row"), lambda: api.patternClearRow(trackId, potentialStep.row)))
|
|
listOfLabelsAndFunctions.insert(0, (QtCore.QCoreApplication.translate("EventContextMenu", "Invert Row"), lambda: api.patternInvertRow(trackId, potentialStep.row)))
|
|
|
|
for text, function in listOfLabelsAndFunctions:
|
|
if function is None:
|
|
l = QtWidgets.QLabel(text)
|
|
l.setAlignment(QtCore.Qt.AlignCenter)
|
|
a = QtWidgets.QWidgetAction(menu)
|
|
a.setDefaultWidget(l)
|
|
menu.addAction(a)
|
|
else:
|
|
a = QtWidgets.QAction(text, menu)
|
|
menu.addAction(a)
|
|
a.triggered.connect(function)
|
|
|
|
pos = QtGui.QCursor.pos()
|
|
pos.setY(pos.y() + 5)
|
|
menu.exec_(pos)
|
|
|
|
def wheelEvent(self, event):
|
|
"""zoom, otherwise ignore event"""
|
|
if QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.ControlModifier:
|
|
if event.delta() > 0: #zoom in
|
|
self._zoomFactor = min(5, round(self._zoomFactor + 0.25, 2))
|
|
else: #zoom out
|
|
self._zoomFactor = max(0.1, round(self._zoomFactor - 0.25, 2))
|
|
self._zoom(event)
|
|
event.accept()
|
|
elif QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.AltModifier:
|
|
potentialStep = self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())
|
|
if type(potentialStep) is Step:
|
|
trackId = self.parentView.parentMainWindow.currentTrackId
|
|
if event.delta() > 0:
|
|
delta = 2
|
|
else:
|
|
delta = -2
|
|
api.patternRowChangeVelocity(trackId, potentialStep.row, delta)
|
|
self.showVelocities() #removed in self.keyReleaseEvent
|
|
event.accept()
|
|
else:
|
|
event.ignore()
|
|
super().wheelEvent(event)
|
|
else:
|
|
event.ignore()
|
|
super().wheelEvent(event)
|
|
|
|
def keyReleaseEvent(self, event):
|
|
"""Complementary for wheelEvent with Alt to change row velocity.
|
|
It is hard to detect the Alt key. We just brute force because there are not many
|
|
keyPresses in Patroneo at all."""
|
|
self.hideVelocities()
|
|
event.ignore()
|
|
super().keyReleaseEvent(event)
|
|
|
|
def _zoom(self, event):
|
|
if 0.1 < self._zoomFactor < 5:
|
|
self.parentView.resetTransform()
|
|
self.parentView.scale(self._zoomFactor, self._zoomFactor)
|
|
self.parentView.centerOn(event.scenePos())
|
|
|
|
|
|
def createShadow(self, exportDict):
|
|
"""Receives steps from another track and display them as shadoy steps in the current one
|
|
as a reference. Creating a new shadow does not delete the old one.
|
|
|
|
It is possible that the source pattern is longer (multiplicator) than our own. We ignore
|
|
all shadows beyond our own length.
|
|
|
|
Same is true if the source has more notes (pitches) than ours. We do naive x/y mapping.
|
|
Making sense of that is up to the user :)
|
|
|
|
It is not a bug that shadows always appear as exactly one step wide. The factor is ignored
|
|
because the user needs to click on the shadow field to activate it. We use the actual
|
|
step-rectangles as shadows, which need to be clickable and not covered by another, overlong
|
|
shadow-note.
|
|
"""
|
|
for x, y in ((s["index"], s["pitch"]) for s in exportDict["pattern"]):
|
|
if (x,y) in self._steps:
|
|
self._steps[(x,y)].shadow = True # (x,y):Step()
|
|
self._steps[(x,y)].setApperance()
|
|
|
|
def removeShadows(self):
|
|
for step in self._steps.values():
|
|
if step.shadow:
|
|
step.shadow = False
|
|
step.setApperance()
|
|
|
|
class Step(QtWidgets.QGraphicsRectItem):
|
|
"""The representation of a note"""
|
|
|
|
def __init__(self, parentScene, column, row): #Factor and Velocity are set on activation
|
|
self.parentScene = parentScene
|
|
self.statusMessage = self.parentScene.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
|
|
self.column = column #grid coordinates, not pixels
|
|
self.row = row
|
|
offset = 2
|
|
self.offset = offset
|
|
self.defaultSize = (offset, offset, SIZE_UNIT-offset*2, SIZE_UNIT-offset*2) #x, y, w, h
|
|
super().__init__(*self.defaultSize)
|
|
self.setAcceptHoverEvents(True)
|
|
self.setFlags(QtWidgets.QGraphicsItem.ItemIsFocusable) #to receive key press events
|
|
self.main = True
|
|
self.exceedsPlayback = False
|
|
self.factor = api.DEFAULT_FACTOR
|
|
self.status = False
|
|
self._factorChangeAllowed = False #during drag and drop this will be True. Used in the mouse steps.
|
|
self.shadow = False
|
|
#Velocity
|
|
self._rememberVelocity = None #check if to send new data on api in self.hoverLeaveEvent
|
|
self.velocityNumber = QtWidgets.QGraphicsSimpleTextItem()
|
|
self.velocityNumber.setParentItem(self)
|
|
self.velocityNumber.setBrush(self.parentScene.labelColor)
|
|
self.velocityNumber.setPos(offset*2,offset*2) #that is not pretty but you can see it under the cursor
|
|
|
|
#Split
|
|
self.split = 1
|
|
self._rememberSplit = None #check if to send new data on api in self.hoverLeaveEvent
|
|
self.splitNumber = QtWidgets.QGraphicsSimpleTextItem()
|
|
self.splitNumber.setParentItem(self)
|
|
self.splitNumber.setBrush(self.parentScene.labelColor)
|
|
self.splitNumber.setPos(SIZE_UNIT-offset*8,SIZE_UNIT-offset*9) # lower right corner. It is always "/n" where n is a single digit. So a fixed position and width.
|
|
#set in setApperance if > 1
|
|
|
|
if self.parentScene.parentView.parentMainWindow.ui.actionVelocitiesAlwaysVisible.isChecked():
|
|
self.velocityNumber.show()
|
|
else:
|
|
self.velocityNumber.hide() #only visible during mouse wheel event
|
|
|
|
#The data section. On creation all the steps are uninitialized. They are off and hold no musical values
|
|
#self.velocity = set in self.useDefaultValues . We don't set it at all here to have a clear Exception when the program tries to access it.
|
|
#self.factor = set in self.useDefaultValues . We don't set it at all here to have a clear Exception when the program tries to access it.
|
|
#self.pitch #this is determined by the position on the grid
|
|
|
|
self.setApperance() #sets color, size and exceedPlayback warning. not velocity.
|
|
|
|
|
|
|
|
def setApperance(self):
|
|
"""sets color, main/sub size and exceedPlayback warning. not velocity.
|
|
This gets called quite often. On mouse down and on release for starters."""
|
|
|
|
def setWidth():
|
|
if not self.exceedsPlayback and self.x() + self.rect().width() + SIZE_RIGHT_OFFSET> self.parentScene.sceneRect().right():
|
|
self.exceedsPlayback = True
|
|
if self.exceedsPlayback:
|
|
rect = self.rect()
|
|
maximumWidth = self.parentScene.sceneRect().right() - self.x() - SIZE_RIGHT_OFFSET - self.offset*2
|
|
rect.setWidth(maximumWidth)
|
|
self.setRect(rect)
|
|
else:
|
|
rect = self.rect()
|
|
rect.setWidth(SIZE_UNIT * self.factor - self.offset*2)
|
|
self.setRect(rect)
|
|
|
|
if self.status: #step/note is on
|
|
setWidth()
|
|
assert self.parentScene.currentColor
|
|
self.setBrush(self.parentScene.currentColor)
|
|
self.velocityNumber.setBrush(self.parentScene.labelColor)
|
|
if self.split > 1:
|
|
self.splitNumber.show()
|
|
self.splitNumber.setBrush(self.parentScene.labelColor)
|
|
self.splitNumber.setText("/" + str(self.split))
|
|
else:
|
|
self.splitNumber.hide()
|
|
|
|
self.setZValue(_zValuesRelativeToScene["step"])
|
|
else: #step/note is off
|
|
self.setOpacity(1)
|
|
if self.shadow:
|
|
setWidth()
|
|
color = self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.Shadow) #this is already an existing instance
|
|
self.setOpacity(0.3)
|
|
self.setZValue(_zValuesRelativeToScene["shadow"])
|
|
elif self.main:
|
|
color = self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.AlternateBase) #this is already an existing instance
|
|
self.setZValue(_zValuesRelativeToScene["off"])
|
|
else:
|
|
color = self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.Base) #this is already an existing instance
|
|
self.setZValue(_zValuesRelativeToScene["off"])
|
|
self.setBrush(color)
|
|
|
|
@property
|
|
def velocity(self):
|
|
return self._velocity
|
|
|
|
@velocity.setter
|
|
def velocity(self, value):
|
|
self._velocity = value
|
|
self.velocityNumber.setText(str(value))
|
|
self.setOpacity(self._compress(value, 1, 127, 0.4, 1.0))
|
|
|
|
def _compress(self, input, inputLowest, inputHighest, outputLowest, outputHighest):
|
|
return (input-inputLowest) / (inputHighest-inputLowest) * (outputHighest-outputLowest) + outputLowest
|
|
|
|
def export(self):
|
|
"""Make a dict to send to the engine"""
|
|
return {
|
|
"index":self.column,
|
|
"pitch":self.row,
|
|
"factor":self.factor,
|
|
"velocity":self.velocity,
|
|
"split":self.split,
|
|
}
|
|
|
|
def useDefaultValues(self):
|
|
self.velocity = api.getAverageVelocity(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId) #already sets opacity and velocityNumber
|
|
self._rememberVelocity = self.velocity
|
|
self.factor = api.DEFAULT_FACTOR
|
|
self.split=1
|
|
self._rememberSplit = self.split
|
|
self.initalized = True
|
|
|
|
def on(self, velocityAndFactorAndSplit=None, exceedsPlayback=None):
|
|
"""velocityAndFactorAndSplit is a tuple"""
|
|
if velocityAndFactorAndSplit: #on load / by callback
|
|
self.velocity, self.factor, self.split = velocityAndFactorAndSplit
|
|
else: #User clicked on an empty field.
|
|
self.useDefaultValues()
|
|
|
|
if self.parentScene.parentView.parentMainWindow.ui.actionVelocitiesAlwaysVisible.isChecked():
|
|
self.velocityNumber.show()
|
|
|
|
self.exceedsPlayback = exceedsPlayback
|
|
|
|
assert self.factor > 0
|
|
rect = self.rect()
|
|
#rect.setWidth(self.defaultSize[2] * self.factor)
|
|
rect.setWidth(SIZE_UNIT * self.factor - self.offset*2)
|
|
self.setRect(rect)
|
|
|
|
self.status = True
|
|
self.setApperance() #sets color, main/sub size and exceedPlayback warning
|
|
|
|
def off(self):
|
|
self.status = False
|
|
self.setRect(*self.defaultSize)
|
|
self.setApperance() #sets color, main/sub size and exceedPlayback warning
|
|
self.velocityNumber.hide()
|
|
self.splitNumber.hide()
|
|
|
|
|
|
def mousePressEvent(self, event):
|
|
if event.button() == QtCore.Qt.LeftButton:
|
|
event.accept()
|
|
if self.status:
|
|
self.off()
|
|
api.removeStep(self.parentScene.parentView.parentMainWindow.currentTrackId, self.column, self.row)
|
|
else:
|
|
self.on()
|
|
self.parentScene.currentHoverStep = self
|
|
self._factorChangeAllowed = True
|
|
self._factorStartTime = time() #see mouseReleaseEvent
|
|
else:
|
|
event.ignore()
|
|
|
|
def mouseMoveEvent(self, event):
|
|
if self._factorChangeAllowed:
|
|
# < is left to right
|
|
# > is right to left
|
|
event.accept()
|
|
rect = self.rect()
|
|
if event.lastScenePos().x() < event.scenePos().x():
|
|
new = event.scenePos().x() - self.x()
|
|
else:
|
|
new = max(self.defaultSize[2]/2, event.scenePos().x() - self.x()) #pixel values, not tick, nor factor
|
|
rect.setRight(new)
|
|
self.setRect(rect)
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
if self._factorChangeAllowed:
|
|
assert self.status
|
|
self._factorChangeAllowed = False
|
|
width = self.rect().width() + self.offset*2
|
|
value = width / SIZE_UNIT
|
|
elapsedTime = time() - self._factorStartTime #to prevent hectic mouse pressing from triggering the factor we only accept a change if a certain time treshold was passed
|
|
if (elapsedTime > 0.2 and value >= 0.5):# or value == 0.5:
|
|
self.factor = value
|
|
self.setApperance() #sets color, size and exceedPlayback warning
|
|
else: # A quick mouseclick
|
|
assert self.factor == 1
|
|
self.setRect(*self.defaultSize) #we reset this in case something goes wrong. If everything is all right we will a receive a callback to set the width anyway, before the user sees anything.
|
|
self.setApperance() #sets color, size and exceedPlayback warning
|
|
api.setStep(self.parentScene.parentView.parentMainWindow.currentTrackId, self.export())
|
|
event.accept()
|
|
|
|
def hoverEnterEvent(self, event):
|
|
"""Hover events are only triggered if no mouse button is down. The mouse got grabbed
|
|
by the item. Which makes sense because we want to receive mouseRelease on an item even
|
|
if the mouse cursor is not on that item anymore"""
|
|
if self.status:
|
|
event.accept()
|
|
self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Note: Left click do deactivate. Middle click to listen. MouseWheel to change volume (+ALT key to change entire row). Number keys to split. Right click for pattern options."))
|
|
self.parentScene.currentHoverStep = self #this is replicated in self.mousePressEvent, turning the note on
|
|
self._rememberVelocity = self.velocity
|
|
self._rememberSplit = self.split
|
|
else:
|
|
self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Left click do activate note. Middle click to listen. Right click for pattern options."))
|
|
self.parentScene.currentHoverStep = None
|
|
event.ignore()
|
|
|
|
def hoverLeaveEvent(self, event):
|
|
"""Hover events are only triggered if no mouse button is down. The mouse got grabbed
|
|
by the item. Which makes sense because we want to receive mouseRelease on an item even
|
|
if the mouse cursor is not on that item anymore"""
|
|
if not self.parentScene.parentView.parentMainWindow.ui.actionVelocitiesAlwaysVisible.isChecked():
|
|
self.velocityNumber.hide()
|
|
self.parentScene.currentHoverStep = None
|
|
self.statusMessage("")
|
|
if self.status:
|
|
event.accept()
|
|
if self.status and (self.velocity != self._rememberVelocity or self.split != self._rememberSplit): #check if there is an actual data change.
|
|
api.setStep(self.parentScene.parentView.parentMainWindow.currentTrackId, self.export())
|
|
self._rememberVelocity = self.velocity
|
|
else:
|
|
event.ignore()
|
|
|
|
def wheelEvent(self, event):
|
|
"""This buffers until hoverLeaveEvent and then the new value is sent in self.hoverLeaveEvent"""
|
|
if self.status:
|
|
event.accept()
|
|
self.velocityNumber.show()
|
|
if event.delta() > 0:
|
|
self.velocity += 2
|
|
if self.velocity >= 127:
|
|
self.velocity = 127
|
|
else:
|
|
self.velocity -= 2
|
|
if self.velocity < 0:
|
|
self.velocity = 0
|
|
else:
|
|
event.ignore()
|
|
|
|
|
|
class Scale(QtWidgets.QGraphicsRectItem):
|
|
"""SpinBoxes on the left side of the steps"""
|
|
|
|
|
|
def __init__(self, parentScene):
|
|
super().__init__(0,0,0,0)
|
|
self.parentScene = parentScene
|
|
self.statusMessage = self.parentScene.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
|
|
self.pitchWidgets = [] #sorted from top to bottom in Step Rect and scene coordinates
|
|
self.simpleNoteNames = None #list of 128 notes. use index with note name. Can be changed at runtime. Never empty.
|
|
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
|
|
#self.buildScale(1) #also sets the positions of the buttons above
|
|
|
|
self.setAcceptHoverEvents(True)
|
|
|
|
def hoverEnterEvent(self, event):
|
|
self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Pitch in MIDI half-tones. 60 = middle C. Enter number or spin the mouse wheel to change."))
|
|
def hoverLeaveEvent(self, event):
|
|
self.statusMessage("")
|
|
|
|
def callback_trackMetaDataChanged(self, exportDict):
|
|
#Order matters. We need to set the notenames before the scale.
|
|
self.buildScale(exportDict["numberOfSteps"])
|
|
self.setNoteNames(exportDict["simpleNoteNames"])
|
|
self.setScale(exportDict["scale"])
|
|
|
|
def buildScale(self, numberOfSteps):
|
|
"""This is used for building the GUI as well as switching the active track.
|
|
We only add, show and hide. Never delete steps.
|
|
|
|
Before using self.pitchWidgets to derive a scale for the engine we need to filter out
|
|
hidden ones.
|
|
"""
|
|
|
|
stepsSoFar = len(self.pitchWidgets)
|
|
|
|
if numberOfSteps < stepsSoFar: #reduce only by hiding pitchWidgets
|
|
for i, pitchWidget in enumerate(self.pitchWidgets):
|
|
if i < numberOfSteps:
|
|
pitchWidget.show()
|
|
else:
|
|
pitchWidget.hide()
|
|
|
|
else: #create new steps, incrementally
|
|
for i in range(numberOfSteps):
|
|
if i < stepsSoFar: #already exists
|
|
self.pitchWidgets[i].show()
|
|
else:
|
|
p = PitchWidget(parentItem=self)
|
|
y = i * SIZE_UNIT
|
|
p.setParentItem(self)
|
|
p.setPos(-65, y+10)
|
|
self.pitchWidgets.append(p)
|
|
#self.setRect(0,0, SIZE_RIGHT_OFFSET, p.y() + SIZE_UNIT) #p is the last of the 8.
|
|
|
|
def setScale(self, scaleList):
|
|
"""We receive from top to bottom, in step rect coordinates. This is not sorted after
|
|
pitches! Pitches can be any order the user wants.
|
|
"""
|
|
for widget, scaleMidiPitch in zip(self.pitchWidgets, scaleList):
|
|
widget.blockSignals(True) #choose active track triggers valueChanged, which triggers sendToENgine
|
|
widget.spinBox.blockSignals(True) #choose active track triggers valueChanged, which triggers sendToENgine
|
|
widget.spinBox.setValue(scaleMidiPitch)
|
|
widget.rememberLastValue = scaleMidiPitch
|
|
widget.blockSignals(False)
|
|
widget.spinBox.blockSignals(False)
|
|
|
|
def setNoteNames(self, pNoteNames):
|
|
"""A list of 128 strings. Gets only called by the callback.
|
|
E.g. it happens when you switch the active gui track"""
|
|
#if pNoteNames in pitch.notenames.keys():
|
|
# self.simpleNoteNames = pitch.notenames[pNoteNames]
|
|
#else:
|
|
self.simpleNoteNames = pNoteNames
|
|
for pitchWidget in self.pitchWidgets:
|
|
pitchWidget.spinBoxValueChanged() #change all current pitchWidgets
|
|
|
|
def sendToEngine(self, callback=True):
|
|
result = [widget.spinBox.value() for widget in self.pitchWidgets if widget.isVisible()] #hidden pitchwidgets are old ones that are not present in the current pattenrns scale.
|
|
#result.reverse()
|
|
trackId = self.parentScene.parentView.parentMainWindow.currentTrackId
|
|
if trackId: #startup check
|
|
api.setScale(trackId, scale=result, callback=callback)
|
|
|
|
class TransposeControls(QtWidgets.QWidget):
|
|
"""
|
|
Created in mainwindow.py _populatePatternToolbar()
|
|
|
|
The row of widgets between the track structures and the patterns step grid
|
|
Communication with the scale spinBoxes is done via api callbacks. We just fire and forget
|
|
|
|
"""
|
|
|
|
#Not working. the translation generate works statically. translatedScales = [QtCore.QT_TRANSLATE_NOOP("Scale", scale) for scale in api.schemes]
|
|
#No choice but to prepare the translations manually here. At least we do not need to watch for the order.
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Major")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Minor")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Dorian")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Phrygian")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Lydian")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Mixolydian")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Locrian")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Blues")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Hollywood")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Chromatic")
|
|
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "English")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Lilypond")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "German")
|
|
QtCore.QT_TRANSLATE_NOOP("Scale", "Drums GM")
|
|
|
|
|
|
def __init__(self, parentScene):
|
|
self.parentScene = parentScene
|
|
super().__init__()
|
|
|
|
self.exportDict = None
|
|
|
|
layout = QtWidgets.QHBoxLayout()
|
|
layout.setSpacing(0)
|
|
layout.setContentsMargins(0,0,0,0)
|
|
self.setLayout(layout)
|
|
|
|
transposeUp = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("TransposeControls", "+Half Tone"))
|
|
transposeUp.clicked.connect(self.transposeUp)
|
|
transposeUp.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Transpose the whole scale up a half tone (+1 midi note)"))
|
|
layout.addWidget(transposeUp)
|
|
|
|
transposeDown = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("TransposeControls", "-Half Tone"))
|
|
transposeDown.clicked.connect(self.transposeDown)
|
|
transposeDown.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Transpose the whole scale down a half tone (-1 midi note)"))
|
|
layout.addWidget(transposeDown)
|
|
|
|
transposeUpOctave = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("TransposeControls", "+Octave"))
|
|
transposeUpOctave.clicked.connect(self.transposeUpOctave)
|
|
transposeUpOctave.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Transpose the whole scale up an octave (+12 midi notes)"))
|
|
layout.addWidget(transposeUpOctave)
|
|
|
|
transposeDownOctave = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("TransposeControls", "-Octave"))
|
|
transposeDownOctave.clicked.connect(self.transposeDownOctave)
|
|
transposeDownOctave.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Transpose the whole scale down an octave (-12 midi notes)"))
|
|
layout.addWidget(transposeDownOctave)
|
|
|
|
translatedSchemes = [QtCore.QCoreApplication.translate("Scale", scheme) for scheme in api.schemes]
|
|
|
|
transposeToScale = QtWidgets.QComboBox()
|
|
self._transposeToScaleWidget = transposeToScale
|
|
transposeToScale.addItems([QtCore.QCoreApplication.translate("TransposeControls","Set Scale to:")] + translatedSchemes) #This is a hack. QProxyWidgets will draw outside of the view and cannot be seen anymore. We reset to the 0th entry after each change.
|
|
transposeToScale.activated.connect(self.transposeToScale) #activated, not changend. even when choosing the same item
|
|
transposeToScale.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Take the bottom note and build a predefined scale from it upwards."))
|
|
layout.addWidget(transposeToScale)
|
|
|
|
|
|
self._comboBoxNoteNames = QtWidgets.QComboBox()
|
|
translatedNotenames = [QtCore.QCoreApplication.translate("Scale", scheme) for scheme in sorted(list(pitch.simpleNoteNames.keys()))]
|
|
self._comboBoxNoteNames.addItems([QtCore.QCoreApplication.translate("TransposeControls","Set Notenames to:")] + translatedNotenames)
|
|
self._comboBoxNoteNames.activated.connect(self._changeNoteNamesByDropdown) #activated, not changend. even when choosing the same item
|
|
self._comboBoxNoteNames.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Use this scheme as note names."))
|
|
layout.addWidget(self._comboBoxNoteNames)
|
|
|
|
self._numberOfStepsChooser = QtWidgets.QSpinBox()
|
|
self._numberOfStepsChooser.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Choose how many different notes does this pattern should have."))
|
|
self._numberOfStepsChooser.setMinimum(1)
|
|
self._numberOfStepsChooser.setMaximum(127)
|
|
self._numberOfStepsChooser.setSuffix(QtCore.QCoreApplication.translate("TransposeControls", " Notes"))
|
|
self._numberOfStepsChooser.valueChanged.connect(self._reactToNumberOfStepsChooser)
|
|
layout.addWidget(self._numberOfStepsChooser)
|
|
|
|
def guicallback_chooseCurrentTrack(self, exportDict, newCurrentTrackId):
|
|
"""Called by mainwindow chooseCurrentTrack()"""
|
|
self.exportDict = exportDict
|
|
self._numberOfStepsChooser.blockSignals(True)
|
|
self._numberOfStepsChooser.setValue(exportDict["numberOfSteps"])
|
|
self._numberOfStepsChooser.blockSignals(False)
|
|
|
|
def _reactToNumberOfStepsChooser(self):
|
|
self._numberOfStepsChooser.blockSignals(True)
|
|
api.resizePatternWithoutScale(self.exportDict["id"], self._numberOfStepsChooser.value())
|
|
self._numberOfStepsChooser.blockSignals(False)
|
|
|
|
def _changeNoteNamesByDropdown(self, index):
|
|
if index > 0:
|
|
index -= 1 # we inserted our "Set NoteNames to" at index 0 shifting the real values +1.
|
|
|
|
schemes = sorted(pitch.simpleNoteNames.keys())
|
|
noteNamesAsString = sorted(pitch.simpleNoteNames.keys())[index]
|
|
simpleNoteNames = pitch.simpleNoteNames[noteNamesAsString]
|
|
api.setSimpleNoteNames(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, simpleNoteNames=simpleNoteNames)
|
|
self._comboBoxNoteNames.blockSignals(True)
|
|
self._comboBoxNoteNames.setCurrentIndex(0)
|
|
self._comboBoxNoteNames.blockSignals(False)
|
|
|
|
def transposeUp(self):
|
|
api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=1)
|
|
|
|
def transposeDown(self):
|
|
api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=-1)
|
|
|
|
|
|
def transposeUpOctave(self):
|
|
api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=12)
|
|
|
|
def transposeDownOctave(self):
|
|
api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=-12)
|
|
|
|
def transposeToScale(self, index):
|
|
"""Index is a shared index, by convention, between our drop-down list and api.schemes"""
|
|
if index > 0:
|
|
index -= 1 # the backend list obviously has no "Set Scale to" on index [0]
|
|
api.setScaleToKeyword(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, keyword=api.schemes[index]) #this schemes must NOT be translated since it is the original key/symbol.
|
|
self._transposeToScaleWidget.blockSignals(True)
|
|
self._transposeToScaleWidget.setCurrentIndex(0)
|
|
self._transposeToScaleWidget.blockSignals(False)
|
|
|
|
class PitchWidget(QtWidgets.QGraphicsProxyWidget):
|
|
""" A PitchWidget has a variable width by nature because the note-name can vary.
|
|
For that reason We need to truncate to match the fixed size.
|
|
Offset and position are set in Scale.buildScale
|
|
"""
|
|
|
|
def __init__(self, parentItem):
|
|
super().__init__()
|
|
self.parentItem = parentItem
|
|
self.spinBox = QtWidgets.QSpinBox()
|
|
#self.spinBox.setFrame(True)
|
|
self.spinBox.setMinimum(0)
|
|
self.spinBox.setMaximum(127)
|
|
self.spinBox.stepBy = self.stepBy
|
|
#self.spinBox.setValue(0) #No init value. This is changed on active track callback
|
|
|
|
|
|
widget = QtWidgets.QWidget()
|
|
layout = QtWidgets.QHBoxLayout()
|
|
layout.setSpacing(0)
|
|
layout.setContentsMargins(0,0,0,0)
|
|
widget.setLayout(layout)
|
|
widget.setStyleSheet(".QWidget { background-color: rgba(0,0,0,0) }") #transparent, but only this widget, hence the leading dot
|
|
|
|
self.label = QtWidgets.QLabel() #changed in spinBoxValueChanged
|
|
self.label.setText("")
|
|
self.label.setFixedSize(110, 18)
|
|
self.label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
|
|
|
|
layout.addWidget(self.label)
|
|
layout.addWidget(self.spinBox)
|
|
|
|
self.setWidget(widget)
|
|
|
|
self.spinBox.wheelEvent = self.spinBoxMouseWheelEvent
|
|
self.spinBox.valueChanged.connect(self.spinBoxValueChanged)
|
|
self.spinBox.editingFinished.connect(self.spinBoxEditingFinished)
|
|
#self.spinBoxValueChanged() #Delay that. The engine Data is not ready yet. It will be sent by the callback
|
|
|
|
self.rememberLastValue = None #set by parent
|
|
|
|
|
|
def midiToNotename(self, midipitch):
|
|
assert self.parentItem.simpleNoteNames, (self.parentItem, self.parentItem.simpleNoteNames)
|
|
try:
|
|
return self.parentItem.simpleNoteNames[midipitch] #includes octave names
|
|
except IndexError as e:
|
|
print (e)
|
|
print ("Midipitch:", midipitch)
|
|
print ("Simple Notename:", self.parentItem.simpleNoteNames)
|
|
exit()
|
|
|
|
def spinBoxValueChanged(self):
|
|
"""This triggers at multiple occasions, include track change.
|
|
Everything that triggers focus lost"""
|
|
self.label.setText(self.midiToNotename(self.spinBox.value()))
|
|
#self.spinBoxEditingFinished(callback=False) . No. This was to send updates when the user uses the arrow keys to change pitch. But that horrible side effects like api.setScale for every note for every track change, incl. undo registration.
|
|
|
|
def spinBoxEditingFinished(self, callback=True):
|
|
if not self.rememberLastValue == self.spinBox.value():
|
|
self.parentItem.sendToEngine(callback)
|
|
self.rememberLastValue = self.spinBox.value()
|
|
|
|
def stepBy(self, n):
|
|
"""Override standard behaviour to make page up and page down go in octaves, not in 10"""
|
|
if n == 10:
|
|
QtWidgets.QAbstractSpinBox.stepBy(self.spinBox, 12)
|
|
elif n == -10:
|
|
QtWidgets.QAbstractSpinBox.stepBy(self.spinBox, -12)
|
|
else:
|
|
QtWidgets.QAbstractSpinBox.stepBy(self.spinBox, n)
|
|
|
|
def spinBoxMouseWheelEvent(self, event):
|
|
"""We cannot use spinBoxValueChanged to send mousewheel scrolling pitch changing directly
|
|
to the engine while editing is still active. this results in signal loops and various
|
|
data corruptions. Fixing this would be far too much work.
|
|
You can either use the arrow keys and press enter, which triggers editingFinished.
|
|
But here we intercept the mousewheel directly."""
|
|
event.ignore()
|
|
QtWidgets.QSpinBox.wheelEvent(self.spinBox, event) #this changes to the new text and therefore the new value. Call BEFORE sendToEngine
|
|
self.parentItem.sendToEngine(callback=False)
|
|
#if event.angleDelta().y() > 0: #up
|
|
#else: #down
|
|
|
|
|
|
class Playhead(QtWidgets.QGraphicsLineItem):
|
|
def __init__(self, parentScene):
|
|
super().__init__(0, 0, 0, SIZE_UNIT) # (x1, y1, x2, y2) #Program start we are just 1 unit high. Changes with actual data.
|
|
self.parentScene = parentScene
|
|
p = QtGui.QPen()
|
|
p.setColor(QtGui.QColor("red"))
|
|
p.setWidth(3)
|
|
#p.setCosmetic(True)
|
|
self.setPen(p)
|
|
api.callbacks.setPlaybackTicks.append(self.setCursorPosition)
|
|
self.setZValue(90)
|
|
|
|
def setCursorPosition(self, tickindex, playbackStatus):
|
|
"""Using modulo makes the playback cursor wrap around and play over the pattern
|
|
eventhough we use the global tick value."""
|
|
factor = self.parentScene._tracks[self.parentScene.parentView.parentMainWindow.currentTrackId]["patternLengthMultiplicator"]
|
|
x = (tickindex % (self.parentScene.oneMeasureInTicks * factor)) / self.parentScene.ticksToPixelRatio
|
|
x += SIZE_RIGHT_OFFSET
|
|
|
|
numberOfSteps = self.parentScene._tracks[self.parentScene.parentView.parentMainWindow.currentTrackId]["numberOfSteps"]
|
|
self.setLine(0, 0, 0, numberOfSteps * SIZE_UNIT)
|
|
self.setX(x)
|
|
#scenePos = self.parentScene.parentView.mapFromScene(self.pos())
|
|
#cursorViewPosX = scenePos.x() #the cursor position in View coordinates
|
|
#width = self.parentScene.parentView.geometry().width()
|
|
|
|
if playbackStatus: # api.duringPlayback:
|
|
self.setOpacity(1)
|
|
if self.parentScene.parentView.parentMainWindow.ui.actionPatternFollowPlayhead.isChecked():
|
|
self.parentScene.parentView.centerOn(x, 0)
|
|
|
|
else:
|
|
#self.hide() #This was before 2.0 when we only had short patterns. But now we need to see where we are. Lower opacity instead.:
|
|
self.setOpacity(0.5)
|
|
|
|
|
|
class VelocityControls(QtWidgets.QWidget):
|
|
def __init__(self, mainWindow, patternScene):
|
|
super().__init__()
|
|
|
|
self.parentScene = patternScene
|
|
self.mainWindow = mainWindow
|
|
self.statusMessage = self.parentScene.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
|
|
|
|
|
|
layout = QtWidgets.QHBoxLayout()
|
|
layout.setSpacing(0)
|
|
layout.setContentsMargins(0,0,0,0)
|
|
self.setLayout(layout)
|
|
|
|
velocityUp = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("VelocityControls", "+Velocity"))
|
|
velocityUp.clicked.connect(self.velocityUp)
|
|
velocityUp.wheelEvent = self._mouseWheelEvent
|
|
velocityUp.setToolTip(QtCore.QCoreApplication.translate("VelocityControls", "Make everything louder. Hover and mousewheel up/down to go in steps of 10."))
|
|
layout.addWidget(velocityUp)
|
|
|
|
velocityDown = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("VelocityControls", "-Velocity"))
|
|
velocityDown.clicked.connect(self.velocityDown)
|
|
velocityDown.wheelEvent = self._mouseWheelEvent
|
|
velocityDown.setToolTip(QtCore.QCoreApplication.translate("VelocityControls", "Make everything softer. Hover and mousewheel up/down to go in steps of 10."))
|
|
layout.addWidget(velocityDown)
|
|
|
|
def _mouseWheelEvent(self, event):
|
|
event.accept()
|
|
if event.angleDelta().y() > 0: #up
|
|
api.changePatternVelocity(trackId=self.mainWindow.currentTrackId, steps=10)
|
|
else: #down
|
|
api.changePatternVelocity(trackId=self.mainWindow.currentTrackId, steps=-10)
|
|
#The steps got redrawn, therefore their velocity numbers are now invislbe again. We are still in hover modus:
|
|
self.parentScene.showVelocities()
|
|
|
|
def enterEvent(self, event):
|
|
self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Click to change volume for all notes in single steps, spin mouse wheel to change in steps of 10."))
|
|
self.parentScene.showVelocities()
|
|
|
|
def leaveEvent(self, event):
|
|
self.statusMessage("")
|
|
self.parentScene.hideVelocities()
|
|
|
|
def velocityUp(self):
|
|
api.changePatternVelocity(trackId=self.mainWindow.currentTrackId, steps=1)
|
|
#The steps got redrawn, therefore their velocity numbers are now invislbe again. We are still in hover modus:
|
|
self.parentScene.showVelocities()
|
|
|
|
def velocityDown(self):
|
|
api.changePatternVelocity(trackId=self.mainWindow.currentTrackId, steps=-1)
|
|
#The steps got redrawn, therefore their velocity numbers are now invislbe again. We are still in hover modus:
|
|
self.parentScene.showVelocities()
|
|
|