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.
 
 

914 lines
41 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2019, 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/>.
"""
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._steps = {} # (x,y):Step()
self._labels = [] #Step numbers
self._zoomFactor = 1 # no save. We don't keep a qt config.
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.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)
def callback_timeSignatureChanged(self, howMany, typeInTicks):
"""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 _redrawSteps(self, howMany):
"""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()
#Build a two dimensional grid
for column in range(howMany):
for row in range(api.NUMBER_OF_STEPS):
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 guicallback_chooseCurrentTrack(self, exportDict):
"""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.
updateMode = self.parentView.viewportUpdateMode() #prevent iteration flickering from color changes.
self.parentView.setViewportUpdateMode(self.parentView.NoViewportUpdate)
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.removeShadows()
self.parentView.setViewportUpdateMode(updateMode)
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.
"""
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)
for step in self._steps.values():
step.off()
for noteDict in exportDict["pattern"]:
x = noteDict["index"]
y = noteDict["pitch"]
velocityAndFactor = (noteDict["velocity"], noteDict["factor"])
self._steps[(x,y)].on(velocityAndFactor=velocityAndFactor, exceedsPlayback=noteDict["exceedsPlayback"])
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.
"""
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"""
#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
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):
for patternStep in self._steps.values():
patternStep.velocityNumber.hide()
def mousePressEvent(self, event):
self._middleMouseDown = False
if event.button() == QtCore.Qt.MiddleButton:
self._middleMouseDown = True
self._lastRow = None
self._play(event)
event.accept()
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
if ( row < 0 or row > 7 ) 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.
"""
for x, y in ((s["index"], s["pitch"]) for s in exportDict["pattern"]):
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.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
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
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:
setWidth()
assert self.parentScene.currentColor
self.setBrush(self.parentScene.currentColor)
self.velocityNumber.setBrush(self.parentScene.labelColor)
self.setZValue(_zValuesRelativeToScene["step"])
else:
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}
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.initalized = True
def on(self, velocityAndFactor=None, exceedsPlayback=None):
"""velocityAndFactor is a tuple"""
if velocityAndFactor: #on load / by callback
self.velocity, self.factor = velocityAndFactor
else: #User clicked on an empty field.
self.useDefaultValues()
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() #just in case.
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._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._rememberVelocity = self.velocity
else:
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"""
self.velocityNumber.hide()
if self.status:
event.accept()
if self.status and not self.velocity == self._rememberVelocity:
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 <= 2:
self.velocity = 1
else:
event.ignore()
class Scale(QtWidgets.QGraphicsRectItem):
def __init__(self, parentScene):
super().__init__(0,0,0,0)
self.parentScene = parentScene
self.pitchWidgets = [] #sorted from top to bottom in Step Rect and scene coordinates
self.simpleNoteNames = [] #list of 128 notes. use index with note name. Can be changed at runtime.
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
self.buildScale() #also sets the positions of the buttons above
def callback_trackMetaDataChanged(self, exportDict):
#Order matters. We need to set the notenames before the scale.
self.setNoteNames(exportDict["simpleNoteNames"])
self.setScale(exportDict["scale"])
def buildScale(self):
"""Only executed once per pattern"""
for i in range(api.NUMBER_OF_STEPS):
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.spinBox.setValue(scaleMidiPitch)
widget.rememberLastValue = scaleMidiPitch
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]
#result.reverse()
trackId = self.parentScene.parentView.parentMainWindow.currentTrackId
if trackId: #startup check
api.setScale(trackId, scale=result, callback=callback)
class TransposeControls(QtWidgets.QWidget):
"""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__()
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)
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):
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
def midiToNotename(self, midipitch):
assert self.parentItem.simpleNoteNames, self.parentItem.simpleNoteNames
try:
return self.parentItem.simpleNoteNames[midipitch] #includes octave names
except IndexError:
print (midipitch)
print (self.parentItem.simpleNoteNames)
exit()
def spinBoxValueChanged(self):
self.label.setText(self.midiToNotename(self.spinBox.value()))
#self.parentItem.sendToEngine(callback=False) # results in a loop with callback, and in wrong data without. This is not the right place to implement immediate note feedback while editing is still going on.
def spinBoxEditingFinished(self):
if not self.rememberLastValue == self.spinBox.value():
self.parentItem.sendToEngine()
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, api.NUMBER_OF_STEPS*SIZE_UNIT) # (x1, y1, x2, y2)
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."""
x = (tickindex % self.parentScene.oneMeasureInTicks) / self.parentScene.ticksToPixelRatio
x += SIZE_RIGHT_OFFSET
if playbackStatus: # api.duringPlayback:
self.show()
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 cursorViewPosX <= 0 or cursorViewPosX >= width: #"pageflip"
self.parentScene.parentView.horizontalScrollBar().setValue(x)
else:
self.hide()
class VelocityControls(QtWidgets.QWidget):
def __init__(self, mainWindow, patternScene):
super().__init__()
self.parentScene = patternScene
self.mainWindow = mainWindow
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.parentScene.showVelocities()
def leaveEvent(self, event):
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()